IAM "max usage" feature

This commit is contained in:
Fabian @ Blax Software 2026-05-19 14:01:52 +02:00
parent 6d22e130ed
commit 4712133eac
9 changed files with 835 additions and 2 deletions

View File

@ -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.

View File

@ -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);
}
});
}
};

View File

@ -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."
);
}
}

View File

@ -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."
);
}
}

View File

@ -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:0016: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,
];
}
}

View File

@ -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,

View File

@ -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,
];
}

View File

@ -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'];
}

View File

@ -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();
}
}