From 4712133eacb2535907fdd3d374bb299f0cb4f07a Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Tue, 19 May 2026 14:01:52 +0200 Subject: [PATCH] IAM "max usage" feature --- ...5_01_01_000001_create_blax_shop_tables.php | 5 + ..._per_cart_and_max_per_user_to_products.php | 61 +++ src/Exceptions/ExceedsMaxPerCartException.php | 35 ++ src/Exceptions/ExceedsMaxPerUserException.php | 47 ++ src/Models/Cart.php | 158 +++++- src/Models/Product.php | 8 + src/Traits/HasStocks.php | 40 +- tests/Feature/Cart/CartPurchaseLimitsTest.php | 480 ++++++++++++++++++ tests/TestCase.php | 3 + 9 files changed, 835 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_01_01_000002_add_max_per_cart_and_max_per_user_to_products.php create mode 100644 src/Exceptions/ExceedsMaxPerCartException.php create mode 100644 src/Exceptions/ExceedsMaxPerUserException.php create mode 100644 tests/Feature/Cart/CartPurchaseLimitsTest.php diff --git a/database/migrations/2025_01_01_000001_create_blax_shop_tables.php b/database/migrations/2025_01_01_000001_create_blax_shop_tables.php index b0a6cf5..f6b58e1 100644 --- a/database/migrations/2025_01_01_000001_create_blax_shop_tables.php +++ b/database/migrations/2025_01_01_000001_create_blax_shop_tables.php @@ -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. diff --git a/database/migrations/2026_01_01_000002_add_max_per_cart_and_max_per_user_to_products.php b/database/migrations/2026_01_01_000002_add_max_per_cart_and_max_per_user_to_products.php new file mode 100644 index 0000000..427e82b --- /dev/null +++ b/database/migrations/2026_01_01_000002_add_max_per_cart_and_max_per_user_to_products.php @@ -0,0 +1,61 @@ +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); + } + }); + } +}; diff --git a/src/Exceptions/ExceedsMaxPerCartException.php b/src/Exceptions/ExceedsMaxPerCartException.php new file mode 100644 index 0000000..91d59f9 --- /dev/null +++ b/src/Exceptions/ExceedsMaxPerCartException.php @@ -0,0 +1,35 @@ +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, + * dates_partial: list, + * partial_windows: array>, * }>, * } */ @@ -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, ]; } } diff --git a/src/Models/Product.php b/src/Models/Product.php index edaf856..556e5a0 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -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, diff --git a/src/Traits/HasStocks.php b/src/Traits/HasStocks.php index f0fa390..4f8f0bf 100644 --- a/src/Traits/HasStocks.php +++ b/src/Traits/HasStocks.php @@ -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, ]; } diff --git a/tests/Feature/Cart/CartPurchaseLimitsTest.php b/tests/Feature/Cart/CartPurchaseLimitsTest.php new file mode 100644 index 0000000..ff07d41 --- /dev/null +++ b/tests/Feature/Cart/CartPurchaseLimitsTest.php @@ -0,0 +1,480 @@ +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']; +} diff --git a/tests/TestCase.php b/tests/TestCase.php index dee9c3b..5eb2c33 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -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(); } }