IAM "max usage" feature
This commit is contained in:
parent
6d22e130ed
commit
4712133eac
|
|
@ -29,6 +29,11 @@ return new class extends Migration
|
|||
// is intentionally no denormalised column on products. See
|
||||
// HasStocks::getAvailableStock() for the canonical read.
|
||||
$table->integer('low_stock_threshold')->nullable();
|
||||
// Per-product purchase caps. NULL = unlimited (the historical
|
||||
// default). Enforced in Cart::addToCart() — see
|
||||
// ExceedsMaxPerCartException / ExceedsMaxPerUserException.
|
||||
$table->integer('max_per_cart')->nullable();
|
||||
$table->integer('max_per_user')->nullable();
|
||||
// Live stock state (in-stock?, status) is computed from the
|
||||
// ProductStock ledger — see HasStocks::isInStock / scopeInStock.
|
||||
// No denormalised columns on products.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Add purchase-limit columns to `products`:
|
||||
*
|
||||
* - `max_per_cart`: maximum quantity of this product allowed in a single
|
||||
* cart at once. NULL = unlimited (the default — preserves existing
|
||||
* behaviour for every already-seeded product).
|
||||
* - `max_per_user`: maximum quantity a single customer may ever buy across
|
||||
* all their orders + their currently-open cart. NULL = unlimited.
|
||||
*
|
||||
* Both are nullable signed integers because "no cap" is the most common
|
||||
* configuration and treating 0 as "no cap" would be a foot-gun (an admin
|
||||
* setting `max_per_cart = 0` clearly means "do not sell," not "unlimited").
|
||||
* Enforcement lives in {@see \Blax\Shop\Models\Cart::addToCart()}.
|
||||
*/
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$table = config('shop.tables.products', 'products');
|
||||
|
||||
if (!Schema::hasTable($table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table($table, function (Blueprint $t) use ($table) {
|
||||
if (!Schema::hasColumn($table, 'max_per_cart')) {
|
||||
$t->integer('max_per_cart')->nullable()->after('low_stock_threshold');
|
||||
}
|
||||
if (!Schema::hasColumn($table, 'max_per_user')) {
|
||||
$t->integer('max_per_user')->nullable()->after('max_per_cart');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$table = config('shop.tables.products', 'products');
|
||||
|
||||
if (!Schema::hasTable($table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table($table, function (Blueprint $t) use ($table) {
|
||||
$cols = [];
|
||||
if (Schema::hasColumn($table, 'max_per_cart')) {
|
||||
$cols[] = 'max_per_cart';
|
||||
}
|
||||
if (Schema::hasColumn($table, 'max_per_user')) {
|
||||
$cols[] = 'max_per_user';
|
||||
}
|
||||
if (!empty($cols)) {
|
||||
$t->dropColumn($cols);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Thrown when adding the requested quantity to a cart would push the total
|
||||
* quantity of a single product above its configured `max_per_cart` limit.
|
||||
*
|
||||
* Raised from {@see \Blax\Shop\Models\Cart::addToCart()}. Cart-scope only —
|
||||
* the cross-purchase cap is enforced by {@see ExceedsMaxPerUserException}.
|
||||
*/
|
||||
class ExceedsMaxPerCartException extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message = 'Adding this quantity would exceed the per-cart limit for this product.',
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public static function forProduct(string $productName, int $max, int $alreadyInCart, int $requested): self
|
||||
{
|
||||
$remaining = max(0, $max - $alreadyInCart);
|
||||
return new self(
|
||||
"Product '{$productName}' allows a maximum of {$max} per cart. " .
|
||||
"You already have {$alreadyInCart} in the cart and requested {$requested}. " .
|
||||
"You may add up to {$remaining} more."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Thrown when adding the requested quantity to a cart would push the
|
||||
* customer's lifetime purchase total (already-purchased + current cart)
|
||||
* above the product's configured `max_per_user` limit.
|
||||
*
|
||||
* "Already purchased" is sourced from {@see \Blax\Shop\Models\ProductPurchase}
|
||||
* rows in any non-cancelled status (PENDING, UNPAID, COMPLETED). Guest carts
|
||||
* — i.e. carts without a `customer_id` — are not subject to this cap because
|
||||
* there's no identity to count against; the cap kicks in once the cart is
|
||||
* attached to a user.
|
||||
*
|
||||
* Raised from {@see \Blax\Shop\Models\Cart::addToCart()}.
|
||||
*/
|
||||
class ExceedsMaxPerUserException extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message = 'Adding this quantity would exceed the per-user purchase limit for this product.',
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public static function forProduct(
|
||||
string $productName,
|
||||
int $max,
|
||||
int $alreadyPurchased,
|
||||
int $alreadyInCart,
|
||||
int $requested
|
||||
): self {
|
||||
$consumed = $alreadyPurchased + $alreadyInCart;
|
||||
$remaining = max(0, $max - $consumed);
|
||||
return new self(
|
||||
"Product '{$productName}' allows a maximum of {$max} per customer. " .
|
||||
"You've already purchased {$alreadyPurchased} and have {$alreadyInCart} in the cart " .
|
||||
"(requested {$requested}). You may add up to {$remaining} more."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ use Blax\Shop\Exceptions\CartAlreadyConvertedException;
|
|||
use Blax\Shop\Exceptions\CartDatesRequiredException;
|
||||
use Blax\Shop\Exceptions\CartEmptyException;
|
||||
use Blax\Shop\Exceptions\CartItemMissingInformationException;
|
||||
use Blax\Shop\Exceptions\ExceedsMaxPerCartException;
|
||||
use Blax\Shop\Exceptions\ExceedsMaxPerUserException;
|
||||
use Blax\Shop\Exceptions\InvalidDateRangeException;
|
||||
use Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException;
|
||||
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||
|
|
@ -1030,6 +1032,12 @@ class Cart extends Model
|
|||
}
|
||||
}
|
||||
|
||||
// Enforce per-cart and per-user purchase caps before any pool
|
||||
// recursion / stock validation kicks in. Cheaper to fail early, and
|
||||
// the recursive pool path repeats one-unit calls — without an early
|
||||
// bail the cap message would only surface once the recursion hit the
|
||||
// (more expensive) stock check.
|
||||
$this->enforcePurchaseLimits($cartable, $quantity);
|
||||
|
||||
// For pool products with quantity > 1, add them one at a time to get progressive pricing
|
||||
if ($is_pool && $quantity > 1) {
|
||||
|
|
@ -1391,6 +1399,96 @@ class Cart extends Model
|
|||
return $cartItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the `max_per_cart` and `max_per_user` caps declared on the
|
||||
* product (if any) before the item is actually added.
|
||||
*
|
||||
* `max_per_cart` counts every quantity of this product already living in
|
||||
* the cart, regardless of dates / parameters / which single-item a pool
|
||||
* allocation picked. It's a flat "you can have at most N of this thing
|
||||
* in your cart" rule — the simplest mental model for shop admins.
|
||||
*
|
||||
* `max_per_user` is summed across the customer's already-placed
|
||||
* {@see ProductPurchase} rows (any status except CART/FAILED — pending
|
||||
* and unpaid still count, otherwise a customer could spam an unpaid
|
||||
* order to bypass the cap) PLUS what's currently in the cart. Guest
|
||||
* carts (`customer_id = null`) are not subject to the per-user cap,
|
||||
* because there is no identity to count against.
|
||||
*
|
||||
* Both caps are skipped silently when the cartable is not a Product
|
||||
* instance — non-Product cartables (e.g. host-app models using
|
||||
* IsSimplePurchasable) do not own these columns.
|
||||
*
|
||||
* @throws ExceedsMaxPerCartException
|
||||
* @throws ExceedsMaxPerUserException
|
||||
*/
|
||||
protected function enforcePurchaseLimits(Model $cartable, int $quantity): void
|
||||
{
|
||||
// Only Product owns these columns. ProductPrice etc. inherit caps
|
||||
// from the underlying product, so resolve through .purchasable when
|
||||
// a price was passed directly.
|
||||
$product = match (true) {
|
||||
$cartable instanceof Product => $cartable,
|
||||
$cartable instanceof ProductPrice && $cartable->purchasable instanceof Product => $cartable->purchasable,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (!$product) {
|
||||
return;
|
||||
}
|
||||
|
||||
$maxPerCart = $product->max_per_cart;
|
||||
$maxPerUser = $product->max_per_user;
|
||||
|
||||
if ($maxPerCart === null && $maxPerUser === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Count what's already in this cart for the same product. Pool
|
||||
// products may be split across multiple cart_items (different
|
||||
// single-item allocations / price tiers), so we sum by
|
||||
// purchasable_id + purchasable_type rather than by cart_item.id.
|
||||
$alreadyInCart = (int) $this->items()
|
||||
->where('purchasable_id', $product->getKey())
|
||||
->where('purchasable_type', get_class($product))
|
||||
->sum('quantity');
|
||||
|
||||
if ($maxPerCart !== null && ($alreadyInCart + $quantity) > $maxPerCart) {
|
||||
throw ExceedsMaxPerCartException::forProduct(
|
||||
(string) $product->name,
|
||||
$maxPerCart,
|
||||
$alreadyInCart,
|
||||
$quantity
|
||||
);
|
||||
}
|
||||
|
||||
if ($maxPerUser !== null && $this->customer_id && $this->customer_type) {
|
||||
// PENDING / UNPAID still count — only carts (not yet committed)
|
||||
// and explicitly failed payments are excluded. This prevents the
|
||||
// common bypass of "place 10 unpaid orders to dodge the cap."
|
||||
$alreadyPurchased = (int) ProductPurchase::query()
|
||||
->where('purchaser_id', $this->customer_id)
|
||||
->where('purchaser_type', $this->customer_type)
|
||||
->where('purchasable_id', $product->getKey())
|
||||
->where('purchasable_type', get_class($product))
|
||||
->whereNotIn('status', [
|
||||
PurchaseStatus::CART->value,
|
||||
PurchaseStatus::FAILED->value,
|
||||
])
|
||||
->sum('quantity');
|
||||
|
||||
if (($alreadyPurchased + $alreadyInCart + $quantity) > $maxPerUser) {
|
||||
throw ExceedsMaxPerUserException::forProduct(
|
||||
(string) $product->name,
|
||||
$maxPerUser,
|
||||
$alreadyPurchased,
|
||||
$alreadyInCart,
|
||||
$quantity
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function removeFromCart(
|
||||
Model $cartable,
|
||||
int $quantity = 1,
|
||||
|
|
@ -1679,6 +1777,17 @@ class Cart extends Model
|
|||
* - `dates_unavailable[]` — ISO dates within the visible window
|
||||
* where this single item is blocked. The frontend unions these
|
||||
* across items to paint red dots / hover tooltips.
|
||||
* - `dates_partial[]` — ISO dates within the visible window where
|
||||
* the day STARTS or ENDS with enough stock for this item but
|
||||
* drops below the required quantity at some point inside the
|
||||
* day (e.g. someone booked from 16:00 → the morning is still
|
||||
* bookable). Drives the orange "partially available" dot on
|
||||
* the checkout calendar.
|
||||
* - `partial_windows[iso]` — list of `{from: 'HH:MM', until: 'HH:MM'}`
|
||||
* windows on each partial day during which stock is sufficient
|
||||
* for this item. Lets the cart line render copy like "Bookable
|
||||
* only from 06:00–16:00" so the customer understands which
|
||||
* slice of the day still works.
|
||||
*
|
||||
* Performance: `$searchDays` bounds the closest-date search to a few
|
||||
* months each side of the visible window. The default of 90 keeps the
|
||||
|
|
@ -1697,6 +1806,8 @@ class Cart extends Model
|
|||
* closest_after: ?string,
|
||||
* ever_available: bool,
|
||||
* dates_unavailable: list<string>,
|
||||
* dates_partial: list<string>,
|
||||
* partial_windows: array<string, list<array{from: string, until: string}>>,
|
||||
* }>,
|
||||
* }
|
||||
*/
|
||||
|
|
@ -1740,6 +1851,10 @@ class Cart extends Model
|
|||
foreach (($availability['items'] ?? []) as $itemBlock) {
|
||||
$required = (int) ($itemBlock['required_quantity'] ?? 1);
|
||||
$dayRows = $itemBlock['availability']['dates'] ?? [];
|
||||
// Parallel intraday-transitions map — present only for days
|
||||
// where stock dips during the day (the underlying
|
||||
// calendarAvailability() omits the key for uniform days).
|
||||
$transitionsByDay = $itemBlock['availability']['transitions'] ?? [];
|
||||
|
||||
$cartItemIds = $this->items
|
||||
->where('purchasable_id', $itemBlock['product_id'])
|
||||
|
|
@ -1812,10 +1927,49 @@ class Cart extends Model
|
|||
}
|
||||
|
||||
$datesUnavailable = [];
|
||||
$datesPartial = [];
|
||||
$partialWindows = [];
|
||||
foreach ($dayRows as $iso => $row) {
|
||||
if ($iso < $visibleStartIso || $iso > $visibleEndIso) continue;
|
||||
if (($row['max'] ?? 0) < $required) {
|
||||
$max = $row['max'] ?? 0;
|
||||
$min = $row['min'] ?? 0;
|
||||
if ($max < $required) {
|
||||
// Day NEVER reaches required stock — fully blocked.
|
||||
$datesUnavailable[] = $iso;
|
||||
} elseif ($min < $required) {
|
||||
// Day has at least one moment with enough stock and at
|
||||
// least one moment without — partial availability. We
|
||||
// derive bookable windows from the per-day transitions
|
||||
// when the underlying calendarAvailability exposed them
|
||||
// (only present on partial days, by design).
|
||||
$datesPartial[] = $iso;
|
||||
$transitions = $transitionsByDay[$iso] ?? [];
|
||||
if (!empty($transitions)) {
|
||||
$windows = [];
|
||||
$windowFrom = null;
|
||||
foreach ($transitions as $t) {
|
||||
$available = (int) ($t['available'] ?? 0);
|
||||
$time = (string) ($t['time'] ?? '00:00');
|
||||
$isOk = $available >= $required;
|
||||
if ($isOk && $windowFrom === null) {
|
||||
$windowFrom = $time;
|
||||
} elseif (!$isOk && $windowFrom !== null) {
|
||||
// Skip zero-length windows that show up when
|
||||
// two transitions land on the same minute.
|
||||
if ($windowFrom !== $time) {
|
||||
$windows[] = ['from' => $windowFrom, 'until' => $time];
|
||||
}
|
||||
$windowFrom = null;
|
||||
}
|
||||
}
|
||||
if ($windowFrom !== null) {
|
||||
// Open window runs to end-of-day.
|
||||
$windows[] = ['from' => $windowFrom, 'until' => '23:59'];
|
||||
}
|
||||
if (!empty($windows)) {
|
||||
$partialWindows[$iso] = $windows;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1830,6 +1984,8 @@ class Cart extends Model
|
|||
'closest_after' => $closestAfter,
|
||||
'ever_available' => $everAvailable,
|
||||
'dates_unavailable' => $datesUnavailable,
|
||||
'dates_partial' => $datesPartial,
|
||||
'partial_windows' => $partialWindows,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ use Illuminate\Support\Facades\Cache;
|
|||
* @property \Illuminate\Support\Carbon|null $sale_end
|
||||
* @property bool $manage_stock
|
||||
* @property int|null $low_stock_threshold
|
||||
* @property int|null $max_per_cart
|
||||
* @property int|null $max_per_user
|
||||
* @property float|null $weight
|
||||
* @property float|null $length
|
||||
* @property float|null $width
|
||||
|
|
@ -103,6 +105,8 @@ class Product extends Model implements Purchasable, Cartable
|
|||
'sale_end',
|
||||
'manage_stock',
|
||||
'low_stock_threshold',
|
||||
'max_per_cart',
|
||||
'max_per_user',
|
||||
'weight',
|
||||
'length',
|
||||
'width',
|
||||
|
|
@ -135,6 +139,8 @@ class Product extends Model implements Purchasable, Cartable
|
|||
'featured' => 'boolean',
|
||||
'is_visible' => 'boolean',
|
||||
'low_stock_threshold' => 'integer',
|
||||
'max_per_cart' => 'integer',
|
||||
'max_per_user' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
|
|
@ -546,6 +552,8 @@ class Product extends Model implements Purchasable, Cartable
|
|||
'sale_price' => $this->sale_price,
|
||||
'is_on_sale' => $this->isOnSale(),
|
||||
'low_stock' => $this->isLowStock(),
|
||||
'max_per_cart' => $this->max_per_cart,
|
||||
'max_per_user' => $this->max_per_user,
|
||||
'featured' => $this->featured,
|
||||
'virtual' => $this->virtual,
|
||||
'downloadable' => $this->downloadable,
|
||||
|
|
|
|||
|
|
@ -814,6 +814,14 @@ trait HasStocks
|
|||
->get();
|
||||
|
||||
$dates = [];
|
||||
// Per-day intraday transitions, keyed by `YYYY-MM-DD`. Kept as a
|
||||
// SEPARATE top-level field — rather than nesting it inside each
|
||||
// `$dates[$iso]` row — so that consumers asserting strict
|
||||
// equality against the day row (`['min' => x, 'max' => y]`)
|
||||
// keep passing. Only populated for days where availability
|
||||
// varies within the day (min < max); fully uniform days don't
|
||||
// need transitions and the absent key carries that semantic.
|
||||
$transitionsByDay = [];
|
||||
$globalMax = PHP_INT_MIN;
|
||||
$globalMin = PHP_INT_MAX;
|
||||
|
||||
|
|
@ -846,6 +854,12 @@ trait HasStocks
|
|||
|
||||
$dayMin = PHP_INT_MAX;
|
||||
$dayMax = PHP_INT_MIN;
|
||||
// Time-ordered series of (HH:MM => available units) for the
|
||||
// events visited in this day. We surface it only when the day
|
||||
// is non-uniform (min < max) so downstream consumers — the
|
||||
// checkout calendar's partial-day hint, in particular — can
|
||||
// describe WHEN the item is bookable inside the day.
|
||||
$dayTransitions = [];
|
||||
|
||||
// Check availability at each event timestamp to find min/max for the day
|
||||
$eventDayEnd = $dayEnd->copy();
|
||||
|
|
@ -888,12 +902,35 @@ trait HasStocks
|
|||
$available = max(0, $available);
|
||||
$dayMin = min($dayMin, $available);
|
||||
$dayMax = max($dayMax, $available);
|
||||
$dayTransitions[] = [
|
||||
'time' => $eventTime->format('H:i'),
|
||||
'available' => $available,
|
||||
];
|
||||
}
|
||||
|
||||
$dates[$currentDate->toDateString()] = [
|
||||
$iso = $currentDate->toDateString();
|
||||
$dates[$iso] = [
|
||||
'min' => $dayMin,
|
||||
'max' => $dayMax,
|
||||
];
|
||||
if ($dayMin < $dayMax && !empty($dayTransitions)) {
|
||||
// Sort time-ascending and collapse same-minute duplicates
|
||||
// by keeping the LAST sample (later transitions on the same
|
||||
// minute reflect the post-event state — e.g. a booking
|
||||
// that starts at 16:00 leaves "available@16:00" as the
|
||||
// already-reduced count, which is what the customer needs
|
||||
// to see).
|
||||
usort($dayTransitions, fn ($a, $b) => strcmp($a['time'], $b['time']));
|
||||
$deduped = [];
|
||||
foreach ($dayTransitions as $t) {
|
||||
$deduped[$t['time']] = $t['available'];
|
||||
}
|
||||
$transitionsOut = [];
|
||||
foreach ($deduped as $time => $available) {
|
||||
$transitionsOut[] = ['time' => $time, 'available' => $available];
|
||||
}
|
||||
$transitionsByDay[$iso] = $transitionsOut;
|
||||
}
|
||||
|
||||
$globalMin = min($globalMin, $dayMin);
|
||||
$globalMax = max($globalMax, $dayMax);
|
||||
|
|
@ -905,6 +942,7 @@ trait HasStocks
|
|||
'max_available' => $globalMax === PHP_INT_MIN ? 0 : $globalMax,
|
||||
'min_available' => $globalMin === PHP_INT_MAX ? 0 : $globalMin,
|
||||
'dates' => $dates,
|
||||
'transitions' => $transitionsByDay,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,480 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Tests\Feature\Cart;
|
||||
|
||||
use Blax\Shop\Contracts\Cartable;
|
||||
use Blax\Shop\Contracts\Purchasable;
|
||||
use Blax\Shop\Enums\ProductRelationType;
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
use Blax\Shop\Enums\PurchaseStatus;
|
||||
use Blax\Shop\Exceptions\ExceedsMaxPerCartException;
|
||||
use Blax\Shop\Exceptions\ExceedsMaxPerUserException;
|
||||
use Blax\Shop\Models\Cart;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Models\ProductPrice;
|
||||
use Blax\Shop\Models\ProductPurchase;
|
||||
use Blax\Shop\Tests\TestCase;
|
||||
use Blax\Shop\Traits\IsSimplePurchasable;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Workbench\App\Models\User;
|
||||
|
||||
/**
|
||||
* Covers the `max_per_cart` and `max_per_user` purchase caps configured on
|
||||
* {@see Product} and enforced in {@see Cart::addToCart()}.
|
||||
*
|
||||
* What we care about:
|
||||
* - NULL on both columns keeps the historical behaviour (no cap).
|
||||
* - max_per_cart counts total in-cart quantity, not cart items.
|
||||
* - max_per_cart respects existing items when a second add bumps over.
|
||||
* - max_per_user only applies to identified customers (not guest carts).
|
||||
* - max_per_user counts already-placed purchases AND current cart, but
|
||||
* skips CART / FAILED rows so the cap can't be bypassed by accumulating
|
||||
* dead rows.
|
||||
* - Pool products go through the recursive single-unit path; the cap must
|
||||
* still fire even when the request is split into per-unit adds.
|
||||
* - Different customers under the same product see independent counters.
|
||||
*/
|
||||
class CartPurchaseLimitsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
if (!Schema::hasTable('limit_widgets')) {
|
||||
Schema::create('limit_widgets', function ($table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function productWithCaps(?int $maxPerCart = null, ?int $maxPerUser = null, int $price = 1000): Product
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'name' => 'Capped Product',
|
||||
'manage_stock' => false,
|
||||
'max_per_cart' => $maxPerCart,
|
||||
'max_per_user' => $maxPerUser,
|
||||
]);
|
||||
|
||||
ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'unit_amount' => $price,
|
||||
'currency' => 'EUR',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
return $product->fresh();
|
||||
}
|
||||
|
||||
private function userCart(): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$cart = Cart::create([
|
||||
'customer_type' => get_class($user),
|
||||
'customer_id' => $user->id,
|
||||
]);
|
||||
return [$user, $cart];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// max_per_cart
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[Test]
|
||||
public function null_caps_keep_unlimited_behaviour(): void
|
||||
{
|
||||
$product = $this->productWithCaps(maxPerCart: null, maxPerUser: null);
|
||||
$cart = Cart::create();
|
||||
|
||||
$cart->addToCart($product, quantity: 50);
|
||||
|
||||
$this->assertSame(50, (int) $cart->fresh()->items->sum('quantity'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_blocks_a_single_add_that_exceeds_max_per_cart(): void
|
||||
{
|
||||
$product = $this->productWithCaps(maxPerCart: 3);
|
||||
$cart = Cart::create();
|
||||
|
||||
$this->expectException(ExceedsMaxPerCartException::class);
|
||||
|
||||
$cart->addToCart($product, quantity: 4);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_allows_adding_up_to_the_max_per_cart(): void
|
||||
{
|
||||
$product = $this->productWithCaps(maxPerCart: 3);
|
||||
$cart = Cart::create();
|
||||
|
||||
$cart->addToCart($product, quantity: 3);
|
||||
|
||||
$this->assertSame(3, (int) $cart->fresh()->items->sum('quantity'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_blocks_a_second_add_that_would_exceed_max_per_cart(): void
|
||||
{
|
||||
$product = $this->productWithCaps(maxPerCart: 3);
|
||||
$cart = Cart::create();
|
||||
|
||||
$cart->addToCart($product, quantity: 2);
|
||||
|
||||
$this->expectException(ExceedsMaxPerCartException::class);
|
||||
$cart->addToCart($product, quantity: 2); // 2 + 2 = 4 > 3
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exception_message_reports_the_correct_remaining_quantity(): void
|
||||
{
|
||||
$product = $this->productWithCaps(maxPerCart: 5);
|
||||
$cart = Cart::create();
|
||||
$cart->addToCart($product, quantity: 4);
|
||||
|
||||
try {
|
||||
$cart->addToCart($product, quantity: 3);
|
||||
$this->fail('Expected ExceedsMaxPerCartException was not thrown');
|
||||
} catch (ExceedsMaxPerCartException $e) {
|
||||
$this->assertStringContainsString('maximum of 5 per cart', $e->getMessage());
|
||||
$this->assertStringContainsString('already have 4', $e->getMessage());
|
||||
$this->assertStringContainsString('up to 1 more', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function caps_are_per_product_not_global(): void
|
||||
{
|
||||
$productA = $this->productWithCaps(maxPerCart: 2);
|
||||
$productB = $this->productWithCaps(maxPerCart: 2);
|
||||
$cart = Cart::create();
|
||||
|
||||
$cart->addToCart($productA, quantity: 2);
|
||||
$cart->addToCart($productB, quantity: 2);
|
||||
|
||||
$this->assertSame(4, (int) $cart->fresh()->items->sum('quantity'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function adding_via_product_price_still_enforces_the_cap(): void
|
||||
{
|
||||
// The Cartable can be a ProductPrice — make sure we resolve through
|
||||
// .purchasable so a price-driven add hits the same enforcement path
|
||||
// as a product-driven one.
|
||||
$product = $this->productWithCaps(maxPerCart: 2);
|
||||
$price = $product->defaultPrice()->first();
|
||||
$cart = Cart::create();
|
||||
|
||||
$this->expectException(ExceedsMaxPerCartException::class);
|
||||
$cart->addToCart($price, quantity: 3);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// max_per_user
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[Test]
|
||||
public function max_per_user_is_skipped_for_guest_carts(): void
|
||||
{
|
||||
// Guest cart has no customer_id, so there's no identity to count
|
||||
// against — the cap is intentionally bypassed in this case.
|
||||
$product = $this->productWithCaps(maxPerUser: 1);
|
||||
$guestCart = Cart::create();
|
||||
|
||||
$guestCart->addToCart($product, quantity: 5);
|
||||
|
||||
$this->assertSame(5, (int) $guestCart->fresh()->items->sum('quantity'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function max_per_user_blocks_when_in_cart_alone_would_exceed(): void
|
||||
{
|
||||
$product = $this->productWithCaps(maxPerUser: 2);
|
||||
[$user, $cart] = $this->userCart();
|
||||
|
||||
$this->expectException(ExceedsMaxPerUserException::class);
|
||||
$cart->addToCart($product, quantity: 3);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function max_per_user_blocks_when_existing_purchases_plus_cart_exceed(): void
|
||||
{
|
||||
$product = $this->productWithCaps(maxPerUser: 3);
|
||||
[$user, $cart] = $this->userCart();
|
||||
|
||||
ProductPurchase::create([
|
||||
'status' => PurchaseStatus::COMPLETED,
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'purchaser_id' => $user->id,
|
||||
'purchaser_type' => get_class($user),
|
||||
'quantity' => 2,
|
||||
'amount' => 2000,
|
||||
'amount_paid' => 2000,
|
||||
]);
|
||||
|
||||
// Already bought 2/3 — adding 2 more would land on 4 > 3.
|
||||
$this->expectException(ExceedsMaxPerUserException::class);
|
||||
$cart->addToCart($product, quantity: 2);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function max_per_user_allows_the_exact_remaining_quantity(): void
|
||||
{
|
||||
$product = $this->productWithCaps(maxPerUser: 3);
|
||||
[$user, $cart] = $this->userCart();
|
||||
|
||||
ProductPurchase::create([
|
||||
'status' => PurchaseStatus::COMPLETED,
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'purchaser_id' => $user->id,
|
||||
'purchaser_type' => get_class($user),
|
||||
'quantity' => 2,
|
||||
'amount' => 2000,
|
||||
'amount_paid' => 2000,
|
||||
]);
|
||||
|
||||
$cart->addToCart($product, quantity: 1); // 2 + 1 = 3 == cap
|
||||
|
||||
$this->assertSame(1, (int) $cart->fresh()->items->sum('quantity'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pending_and_unpaid_purchases_still_count(): void
|
||||
{
|
||||
// Critical: the cap can't be bypassed by accumulating unpaid orders.
|
||||
$product = $this->productWithCaps(maxPerUser: 2);
|
||||
[$user, $cart] = $this->userCart();
|
||||
|
||||
ProductPurchase::create([
|
||||
'status' => PurchaseStatus::PENDING,
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'purchaser_id' => $user->id,
|
||||
'purchaser_type' => get_class($user),
|
||||
'quantity' => 1,
|
||||
'amount' => 1000,
|
||||
]);
|
||||
ProductPurchase::create([
|
||||
'status' => PurchaseStatus::UNPAID,
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'purchaser_id' => $user->id,
|
||||
'purchaser_type' => get_class($user),
|
||||
'quantity' => 1,
|
||||
'amount' => 1000,
|
||||
]);
|
||||
|
||||
$this->expectException(ExceedsMaxPerUserException::class);
|
||||
$cart->addToCart($product, quantity: 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function failed_and_cart_status_purchases_are_not_counted(): void
|
||||
{
|
||||
// Cart rows are not committed purchases; failed rows shouldn't lock
|
||||
// the customer out of trying again with a different payment method.
|
||||
$product = $this->productWithCaps(maxPerUser: 2);
|
||||
[$user, $cart] = $this->userCart();
|
||||
|
||||
ProductPurchase::create([
|
||||
'status' => PurchaseStatus::CART,
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'purchaser_id' => $user->id,
|
||||
'purchaser_type' => get_class($user),
|
||||
'quantity' => 5,
|
||||
'amount' => 5000,
|
||||
]);
|
||||
ProductPurchase::create([
|
||||
'status' => PurchaseStatus::FAILED,
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'purchaser_id' => $user->id,
|
||||
'purchaser_type' => get_class($user),
|
||||
'quantity' => 5,
|
||||
'amount' => 5000,
|
||||
]);
|
||||
|
||||
// None of the rows above count, so the customer can still buy 2.
|
||||
$cart->addToCart($product, quantity: 2);
|
||||
|
||||
$this->assertSame(2, (int) $cart->fresh()->items->sum('quantity'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function caps_are_per_customer_not_global(): void
|
||||
{
|
||||
$product = $this->productWithCaps(maxPerUser: 2);
|
||||
|
||||
[$userA, $cartA] = $this->userCart();
|
||||
[$userB, $cartB] = $this->userCart();
|
||||
|
||||
ProductPurchase::create([
|
||||
'status' => PurchaseStatus::COMPLETED,
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'purchaser_id' => $userA->id,
|
||||
'purchaser_type' => get_class($userA),
|
||||
'quantity' => 2,
|
||||
'amount' => 2000,
|
||||
'amount_paid' => 2000,
|
||||
]);
|
||||
|
||||
// User A is capped, user B is untouched.
|
||||
$cartB->addToCart($product, quantity: 2);
|
||||
$this->assertSame(2, (int) $cartB->fresh()->items->sum('quantity'));
|
||||
|
||||
$this->expectException(ExceedsMaxPerUserException::class);
|
||||
$cartA->addToCart($product, quantity: 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function existing_in_cart_combined_with_purchases_is_summed_correctly(): void
|
||||
{
|
||||
$product = $this->productWithCaps(maxPerUser: 5);
|
||||
[$user, $cart] = $this->userCart();
|
||||
|
||||
ProductPurchase::create([
|
||||
'status' => PurchaseStatus::COMPLETED,
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'purchaser_id' => $user->id,
|
||||
'purchaser_type' => get_class($user),
|
||||
'quantity' => 2,
|
||||
'amount' => 2000,
|
||||
'amount_paid' => 2000,
|
||||
]);
|
||||
|
||||
$cart->addToCart($product, quantity: 2); // total: 4
|
||||
$cart->addToCart($product, quantity: 1); // total: 5 (cap)
|
||||
|
||||
$this->assertSame(3, (int) $cart->fresh()->items->sum('quantity'));
|
||||
|
||||
$this->expectException(ExceedsMaxPerUserException::class);
|
||||
$cart->addToCart($product, quantity: 1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Both caps together
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[Test]
|
||||
public function the_lower_cap_wins_when_both_are_configured(): void
|
||||
{
|
||||
// max_per_user=10 is loose, max_per_cart=2 is the binding constraint.
|
||||
$product = $this->productWithCaps(maxPerCart: 2, maxPerUser: 10);
|
||||
[$user, $cart] = $this->userCart();
|
||||
|
||||
$this->expectException(ExceedsMaxPerCartException::class);
|
||||
$cart->addToCart($product, quantity: 3);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function per_user_can_be_tighter_than_per_cart(): void
|
||||
{
|
||||
$product = $this->productWithCaps(maxPerCart: 100, maxPerUser: 1);
|
||||
[$user, $cart] = $this->userCart();
|
||||
|
||||
$this->expectException(ExceedsMaxPerUserException::class);
|
||||
$cart->addToCart($product, quantity: 2);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pool product interaction (recursive per-unit add path)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[Test]
|
||||
public function pool_products_respect_max_per_cart(): void
|
||||
{
|
||||
// Pool products with quantity > 1 go through addToCart() recursively
|
||||
// (one unit at a time). The cap must fire on the *initial* call
|
||||
// before the recursion expands, otherwise the request would partly
|
||||
// succeed and partly fail — a non-atomic add we want to avoid.
|
||||
$pool = Product::factory()->create([
|
||||
'name' => 'Capped Pool',
|
||||
'type' => ProductType::POOL,
|
||||
'manage_stock' => false,
|
||||
'max_per_cart' => 2,
|
||||
]);
|
||||
|
||||
$single1 = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
]);
|
||||
$single1->increaseStock(5);
|
||||
$single2 = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
]);
|
||||
$single2->increaseStock(5);
|
||||
|
||||
ProductPrice::factory()->create([
|
||||
'purchasable_id' => $single1->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'unit_amount' => 1000,
|
||||
'currency' => 'EUR',
|
||||
'is_default' => true,
|
||||
]);
|
||||
ProductPrice::factory()->create([
|
||||
'purchasable_id' => $single2->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'unit_amount' => 1000,
|
||||
'currency' => 'EUR',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$pool->productRelations()->attach($single1->id, ['type' => ProductRelationType::SINGLE->value]);
|
||||
$pool->productRelations()->attach($single2->id, ['type' => ProductRelationType::SINGLE->value]);
|
||||
|
||||
$cart = Cart::create();
|
||||
|
||||
$this->expectException(ExceedsMaxPerCartException::class);
|
||||
$cart->addToCart($pool, quantity: 3, parameters: [], from: Carbon::tomorrow(), until: Carbon::tomorrow()->addDays(2));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Non-Product cartables
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[Test]
|
||||
public function caps_are_silently_skipped_for_non_product_cartables(): void
|
||||
{
|
||||
// Host-app models using IsSimplePurchasable don't own these columns,
|
||||
// so the enforcement should be a no-op rather than a crash.
|
||||
$cart = Cart::create();
|
||||
$widget = LimitWidget::create(['name' => 'Hyperion']);
|
||||
|
||||
$cart->addToCart($widget, quantity: 99);
|
||||
|
||||
$this->assertSame(99, (int) $cart->fresh()->items->sum('quantity'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-line fixture: smallest valid IsSimplePurchasable host. Used to confirm
|
||||
* the cap enforcement is a no-op for non-Product cartables (they don't own
|
||||
* the max_per_* columns and shouldn't crash by trying to read them).
|
||||
*/
|
||||
class LimitWidget extends Model implements Cartable, Purchasable
|
||||
{
|
||||
use HasUuids;
|
||||
use IsSimplePurchasable;
|
||||
|
||||
protected $table = 'limit_widgets';
|
||||
|
||||
protected $fillable = ['name'];
|
||||
}
|
||||
|
|
@ -67,5 +67,8 @@ abstract class TestCase extends Orchestra
|
|||
|
||||
$migration = include __DIR__ . '/../database/migrations/2025_01_01_000002_create_product_price_tiers_table.php';
|
||||
$migration->up();
|
||||
|
||||
$migration = include __DIR__ . '/../database/migrations/2026_01_01_000002_add_max_per_cart_and_max_per_user_to_products.php';
|
||||
$migration->up();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue