output:
Chunk ID: 6537b2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 11090
Output:
Total output lines: 394

specs/05-BUSINESS-LOGIC.md:12:| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |
specs/05-BUSINESS-LOGIC.md:71:The trait applies a global query scope to every model that uses it. The scope adds a `WHERE store_id = <current_store_id>` clause to all queries automatically.
specs/05-BUSINESS-LOGIC.md:75:- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).
specs/05-BUSINESS-LOGIC.md:80:products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.
specs/05-BUSINESS-LOGIC.md:94:| Manage discounts | Yes | Yes | Yes | No |
specs/05-BUSINESS-LOGIC.md:203:Each `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).
specs/05-BUSINESS-LOGIC.md:300:8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).
specs/05-BUSINESS-LOGIC.md:301:9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.
specs/05-BUSINESS-LOGIC.md:330:line_discount_amount = 0  (updated by discount engine during checkout)
specs/05-BUSINESS-LOGIC.md:331:line_total_amount    = line_subtotal_amount - line_discount_amount
specs/05-BUSINESS-LOGIC.md:361:    Apply discount rules (Section 7)
specs/05-BUSINESS-LOGIC.md:364:    subtotal - total_discount"]
specs/05-BUSINESS-LOGIC.md:368:    Calculated on discounted line amounts
specs/05-BUSINESS-LOGIC.md:371:    discounted_subtotal + shipping + tax_total"]
specs/05-BUSINESS-LOGIC.md:380:| `subtotal` | integer (cents) | Sum of all line subtotals before discount |
specs/05-BUSINESS-LOGIC.md:381:| `discount` | integer (cents) | Total discount amount |
specs/05-BUSINESS-LOGIC.md:385:| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |
specs/05-BUSINESS-LOGIC.md:398:- Each line's discount allocation, tax, and total are rounded independently.
specs/05-BUSINESS-LOGIC.md:401:- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).
specs/05-BUSINESS-LOGIC.md:406:- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.
specs/05-BUSINESS-LOGIC.md:550:       - tax_lines_json, discount_allocations_json
specs/05-BUSINESS-LOGIC.md:562:    9. IF checkout has a discount code:
specs/05-BUSINESS-LOGIC.md:563:       INCREMENT discount's usage_count by 1
specs/05-BUSINESS-LOGIC.md:604:**Enum:** `App\Enums\DiscountType` - values: `code`, `automatic`
specs/05-BUSINESS-LOGIC.md:608:| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |
specs/05-BUSINESS-LOGIC.md:609:| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |
specs/05-BUSINESS-LOGIC.md:612:- Only one code discount per checkout.
specs/05-BUSINESS-LOGIC.md:613:- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).
specs/05-BUSINESS-LOGIC.md:619:| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |
specs/05-BUSINESS-LOGIC.md:622:| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |
specs/05-BUSINESS-LOGIC.md:627:- `active` -> `expired` (automatic)
specs/05-BUSINESS-LOGIC.md:636:| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |
specs/05-BUSINESS-LOGIC.md:637:| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |
specs/05-BUSINESS-LOGIC.md:638:| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |
specs/05-BUSINESS-LOGIC.md:642:Rules are stored in `discounts.rules_json` with the following structure:
specs/05-BUSINESS-LOGIC.md:651:**Applicability logic:** When both `applicable_product_ids` and `applicable_collection_ids` are set, a product qualifies if it matches **either** condition (union, not intersection). When both are null or empty, the discount applies to the entire order.
specs/05-BUSINESS-LOGIC.md:655:**Service:** `App\Services\DiscountService`
specs/05-BUSINESS-LOGIC.md:661:    A[Input: discount code + cart] --> B{Code exists in store?
specs/05-BUSINESS-LOGIC.md:663:    B -->|No| C["Error: discount_not_found"]
specs/05-BUSINESS-LOGIC.md:665:    D -->|No| E["Error: discount_expired"]
specs/05-BUSINESS-LOGIC.md:667:    F -->|No| G["Error: discount_not_yet_active"]
specs/05-BUSINESS-LOGIC.md:669:    H -->|No| I["Error: discount_expired"]
specs/05-BUSINESS-LOGIC.md:671:    J -->|Yes| K["Error: discount_usage_limit_reached"]
specs/05-BUSINESS-LOGIC.md:673:    L -->|Yes| M["Error: discount_min_purchase_not_met"]
specs/05-BUSINESS-LOGIC.md:675:    N -->|Yes| O["Error: discount_not_applicable"]
specs/05-BUSINESS-LOGIC.md:676:    N -->|No| P["Valid - return discount"]
specs/05-BUSINESS-LOGIC.md:683:| `discount_not_found` | Code does not exist in this store |
specs/05-BUSINESS-LOGIC.md:684:| `discount_expired` | Past `ends_at` date or status is not active |
specs/05-BUSINESS-LOGIC.md:685:| `discount_not_yet_active` | Before `starts_at` date |
specs/05-BUSINESS-LOGIC.md:686:| `discount_usage_limit_reached` | `usage_count >= usage_limit` |
specs/05-BUSINESS-LOGIC.md:687:| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |
specs/05-BUSINESS-LOGIC.md:688:| `discount_not_applicable` | No qualifying products in cart |
specs/05-BUSINESS-LOGIC.md:692:For `percent` and `fixed` discounts with applicable product restrictions:
specs/05-BUSINESS-LOGIC.md:695:PROCEDURE allocateDiscount(discount, cartLines):
specs/05-BUSINESS-LOGIC.md:700:    IF discount.value_type = 'percent':
specs/05-BUSINESS-LOGIC.md:701:        totalDiscount = qualifyingSubtotal * discount.value_amount / 100
specs/05-BUSINESS-LOGIC.md:702:    ELSE IF discount.value_type = 'fixed':
specs/05-BUSINESS-LOGIC.md:703:        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)
specs/05-BUSINESS-LOGIC.md:710:        SET line.line_discount_amount = lineDiscount
specs/05-BUSINESS-LOGIC.md:713:    SET lastLine.line_discount_amount = remainingDiscount
specs/05-BUSINESS-LOGIC.md:719:- During checkout: update `cart_lines.line_discount_amount`.
specs/05-BUSINESS-LOGIC.md:720:- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.
specs/05-BUSINESS-LOGIC.md:724:When an order is successfully created with a discount code:
specs/05-BUSINESS-LOGIC.md:725:- Increment `discounts.usage_count` by 1.
specs/05-BUSINESS-LOGIC.md:737:- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.
specs/05-BUSINESS-LOGIC.md:756:line_tax = ROUND(line_total_after_discount * rate / 10000)
specs/05-BUSINESS-LOGIC.md:811:Discounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.
specs/05-BUSINESS-LOGIC.md:813:For tax-inclusive pricing (`prices_include_tax = true`): The discount is subtracted from the gross (tax-inclusive) subtotal. Tax is then extracted from the post-discount gross amount: `tax = post_discount_gross * rate / (1 + rate)`. This means the tax portion decreases proportionally when a discount is applied.
specs/05-BUSINESS-LOGIC.md:817:2. Apply discount allocations to lines.
specs/05-BUSINESS-LOGIC.md:818:3. Calculate tax on discounted line amounts.
specs/05-BUSINESS-LOGIC.md:923:If a validated discount has `value_type = 'free_shipping'`:
specs/05-BUSINESS-LOGIC.md:1225:Fulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.
specs/05-BUSINESS-LOGIC.md:1269:2. If yes: automatically create a fulfillment record covering all order lines:
specs/05-BUSINESS-LOGIC.md:1283:**CSV columns:** `order_number`, `created_at`, `status`, `financial_status`, `fulfillment_status`, `customer_email`, `customer_name`, `subtotal_amount`, `discount_amount`, `shipping_amount`, `tax_amount`, `total_amount`, `currency`, `shipping_method`, `tracking_number`
specs/05-BUSINESS-LOGIC.md:1421:- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.
specs/05-BUSINESS-LOGIC.md:1644:| DiscountService | `App\Services` | Discount validation and application |
specs/05-BUSINESS-LOGIC.md:1678:| DiscountType | Code (`code`), Automatic (`automatic`) |
specs/05-BUSINESS-LOGIC.md:1700:| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |
specs/05-BUSINESS-LOGIC.md:1704:| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |
app/ValueObjects/PricingResult.php:12:        public int $discount,
app/ValueObjects/PricingResult.php:21:     * @return array{subtotal: int, discount: int, shipping: int, tax_lines: array<int, array{name: string, rate: int, amount: int}>, tax: int, total: int, currency: string}
app/ValueObjects/PricingResult.php:27:            'discount' => $this->discount,
tests/Feature/Orders/OrderServiceTest.php:111:function orderCompletionCheckout(Store $store, string $paymentMethod = 'credit_card', ?string $discountCode = null): array
tests/Feature/Orders/OrderServiceTest.php:133:    if ($discountCode !== null) {
tests/Feature/Orders/OrderServiceTest.php:134:        $checkout->forceFill(['discount_code' => $discountCode])->save();
tests/Feature/Orders/OrderServiceTest.php:145:    $discount = Discount::factory()
tests/Feature/Orders/OrderServiceTest.php:151:    [$checkout, $variant, $cart] = orderCompletionCheckout($store, discountCode: 'SAVE500');
tests/Feature/Orders/OrderServiceTest.php:165:        ->and($order->lines->first()->discount_allocations_json)->toBe([[
tests/Feature/Orders/OrderServiceTest.php:166:            'discount_id' => $discount->getKey(),
tests/Feature/Orders/OrderServiceTest.php:175:        ->and($discount->refresh()->usage_count)->toBe(1);
database/factories/CheckoutFactory.php:33:            'discount_code' => null,
tests/Feature/Api/StorefrontCheckoutApiTest.php:64:test('storefront checkout api progresses through address shipping discount removal and payment selection', function (): void {
tests/Feature/Api/StorefrontCheckoutApiTest.php:118:        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", ['code' => 'SAVE10'])
tests/Feature/Api/StorefrontCheckoutApiTest.php:120:        ->assertJsonPath('data.discount_code', 'SAVE10')
tests/Feature/Api/StorefrontCheckoutApiTest.php:121:        ->assertJsonPath('data.totals.discount', 500);
tests/Feature/Api/StorefrontCheckoutApiTest.php:124:        ->deleteJson("/api/storefront/v1/checkouts/{$checkoutId}/discount")
tests/Feature/Api/StorefrontCheckoutApiTest.php:126:        ->assertJsonPath('data.discount_code', null)
tests/Feature/Api/StorefrontCheckoutApiTest.php:127:        ->assertJsonPath('data.totals.discount', 0);
tests/Feature/Api/StorefrontCheckoutApiTest.php:144:test('storefront checkout api rejects invalid addresses shipping methods and discounts', function (): void {
tests/Feature/Api/StorefrontCheckoutApiTest.php:191:        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", ['code' => 'NOTREAL'])
tests/Feature/Api/StorefrontCheckoutApiTest.php:193:        ->assertJsonPath('reason', 'discount_not_found');
database/factories/CartLineFactory.php:30:            'line_discount_amount' => 0,
database/factories/OrderFactory.php:41:            'discount_amount' => 0,
tests/Feature/Checkout/PricingServicesTest.php:19:use App\Services\DiscountService;
tests/Feature/Checkout/PricingServicesTest.php:119:test('pricing engine applies percent and free shipping discounts', function () {
tests/Feature/Checkout/PricingServicesTest.php:140:        'discount_code' => 'save10',
tests/Feature/Checkout/PricingServicesTest.php:143:    $discounted = app(PricingEngine::class)->calculate($checkout);
tests/Feature/Checkout/PricingServicesTest.php:145:    expect($discounted->discount)->toBe(500)
tests/Feature/Checkout/PricingServicesTest.php:146:        ->and($discounted->shipping)->toBe(499)
tests/Feature/Checkout/PricingServicesTest.php:147:        ->and($discounted->total)->toBe(5949);
tests/Feature/Checkout/PricingServicesTest.php:149:    $checkout->forceFill(['discount_code' => 'FREESHIP'])->save();
tests/Feature/Checkout/PricingServicesTest.php:152:    expect($freeShipping->discount)->toBe(0)
tests/Feature/Checkout/PricingServicesTest.php:157:test('pricing engine applies active automatic discounts', function () {
tests/Feature/Checkout/PricingServicesTest.php:172:    expect($result->discount)->toBe(500)
tests/Feature/Checkout/PricingServicesTest.php:174:        ->and($line->refresh()->line_discount_amount)->toBe(500)
tests/Feature/Checkout/PricingServicesTest.php:178:test('pricing engine stacks automatic discounts after explicit code discounts', function () {
tests/Feature/Checkout/PricingServicesTest.php:195:    $checkout->forceFill(['discount_code' => 'SAVE10'])->save();
tests/Feature/Checkout/PricingServicesTest.php:200:    expect($result->discount)->toBe(950)
tests/Feature/Checkout/PricingServicesTest.php:202:        ->and($line->refresh()->line_discount_amount)->toBe(950)
tests/Feature/Checkout/PricingServicesTest.php:206:test('discount validation enforces one use per customer from order history', function () {
tests/Feature/Checkout/PricingServicesTest.php:211:    $discount = Discount::factory()->create([
tests/Feature/Checkout/PricingServicesTest.php:223:            'discount_amount' => 500,
tests/Feature/Checkout/PricingServicesTest.php:227:        'discount_allocations_json' => [[
tests/Feature/Checkout/PricingServicesTest.php:228:            'discount_id' => $discount->getKey(),
tests/Feature/Checkout/PricingServicesTest.php:238:        app(DiscountService::class)->validate('once', $store, $usedCart);
tests/Feature/Checkout/PricingServicesTest.php:240:        $this->fail('Expected one-per-customer discount validation to fail.');
tests/Feature/Checkout/PricingServicesTest.php:242:        expect($exception->reasonCode)->toBe('discount_usage_limit_reached');
tests/Feature/Checkout/PricingServicesTest.php:248:    expect(app(DiscountService::class)->validate('ONCE', $store, $otherCart)->is($discount))->toBeTrue();
database/factories/OrderLineFactory.php:34:            'discount_allocations_json' => [],
tests/Feature/Storefront/CartCheckoutUiTest.php:86:test('cart page applies discount estimates shipping and carries discount into checkout', function () {
tests/Feature/Storefront/CartCheckoutUiTest.php:94:        ->set('discountCode', 'SAVE10')
tests/Feature/Storefront/CartCheckoutUiTest.php:104:    expect(session('cart_discount_code'))->toBe('SAVE10')
tests/Feature/Storefront/CartCheckoutUiTest.php:105:        ->and($component->instance()->discountAmount())->toBe(500)
tests/Feature/Storefront/CartCheckoutUiTest.php:110:        ->assertSet('discountCode', 'SAVE10');
tests/Feature/Storefront/CartCheckoutUiTest.php:114:    expect($checkout->discount_code)->toBe('SAVE10')
tests/Feature/Storefront/CartCheckoutUiTest.php:115:        ->and($checkout->totals_json['discount'])->toBe(500);
tests/Feature/Storefront/CartCheckoutUiTest.php:142:test('checkout page progresses through address shipping discount and payment selection', function () {
tests/Feature/Storefront/CartCheckoutUiTest.php:167:        ->set('discountCode', 'SAVE10')
tests/Feature/Storefront/CartCheckoutUiTest.php:178:        ->and($checkout->discount_code)->toBe('SAVE10')
tests/Feature/Storefront/CartCheckoutUiTest.php:179:        ->and($checkout->totals_json['discount'])->toBeGreaterThan(0)
tests/Feature/Foundation/AuthorizationTest.php:45:test('staff can manage discounts but only owner or admin can delete them', function () {
tests/Feature/Foundation/AuthorizationTest.php:51:    $discount = policySubjectFor($store);
tests/Feature/Foundation/AuthorizationTest.php:55:        ->and($policy->update($staff, $discount))->toBeTrue()
tests/Feature/Foundation/AuthorizationTest.php:56:        ->and($policy->delete($staff, $discount))->toBeFalse();
tests/Feature/Admin/DiscountManagementTest.php:59:test('admin discount routes require authentication and render store scoped discounts', function (): void {
tests/Feature/Admin/DiscountManagementTest.php:61:    $discount = Discount::factory()->create([
tests/Feature/Admin/DiscountManagementTest.php:74:    $this->get('/admin/discounts')->assertRedirect('/admin/login');
tests/Feature/Admin/DiscountManagementTest.php:78:        ->get('/admin/discounts')
tests/Feature/Admin/DiscountManagementTest.php:87:        ->get('/admin/discounts/create')
tests/Feature/Admin/DiscountManagementTest.php:89:        ->assertSee('Create discount');
tests/Feature/Admin/DiscountManagementTest.php:93:        ->get('/admin/discounts/'.$discount->getKey().'/edit')
tests/Feature/Admin/DiscountManagementTest.php:98:test('admin discount index filters by code and effective status', function (): void {
tests/Feature/Admin/DiscountManagementTest.php:146:        ->set('typeFilter', 'automatic')
tests/Feature/Admin/DiscountManagementTest.php:151:test('admin discount form creates discounts with eligibility rules', function (): void {
tests/Feature/Admin/DiscountManagementTest.php:171:    $discount = Discount::withoutGlobalScopes()
tests/Feature/Admin/DiscountManagementTest.php:176:    expect($discount->value_type)->toBe(DiscountValueType::Fixed)
tests/Feature/Admin/DiscountManagementTest.php:177:        ->and($discount->value_amount)->toBe(500)
tests/Feature/Admin/DiscountManagementTest.php:178:        ->and($discount->usage_limit)->toBe(100)
tests/Feature/Admin/DiscountManagementTest.php:179:        ->and(data_get($discount->rules_json, 'min_purchase_amount'))->toBe(2500)
tests/Feature/Admin/DiscountManagementTest.php:180:        ->and(data_get($discount->rules_json, 'one_per_customer'))->toBeTrue()
tests/Feature/Admin/DiscountManagementTest.php:181:        ->and(data_get($discount->rules_json, 'applicable_product_ids'))->toBe([$product->getKey()])
tests/Feature/Admin/DiscountManagementTest.php:182:        ->and(data_get($discount->rules_json, 'applicable_collection_ids'))->toBe([$collection->getKey()]);
tests/Feature/Admin/DiscountManagementTest.php:185:test('admin discount form enforces mutation policies and verified users', function (): void {
tests/Feature/Admin/DiscountManagementTest.php:196:        ->get('/admin/discounts')
tests/Feature/Admin/DiscountManagementTest.php:201:        ->get('/admin/discounts/create')
tests/Feature/Admin/DiscountManagementTest.php:206:        ->get('/admin/discounts/create')
tests/Feature/Admin/DiscountManagementTest.php:214:test('admin discount form validates percentage values and normalized code uniqueness', function (): void {
tests/Feature/Admin/DiscountManagementTest.php:239:test('admin discount form creates drafts by default and preserves expired discounts', function (): void {
tests/Feature/Admin/DiscountManagementTest.php:255:        ->test(AdminDiscountForm::class, ['discount' => $draft])
tests/Feature/Admin/DiscountManagementTest.php:267:        ->test(AdminDiscountForm::class, ['discount' => $expired])
tests/Feature/Admin/DiscountManagementTest.php:278:test('admin discount form edits discounts and rejects another store', function (): void {
tests/Feature/Admin/Disc…1090 tokens truncated…app/Services/DiscountService.php:116:            DiscountValueType::Fixed => min($discount->value_amount, $qualifyingSubtotal),
app/Services/DiscountService.php:120:        $remaining = $discountAmount;
app/Services/DiscountService.php:131:            $allocation = (int) round($discountAmount * $line->line_total_amount / $qualifyingSubtotal);
app/Services/DiscountService.php:136:        return new DiscountResult($discountAmount, $allocations);
app/Services/DiscountService.php:139:    public function applyToCart(Cart $cart, Discount $discount): DiscountResult
app/Services/DiscountService.php:141:        return DB::transaction(function () use ($cart, $discount): DiscountResult {
app/Services/DiscountService.php:143:            $result = $this->calculate($discount, $lines->sum('line_subtotal_amount'), $lines->all());
app/Services/DiscountService.php:146:                $discountAmount = $result->allocations[$line->getKey()] ?? 0;
app/Services/DiscountService.php:149:                    'line_discount_amount' => $line->line_discount_amount + $discountAmount,
app/Services/DiscountService.php:150:                    'line_total_amount' => max(0, $line->line_total_amount - $discountAmount),
app/Services/DiscountService.php:173:    private function qualifyingLines(Discount $discount, Collection $lines): Collection
app/Services/DiscountService.php:175:        $productIds = collect(data_get($discount->rules_json, 'applicable_product_ids', []))
app/Services/DiscountService.php:179:        $collectionIds = collect(data_get($discount->rules_json, 'applicable_collection_ids', []))
app/Services/DiscountService.php:209:    private function onePerCustomer(Discount $discount): bool
app/Services/DiscountService.php:211:        return (bool) data_get($discount->rules_json, 'one_per_customer', false);
app/Services/DiscountService.php:214:    private function customerHasUsedDiscount(Discount $discount, Cart $cart): bool
app/Services/DiscountService.php:220:        $discountCode = $discount->code ? mb_strtolower($discount->code) : null;
app/Services/DiscountService.php:223:            ->whereHas('order', function ($query) use ($discount, $cart): void {
app/Services/DiscountService.php:226:                    ->where('store_id', $discount->store_id)
app/Services/DiscountService.php:228:                    ->where('discount_amount', '>', 0);
app/Services/DiscountService.php:231:            ->contains(function (OrderLine $line) use ($discount, $discountCode): bool {
app/Services/DiscountService.php:232:                return collect($line->discount_allocations_json ?? [])
app/Services/DiscountService.php:233:                    ->contains(function (mixed $allocation) use ($discount, $discountCode): bool {
app/Services/DiscountService.php:234:                        $allocationDiscountId = data_get($allocation, 'discount_id');
app/Services/DiscountService.php:236:                        if ($allocationDiscountId !== null && (int) $allocationDiscountId === $discount->getKey()) {
app/Services/DiscountService.php:240:                        if ($discountCode === null) {
app/Services/DiscountService.php:244:                        return mb_strtolower((string) data_get($allocation, 'code', '')) === $discountCode;
database/seeders/DiscountSeeder.php:17:            foreach ($this->discounts() as $discount) {
database/seeders/DiscountSeeder.php:21:                        'code' => $discount['code'],
database/seeders/DiscountSeeder.php:25:                        'value_type' => $discount['value_type'],
database/seeders/DiscountSeeder.php:26:                        'value_amount' => $discount['value_amount'],
database/seeders/DiscountSeeder.php:31:                        'rules_json' => $discount['rules_json'],
database/seeders/DiscountSeeder.php:42:    private function discounts(): array
app/Services/PricingEngine.php:17:        private readonly DiscountService $discounts,
app/Services/PricingEngine.php:38:            $discountAmount = 0;
app/Services/PricingEngine.php:41:            if ($checkout->discount_code) {
app/Services/PricingEngine.php:42:                $discount = $this->discounts->validate($checkout->discount_code, $checkout->store, $cart);
app/Services/PricingEngine.php:43:                $discountResult = $this->discounts->applyToCart($cart, $discount);
app/Services/PricingEngine.php:44:                $discountAmount += $discountResult->amount;
app/Services/PricingEngine.php:45:                $freeShipping = $freeShipping || $discountResult->freeShipping;
app/Services/PricingEngine.php:48:            foreach ($this->discounts->automaticForCart($checkout->store, $cart) as $discount) {
app/Services/PricingEngine.php:49:                $discountResult = $this->discounts->applyToCart($cart, $discount);
app/Services/PricingEngine.php:50:                $discountAmount += $discountResult->amount;
app/Services/PricingEngine.php:51:                $freeShipping = $freeShipping || $discountResult->freeShipping;
app/Services/PricingEngine.php:58:            $discountResult = new DiscountResult($discountAmount, [], $freeShipping);
app/Services/PricingEngine.php:59:            $shippingAmount = $this->shippingAmount($checkout, $discountResult);
app/Services/PricingEngine.php:67:            $discountedSubtotal = $subtotal - $discountResult->amount;
app/Services/PricingEngine.php:69:                ? $discountedSubtotal + $shippingAmount
app/Services/PricingEngine.php:70:                : $discountedSubtotal + $shippingAmount + $taxResult->totalAmount;
app/Services/PricingEngine.php:74:                discount: $discountResult->amount,
app/Services/PricingEngine.php:101:                    'line_discount_amount' => 0,
app/Services/PricingEngine.php:107:    private function shippingAmount(Checkout $checkout, DiscountResult $discountResult): int
app/Services/PricingEngine.php:129:        return $discountResult->freeShipping ? 0 : $amount;
app/Services/CartService.php:282:            'line_discount_amount' => 0,
app/Policies/DiscountPolicy.php:17:    public function view(User $user, object $discount): bool
app/Policies/DiscountPolicy.php:19:        return $this->isAnyRole($user, $this->storeIdForModel($discount));
app/Policies/DiscountPolicy.php:27:    public function update(User $user, object $discount): bool
app/Policies/DiscountPolicy.php:29:        return $this->isOwnerAdminOrStaff($user, $this->storeIdForModel($discount));
app/Policies/DiscountPolicy.php:32:    public function delete(User $user, object $discount): bool
app/Policies/DiscountPolicy.php:34:        return $this->isOwnerOrAdmin($user, $this->storeIdForModel($discount));
app/Livewire/Storefront/Checkout/Show.php:67:    public string $discountCode = '';
app/Livewire/Storefront/Checkout/Show.php:163:            'discount_code' => trim($this->discountCode) !== '' ? trim($this->discountCode) : null,
app/Livewire/Storefront/Checkout/Show.php:168:            $this->resetErrorBag('discountCode');
app/Livewire/Storefront/Checkout/Show.php:170:            $checkout->forceFill(['discount_code' => null])->save();
app/Livewire/Storefront/Checkout/Show.php:171:            $this->discountCode = '';
app/Livewire/Storefront/Checkout/Show.php:175:                'discountCode' => $exception->getMessage(),
app/Livewire/Storefront/Checkout/Show.php:241:            session()->forget(['cart_id', 'cart_discount_code']);
app/Livewire/Storefront/Checkout/Show.php:366:            'discount' => 0,
app/Livewire/Storefront/Checkout/Show.php:407:        $this->discountCode = (string) ($checkout->discount_code ?? '');
app/Livewire/Storefront/Checkout/Show.php:420:        $code = trim((string) session('cart_discount_code'));
app/Livewire/Storefront/Checkout/Show.php:422:        if ($code === '' || $checkout->discount_code !== null) {
app/Livewire/Storefront/Checkout/Show.php:426:        $checkout->forceFill(['discount_code' => $code])->save();
app/Livewire/Storefront/Checkout/Show.php:431:            $checkout->forceFill(['discount_code' => null])->save();
app/Livewire/Storefront/Checkout/Show.php:432:            session()->forget('cart_discount_code');
app/Livewire/Storefront/Cart/Show.php:12:use App\Services\DiscountService;
app/Livewire/Storefront/Cart/Show.php:25:    public string $discountCode = '';
app/Livewire/Storefront/Cart/Show.php:38:        $this->appliedDiscountCode = trim((string) session('cart_discount_code')) ?: null;
app/Livewire/Storefront/Cart/Show.php:39:        $this->discountCode = $this->appliedDiscountCode ?? '';
app/Livewire/Storefront/Cart/Show.php:84:            'discountCode' => ['required', 'string', 'max:50'],
app/Livewire/Storefront/Cart/Show.php:93:        $code = trim($this->discountCode);
app/Livewire/Storefront/Cart/Show.php:96:            $discount = app(DiscountService::class)->validate($code, $this->store(), $cart);
app/Livewire/Storefront/Cart/Show.php:97:            app(DiscountService::class)->calculate($discount, $this->subtotal(), $this->lines()->all());
app/Livewire/Storefront/Cart/Show.php:102:                'discountCode' => $exception->getMessage(),
app/Livewire/Storefront/Cart/Show.php:107:        $this->discountCode = $code;
app/Livewire/Storefront/Cart/Show.php:108:        session(['cart_discount_code' => $code]);
app/Livewire/Storefront/Cart/Show.php:109:        $this->resetErrorBag('discountCode');
app/Livewire/Storefront/Cart/Show.php:115:        $this->discountCode = '';
app/Livewire/Storefront/Cart/Show.php:116:        session()->forget('cart_discount_code');
app/Livewire/Storefront/Cart/Show.php:117:        $this->resetErrorBag('discountCode');
app/Livewire/Storefront/Cart/Show.php:232:    public function discountResult(): ?DiscountResult
app/Livewire/Storefront/Cart/Show.php:242:            $discount = app(DiscountService::class)->validate($code, $this->store(), $cart);
app/Livewire/Storefront/Cart/Show.php:244:            return app(DiscountService::class)->calculate($discount, $this->subtotal(), $this->lines()->all());
app/Livewire/Storefront/Cart/Show.php:250:    public function discountAmount(): int
app/Livewire/Storefront/Cart/Show.php:252:        return $this->discountResult()?->amount ?? 0;
app/Livewire/Storefront/Cart/Show.php:255:    public function discountFreeShipping(): bool
app/Livewire/Storefront/Cart/Show.php:257:        return $this->discountResult()?->freeShipping ?? false;
app/Livewire/Storefront/Cart/Show.php:262:        $shipping = $this->discountFreeShipping() ? 0 : ($this->estimatedShippingAmount() ?? 0);
app/Livewire/Storefront/Cart/Show.php:264:        return max(0, $this->subtotal() - $this->discountAmount() + $shipping);
app/Livewire/Storefront/Cart/Show.php:274:            'discountAmount' => $this->discountAmount(),
app/Livewire/Storefront/Cart/Show.php:275:            'discountFreeShipping' => $this->discountFreeShipping(),
app/Enums/DiscountType.php:8:    case Automatic = 'automatic';
app/Livewire/Admin/Discounts/Form.php:23:    public ?Discount $discount = null;
app/Livewire/Admin/Discounts/Form.php:61:    public function mount(?Discount $discount = null): void
app/Livewire/Admin/Discounts/Form.php:69:        if ($discount?->exists) {
app/Livewire/Admin/Discounts/Form.php:70:            abort_unless((int) $discount->store_id === $store->getKey(), 404);
app/Livewire/Admin/Discounts/Form.php:72:            $this->authorize('update', $discount);
app/Livewire/Admin/Discounts/Form.php:74:            $this->discount = $discount;
app/Livewire/Admin/Discounts/Form.php:75:            $this->fillFromDiscount($discount);
app/Livewire/Admin/Discounts/Form.php:95:            'type' => ['required', Rule::in(['code', 'automatic'])],
app/Livewire/Admin/Discounts/Form.php:109:                        ->when($this->discount instanceof Discount, fn ($query) => $query->where('id', '!=', $this->discount?->getKey()))
app/Livewire/Admin/Discounts/Form.php:113:                        $fail(__('The discount code has already been taken.'));
app/Livewire/Admin/Discounts/Form.php:129:        $discount = $this->discount instanceof Discount
app/Livewire/Admin/Discounts/Form.php:130:            ? tap($this->discount)->update($this->payload($store))
app/Livewire/Admin/Discounts/Form.php:133:        $this->discount = $discount->refresh();
app/Livewire/Admin/Discounts/Form.php:134:        $this->fillFromDiscount($this->discount);
app/Livewire/Admin/Discounts/Form.php:240:        return view('livewire.admin.discounts.form', [
app/Livewire/Admin/Discounts/Form.php:241:            'isEditing' => $this->discount instanceof Discount,
app/Livewire/Admin/Discounts/Form.php:247:            'title' => $this->discount ? __('Edit discount') : __('Create discount'),
app/Livewire/Admin/Discounts/Form.php:251:    private function fillFromDiscount(Discount $discount): void
app/Livewire/Admin/Discounts/Form.php:253:        $this->type = $discount->type->value;
app/Livewire/Admin/Discounts/Form.php:254:        $this->code = (string) $discount->code;
app/Livewire/Admin/Discounts/Form.php:255:        $this->valueType = $discount->value_type->value;
app/Livewire/Admin/Discounts/Form.php:256:        $this->valueAmount = $discount->value_type === DiscountValueType::Fixed
app/Livewire/Admin/Discounts/Form.php:257:            ? number_format($discount->value_amount / 100, 2, '.', '')
app/Livewire/Admin/Discounts/Form.php:258:            : (string) $discount->value_amount;
app/Livewire/Admin/Discounts/Form.php:259:        $this->minimumPurchaseAmount = data_get($discount->rules_json, 'min_purchase_amount')
app/Livewire/Admin/Discounts/Form.php:260:            ? number_format(((int) data_get($discount->rules_json, 'min_purchase_amount')) / 100, 2, '.', '')
app/Livewire/Admin/Discounts/Form.php:262:        $this->specificProductIds = collect(data_get($discount->rules_json, 'applicable_product_ids', []))
app/Livewire/Admin/Discounts/Form.php:265:        $this->specificCollectionIds = collect(data_get($discount->rules_json, 'applicable_collection_ids', []))
app/Livewire/Admin/Discounts/Form.php:268:        $this->usageLimit = $discount->usage_limit !== null ? (string) $discount->usage_limit : '';
app/Livewire/Admin/Discounts/Form.php:269:        $this->onePerCustomer = (bool) data_get($discount->rules_json, 'one_per_customer', false);
app/Livewire/Admin/Discounts/Form.php:270:        $this->startsAt = $discount->starts_at->format('Y-m-d\TH:i');
app/Livewire/Admin/Discounts/Form.php:271:        $this->endsAt = $discount->ends_at?->format('Y-m-d\TH:i') ?? '';
app/Livewire/Admin/Discounts/Form.php:272:        $this->isActive = $discount->status === DiscountStatus::Active;
app/Livewire/Admin/Discounts/Form.php:370:        if ($this->discount instanceof Discount) {
app/Livewire/Admin/Discounts/Form.php:371:            $this->authorize('update', $this->discount);
app/Livewire/Admin/Discounts/Form.php:404:        if ($this->discount?->status === DiscountStatus::Expired) {
app/Livewire/Admin/Discounts/Form.php:412:        if ($this->discount?->status === DiscountStatus::Draft || ! $this->discount instanceof Discount) {
app/Livewire/Admin/Discounts/Index.php:60:    public function discounts(): LengthAwarePaginator
app/Livewire/Admin/Discounts/Index.php:80:    public function valueLabel(Discount $discount): string
app/Livewire/Admin/Discounts/Index.php:82:        return match ($discount->value_type) {
app/Livewire/Admin/Discounts/Index.php:83:            DiscountValueType::Percent => $discount->value_amount.'%',
app/Livewire/Admin/Discounts/Index.php:84:            DiscountValueType::Fixed => Money::format($discount->value_amount, $this->storeCurrency),
app/Livewire/Admin/Discounts/Index.php:89:    public function effectiveStatus(Discount $discount): string
app/Livewire/Admin/Discounts/Index.php:91:        if ($discount->status === DiscountStatus::Disabled) {
app/Livewire/Admin/Discounts/Index.php:95:        if ($discount->starts_at->isFuture()) {
app/Livewire/Admin/Discounts/Index.php:99:        if ($discount->status === DiscountStatus::Expired || ($discount->ends_at !== null && $discount->ends_at->isPast())) {
app/Livewire/Admin/Discounts/Index.php:103:        return $discount->status->value;
app/Livewire/Admin/Discounts/Index.php:118:        return view('livewire.admin.discounts.index', [
app/Livewire/Admin/Discounts/Index.php:119:            'discounts' => $this->discounts(),
app/Services/OrderService.php:89:                    'discount_amount' => (int) data_get($totals, 'discount', 0),
app/Services/OrderService.php:265:                'discount_allocations_json' => $this->discountAllocations($checkout, $line),
app/Services/OrderService.php:283:     * @return array<int, array{discount_id: int|null, code: string, amount: int}>
app/Services/OrderService.php:285:    private function discountAllocations(Checkout $checkout, CartLine $line): array
app/Services/OrderService.php:287:        if ($checkout->discount_code === null || $line->line_discount_amount <= 0) {
app/Services/OrderService.php:291:        $code = trim($checkout->discount_code);
app/Services/OrderService.php:292:        $discount = Discount::withoutGlobalScopes()
app/Services/OrderService.php:298:            'discount_id' => $discount?->getKey(),
app/Services/OrderService.php:300:            'amount' => $line->line_discount_amount,
app/Services/OrderService.php:352:        $code = trim((string) $checkout->discount_code);
database/migrations/2026_05_04_000410_create_order_lines_table.php:25:            $table->text('discount_allocations_json')->default('[]');
app/Http/Controllers/Api/Storefront/V1/CheckoutController.php:82:            'discount_code' => trim((string) $request->validated('code')) ?: null,
app/Http/Controllers/Api/Storefront/V1/CheckoutController.php:90:            $checkout->forceFill(['discount_code' => null])->save();
app/Http/Controllers/Api/Storefront/V1/CheckoutController.php:102:        abort_if($checkout->discount_code === null, 404);
app/Http/Controllers/Api/Storefront/V1/CheckoutController.php:104:        $checkout->forceFill(['discount_code' => null])->save();
database/migrations/2026_05_03_225405_create_cart_lines_table.php:21:            $table->integer('line_discount_amount')->default(0);
app/Models/Store.php:124:    public function discounts(): HasMany
app/Http/Resources/Admin/V1/OrderResource.php:33:            'discount_amount' => $this->discount_amount,
app/Models/Checkout.php:29:        'discount_code',
app/Models/Order.php:34:        'discount_amount',
app/Models/Order.php:53:        'discount_amount' => 0,
app/Models/Order.php:126:            'discount_amount' => 'integer',
app/Http/Resources/Storefront/V1/OrderResource.php:30:            'discount_amount' => $this->discount_amount,
database/migrations/2026_05_04_000408_create_orders_table.php:26:            $table->integer('discount_amount')->default(0);
database/migrations/2026_05_03_225427_create_discounts_table.php:14:        Schema::create('discounts', function (Blueprint $table) {
database/migrations/2026_05_03_225427_create_discounts_table.php:17:            $table->enum('type', ['code', 'automatic'])->default('code');
database/migrations/2026_05_03_225427_create_discounts_table.php:41:        Schema::dropIfExists('discounts');
app/Http/Resources/Storefront/V1/CheckoutResource.php:28:            'discount_code' => $this->discount_code,
app/Http/Resources/Storefront/V1/CartResource.php:29:                'discount' => $this->lines->sum('line_discount_amount'),
app/Http/Resources/Storefront/V1/CartLineResource.php:23:            'line_discount_amount' => $this->line_discount_amount,
app/Models/CartLine.php:26:        'line_discount_amount',
app/Models/CartLine.php:37:        'line_discount_amount' => 0,
app/Models/CartLine.php:87:            'line_discount_amount' => 'integer',
database/migrations/2026_05_03_225409_create_checkouts_table.php:25:            $table->string('discount_code')->nullable();
app/Models/OrderLine.php:30:        'discount_allocations_json',
app/Models/OrderLine.php:75:            'discount_allocations_json' => 'array',


cmd: sed -n '320,430p' app/Services/OrderService.php


command: sed -n '320,430p' app/Services/OrderService.php

OrderService.php

type: read

aggregated_output:
    private function releaseReservedOrderInventory(Order $order): void
    {
        $order->lines->each(function (OrderLine $line): void {
            if ($line->variant_id !== null) {
                $this->inventory->release($this->inventoryItemForVariant($line->variant_id), $line->quantity);
            }
        });
    }

    private function releaseFailedPaymentReservation(Checkout $checkout): void
    {
        DB::transaction(function () use ($checkout): void {
            $checkout = $this->freshCheckout($checkout);

            if ($checkout->status !== CheckoutStatus::PaymentSelected) {
                return;
            }

            $this->cartLines($checkout)->each(function (CartLine $line): void {
                $this->inventory->release($this->inventoryItemForVariant($line->variant_id), $line->quantity);
            });

            $checkout->forceFill([
                'status' => CheckoutStatus::ShippingSelected,
                'payment_method' => null,
                'expires_at' => null,
            ])->save();
        });
    }

    private function incrementDiscountUsage(Checkout $checkout): void
    {
        $code = trim((string) $checkout->discount_code);

        if ($code === '') {
            return;
        }

        Discount::withoutGlobalScopes()
            ->where('store_id', $checkout->store_id)
            ->whereRaw('lower(code) = ?', [mb_strtolower($code)])
            ->increment('usage_count');
    }

    private function assertPendingBankTransfer(Order $order): void
    {
        if ($order->payment_method !== PaymentMethod::BankTransfer || $order->financial_status !== FinancialStatus::Pending) {
            throw InvalidOrderOperationException::because('Order is not a pending bank transfer order.');
        }
    }

    private function inventoryItemForVariant(int $variantId): InventoryItem
    {
        return InventoryItem::withoutGlobalScopes()
            ->where('variant_id', $variantId)
            ->firstOrFail();
    }

    /**
     * @return Collection<int, CartLine>
     */
    private function cartLines(Checkout $checkout): Collection
    {
        return CartLine::withoutGlobalScopes()
            ->with([
                'variant' => fn ($query) => $query->withoutGlobalScopes(),
                'variant.product' => fn ($query) => $query->withoutGlobalScopes(),
                'variant.optionValues' => fn ($query) => $query->withoutGlobalScopes(),
                'variant.optionValues.option' => fn ($query) => $query->withoutGlobalScopes(),
            ])
            ->where('cart_id', $checkout->cart_id)
            ->orderBy('id')
            ->get();
    }
}

status: completed


output:
Chunk ID: 766677
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 668
Output:
    private function releaseReservedOrderInventory(Order $order): void
    {
        $order->lines->each(function (OrderLine $line): void {
            if ($line->variant_id !== null) {
                $this->inventory->release($this->inventoryItemForVariant($line->variant_id), $line->quantity);
            }
        });
    }

    private function releaseFailedPaymentReservation(Checkout $checkout): void
    {
        DB::transaction(function () use ($checkout): void {
            $checkout = $this->freshCheckout($checkout);

            if ($checkout->status !== CheckoutStatus::PaymentSelected) {
                return;
            }

            $this->cartLines($checkout)->each(function (CartLine $line): void {
                $this->inventory->release($this->inventoryItemForVariant($line->variant_id), $line->quantity);
            });

            $checkout->forceFill([
                'status' => CheckoutStatus::ShippingSelected,
                'payment_method' => null,
                'expires_at' => null,
            ])->save();
        });
    }

    private function incrementDiscountUsage(Checkout $checkout): void
    {
        $code = trim((string) $checkout->discount_code);

        if ($code === '') {
            return;
        }

        Discount::withoutGlobalScopes()
            ->where('store_id', $checkout->store_id)
            ->whereRaw('lower(code) = ?', [mb_strtolower($code)])
            ->increment('usage_count');
    }

    private function assertPendingBankTransfer(Order $order): void
    {
        if ($order->payment_method !== PaymentMethod::BankTransfer || $order->financial_status !== FinancialStatus::Pending) {
            throw InvalidOrderOperationException::because('Order is not a pending bank transfer order.');
        }
    }

    private function inventoryItemForVariant(int $variantId): InventoryItem
    {
        return InventoryItem::withoutGlobalScopes()
            ->where('variant_id', $variantId)
            ->firstOrFail();
    }

    /**
     * @return Collection<int, CartLine>
     */
    private function cartLines(Checkout $checkout): Collection
    {
        return CartLine::withoutGlobalScopes()
            ->with([
                'variant' => fn ($query) => $query->withoutGlobalScopes(),
                'variant.product' => fn ($query) => $query->withoutGlobalScopes(),
                'variant.optionValues' => fn ($query) => $query->withoutGlobalScopes(),
                'variant.optionValues.option' => fn ($query) => $query->withoutGlobalScopes(),
            ])
            ->where('cart_id', $checkout->cart_id)
            ->orderBy('id')
            ->get();
    }
}


cmd: sed -n '692,730p' specs/05-BUSINESS-LOGIC.md


command: sed -n '692,730p' specs/05-BUSINESS-LOGIC.md

aggregated_output:
For `percent` and `fixed` discounts with applicable product restrictions:

```
PROCEDURE allocateDiscount(discount, cartLines):
    qualifyingLines = FILTER cartLines where product matches applicable_product_ids
                      OR product belongs to applicable_collection_ids
    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)

    IF discount.value_type = 'percent':
        totalDiscount = qualifyingSubtotal * discount.value_amount / 100
    ELSE IF discount.value_type = 'fixed':
        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)

    remainingDiscount = totalDiscount

    FOR EACH qualifyingLine (except last):
        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)
        remainingDiscount -= lineDiscount
        SET line.line_discount_amount = lineDiscount

    // Last line gets the remainder (largest-remainder method)
    SET lastLine.line_discount_amount = remainingDiscount

    RECALCULATE line_total_amount for each affected line
```

**Storage of allocations:**
- During checkout: update `cart_lines.line_discount_amount`.
- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.

### 7.7 Usage Tracking

When an order is successfully created with a discount code:
- Increment `discounts.usage_count` by 1.
- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).

---

## 8. Tax Calculation


output:
Chunk ID: 431d48
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 371
Output:
For `percent` and `fixed` discounts with applicable product restrictions:

```
PROCEDURE allocateDiscount(discount, cartLines):
    qualifyingLines = FILTER cartLines where product matches applicable_product_ids
                      OR product belongs to applicable_collection_ids
    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)

    IF discount.value_type = 'percent':
        totalDiscount = qualifyingSubtotal * discount.value_amount / 100
    ELSE IF discount.value_type = 'fixed':
        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)

    remainingDiscount = totalDiscount

    FOR EACH qualifyingLine (except last):
        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)
        remainingDiscount -= lineDiscount
        SET line.line_discount_amount = lineDiscount

    // Last line gets the remainder (largest-remainder method)
    SET lastLine.line_discount_amount = remainingDiscount

    RECALCULATE line_total_amount for each affected line
```

**Storage of allocations:**
- During checkout: update `cart_lines.line_discount_amount`.
- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.

### 7.7 Usage Tracking

When an order is successfully created with a discount code:
- Increment `discounts.usage_count` by 1.
- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).

---

## 8. Tax Calculation


cmd: rg -n "new OrderService|OrderService\(" app tests


command: rg -n "new OrderService|OrderService\(" app tests

type: search

cmd: rg -n "new OrderService|OrderService\\(" app tests

query: new OrderService|OrderService\(

path: app

status: failed


output:
Chunk ID: be9e55
Wall time: 0.0000 seconds
Process exited with code 1
Original token count: 0
Output:


cmd: sed -n '1,260p' app/Services/CheckoutService.php


command: sed -n '1,260p' app/Services/CheckoutService.php

aggregated_output:
<?php

namespace App\Services;

use App\Enums\CartStatus;
use App\Enums\CheckoutStatus;
use App\Exceptions\InvalidCheckoutTransitionException;
use App\Exceptions\UnserviceableShippingAddressException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Checkout;
use App\Models\Customer;
use App\Models\InventoryItem;
use App\Models\Order;
use App\Models\ShippingRate;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;

class CheckoutService
{
    public function __construct(
        private readonly InventoryService $inventory,
        private readonly ShippingCalculator $shipping,
        private readonly PricingEngine $pricing,
        private readonly OrderService $orders,
    ) {}

    public function createFromCart(Cart $cart, ?Customer $customer = null): Checkout
    {
        $cart = Cart::withoutGlobalScopes()->findOrFail($cart->getKey());

        if ($cart->status !== CartStatus::Active) {
            throw InvalidCheckoutTransitionException::because('Checkout can only start from an active cart.');
        }

        if (! CartLine::withoutGlobalScopes()->where('cart_id', $cart->getKey())->exists()) {
            throw InvalidCheckoutTransitionException::because('Checkout cannot start from an empty cart.');
        }

        return Checkout::withoutGlobalScopes()->create([
            'store_id' => $cart->store_id,
            'cart_id' => $cart->getKey(),
            'customer_id' => $customer?->getKey() ?? $cart->customer_id,
            'status' => CheckoutStatus::Started,
        ]);
    }

    /**
     * @param  array<string, mixed>  $addressData
     */
    public function setAddress(Checkout $checkout, array $addressData): Checkout
    {
        return DB::transaction(function () use ($checkout, $addressData): Checkout {
            $checkout = $this->freshCheckout($checkout);
            $this->assertStatus($checkout, [CheckoutStatus::Started, CheckoutStatus::Addressed]);

            $email = (string) data_get($addressData, 'email');
            $shippingAddress = data_get($addressData, 'shipping_address', $addressData);

            Validator::make([
                'email' => $email,
                'shipping_address' => $shippingAddress,
            ], [
                'email' => ['required', 'email'],
                'shipping_address.first_name' => ['required', 'string'],
                'shipping_address.last_name' => ['required', 'string'],
                'shipping_address.address1' => ['required', 'string'],
                'shipping_address.city' => ['required', 'string'],
                'shipping_address.country' => ['required', 'string', 'size:2'],
                'shipping_address.postal_code' => ['required', 'string'],
            ])->validate();

            $checkout->forceFill([
                'email' => $email,
                'shipping_address_json' => $shippingAddress,
                'billing_address_json' => data_get($addressData, 'billing_address', $shippingAddress),
                'status' => CheckoutStatus::Addressed,
            ])->save();

            $this->pricing->calculate($checkout);

            return $checkout->refresh();
        });
    }

    public function setShippingMethod(Checkout $checkout, ?int $shippingRateId): Checkout
    {
        return DB::transaction(function () use ($checkout, $shippingRateId): Checkout {
            $checkout = $this->freshCheckout($checkout);
            $this->assertStatus($checkout, [CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected]);

            if (! $this->shipping->requiresShipping($checkout->cart)) {
                $checkout->forceFill([
                    'shipping_method_id' => null,
                    'status' => CheckoutStatus::ShippingSelected,
                ])->save();

                $this->pricing->calculate($checkout);

                return $checkout->refresh();
            }

            $availableRates = $this->shipping->getAvailableRates($checkout->store, $checkout->shipping_address_json ?? []);

            if ($availableRates->isEmpty()) {
                throw UnserviceableShippingAddressException::forAddress();
            }

            $rate = $availableRates->firstWhere('id', $shippingRateId);

            if (! $rate instanceof ShippingRate) {
                throw InvalidCheckoutTransitionException::because('Selected shipping rate is not available for this address.');
            }

            $checkout->forceFill([
                'shipping_method_id' => $rate->getKey(),
                'status' => CheckoutStatus::ShippingSelected,
            ])->save();

            $this->pricing->calculate($checkout);

            return $checkout->refresh();
        });
    }

    public function selectPaymentMethod(Checkout $checkout, string $paymentMethod): Checkout
    {
        return DB::transaction(function () use ($checkout, $paymentMethod): Checkout {
            $checkout = $this->freshCheckout($checkout);

            if ($checkout->status === CheckoutStatus::PaymentSelected) {
                return $checkout;
            }

            $this->assertStatus($checkout, [CheckoutStatus::ShippingSelected]);

            if (! in_array($paymentMethod, ['credit_card', 'paypal', 'bank_transfer'], true)) {
                throw InvalidCheckoutTransitionException::because('Payment method is invalid.');
            }

            if ($checkout->totals_json === null) {
                $this->pricing->calculate($checkout);
            }

            CartLine::withoutGlobalScopes()
                ->where('cart_id', $checkout->cart_id)
                ->get()
                ->each(function (CartLine $line): void {
                    $inventory = InventoryItem::withoutGlobalScopes()
                        ->where('variant_id', $line->variant_id)
                        ->firstOrFail();

                    $this->inventory->reserve($inventory, $line->quantity);
                });

            $checkout->forceFill([
                'payment_method' => $paymentMethod,
                'status' => CheckoutStatus::PaymentSelected,
                'expires_at' => now()->addDay(),
            ])->save();

            return $checkout->refresh();
        });
    }

    public function expireCheckout(Checkout $checkout): Checkout
    {
        return DB::transaction(function () use ($checkout): Checkout {
            $checkout = $this->freshCheckout($checkout);

            if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) {
                return $checkout;
            }

            if ($checkout->status === CheckoutStatus::PaymentSelected) {
                CartLine::withoutGlobalScopes()
                    ->where('cart_id', $checkout->cart_id)
                    ->get()
                    ->each(function (CartLine $line): void {
                        $inventory = InventoryItem::withoutGlobalScopes()
                            ->where('variant_id', $line->variant_id)
                            ->firstOrFail();

                        $this->inventory->release($inventory, $line->quantity);
                    });
            }

            $checkout->forceFill([
                'status' => CheckoutStatus::Expired,
            ])->save();

            return $checkout->refresh();
        });
    }

    /**
     * @param  array<string, mixed>  $paymentMethodData
     */
    public function completeCheckout(Checkout $checkout, array $paymentMethodData = []): Order
    {
        return $this->orders->createFromCheckout($checkout, $paymentMethodData);
    }

    private function freshCheckout(Checkout $checkout): Checkout
    {
        return Checkout::withoutGlobalScopes()
            ->with(['cart', 'store'])
            ->whereKey($checkout->getKey())
            ->lockForUpdate()
            ->firstOrFail();
    }

    /**
     * @param  array<int, CheckoutStatus>  $allowed
     */
    private function assertStatus(Checkout $checkout, array $allowed): void
    {
        if (! in_array($checkout->status, $allowed, true)) {
            throw InvalidCheckoutTransitionException::because("Checkout cannot transition from {$checkout->status->value}.");
        }
    }
}


output:
Chunk ID: e24b78
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2057
Output:
<?php

namespace App\Services;

use App\Enums\CartStatus;
use App\Enums\CheckoutStatus;
use App\Exceptions\InvalidCheckoutTransitionException;
use App\Exceptions\UnserviceableShippingAddressException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Checkout;
use App\Models\Customer;
use App\Models\InventoryItem;
use App\Models\Order;
use App\Models\ShippingRate;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;

class CheckoutService
{
    public function __construct(
        private readonly InventoryService $inventory,
        private readonly ShippingCalculator $shipping,
        private readonly PricingEngine $pricing,
        private readonly OrderService $orders,
    ) {}

    public function createFromCart(Cart $cart, ?Customer $customer = null): Checkout
    {
        $cart = Cart::withoutGlobalScopes()->findOrFail($cart->getKey());

        if ($cart->status !== CartStatus::Active) {
            throw InvalidCheckoutTransitionException::because('Checkout can only start from an active cart.');
        }

        if (! CartLine::withoutGlobalScopes()->where('cart_id', $cart->getKey())->exists()) {
            throw InvalidCheckoutTransitionException::because('Checkout cannot start from an empty cart.');
        }

        return Checkout::withoutGlobalScopes()->create([
            'store_id' => $cart->store_id,
            'cart_id' => $cart->getKey(),
            'customer_id' => $customer?->getKey() ?? $cart->customer_id,
            'status' => CheckoutStatus::Started,
        ]);
    }

    /**
     * @param  array<string, mixed>  $addressData
     */
    public function setAddress(Checkout $checkout, array $addressData): Checkout
    {
        return DB::transaction(function () use ($checkout, $addressData): Checkout {
            $checkout = $this->freshCheckout($checkout);
            $this->assertStatus($checkout, [CheckoutStatus::Started, CheckoutStatus::Addressed]);

            $email = (string) data_get($addressData, 'email');
            $shippingAddress = data_get($addressData, 'shipping_address', $addressData);

            Validator::make([
                'email' => $email,
                'shipping_address' => $shippingAddress,
            ], [
                'email' => ['required', 'email'],
                'shipping_address.first_name' => ['required', 'string'],
                'shipping_address.last_name' => ['required', 'string'],
                'shipping_address.address1' => ['required', 'string'],
                'shipping_address.city' => ['required', 'string'],
                'shipping_address.country' => ['required', 'string', 'size:2'],
                'shipping_address.postal_code' => ['required', 'string'],
            ])->validate();

            $checkout->forceFill([
                'email' => $email,
                'shipping_address_json' => $shippingAddress,
                'billing_address_json' => data_get($addressData, 'billing_address', $shippingAddress),
                'status' => CheckoutStatus::Addressed,
            ])->save();

            $this->pricing->calculate($checkout);

            return $checkout->refresh();
        });
    }

    public function setShippingMethod(Checkout $checkout, ?int $shippingRateId): Checkout
    {
        return DB::transaction(function () use ($checkout, $shippingRateId): Checkout {
            $checkout = $this->freshCheckout($checkout);
            $this->assertStatus($checkout, [CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected]);

            if (! $this->shipping->requiresShipping($checkout->cart)) {
                $checkout->forceFill([
                    'shipping_method_id' => null,
                    'status' => CheckoutStatus::ShippingSelected,
                ])->save();

                $this->pricing->calculate($checkout);

                return $checkout->refresh();
            }

            $availableRates = $this->shipping->getAvailableRates($checkout->store, $checkout->shipping_address_json ?? []);

            if ($availableRates->isEmpty()) {
                throw UnserviceableShippingAddressException::forAddress();
            }

            $rate = $availableRates->firstWhere('id', $shippingRateId);

            if (! $rate instanceof ShippingRate) {
                throw InvalidCheckoutTransitionException::because('Selected shipping rate is not available for this address.');
            }

            $checkout->forceFill([
                'shipping_method_id' => $rate->getKey(),
                'status' => CheckoutStatus::ShippingSelected,
            ])->save();

            $this->pricing->calculate($checkout);

            return $checkout->refresh();
        });
    }

    public function selectPaymentMethod(Checkout $checkout, string $paymentMethod): Checkout
    {
        return DB::transaction(function () use ($checkout, $paymentMethod): Checkout {
            $checkout = $this->freshCheckout($checkout);

            if ($checkout->status === CheckoutStatus::PaymentSelected) {
                return $checkout;
            }

            $this->assertStatus($checkout, [CheckoutStatus::ShippingSelected]);

            if (! in_array($paymentMethod, ['credit_card', 'paypal', 'bank_transfer'], true)) {
                throw InvalidCheckoutTransitionException::because('Payment method is invalid.');
            }

            if ($checkout->totals_json === null) {
                $this->pricing->calculate($checkout);
            }

            CartLine::withoutGlobalScopes()
                ->where('cart_id', $checkout->cart_id)
                ->get()
                ->each(function (CartLine $line): void {
                    $inventory = InventoryItem::withoutGlobalScopes()
                        ->where('variant_id', $line->variant_id)
                        ->firstOrFail();

                    $this->inventory->reserve($inventory, $line->quantity);
                });

            $checkout->forceFill([
                'payment_method' => $paymentMethod,
                'status' => CheckoutStatus::PaymentSelected,
                'expires_at' => now()->addDay(),
            ])->save();

            return $checkout->refresh();
        });
    }

    public function expireCheckout(Checkout $checkout): Checkout
    {
        return DB::transaction(function () use ($checkout): Checkout {
            $checkout = $this->freshCheckout($checkout);

            if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) {
                return $checkout;
            }

            if ($checkout->status === CheckoutStatus::PaymentSelected) {
                CartLine::withoutGlobalScopes()
                    ->where('cart_id', $checkout->cart_id)
                    ->get()
                    ->each(function (CartLine $line): void {
                        $inventory = InventoryItem::withoutGlobalScopes()
                            ->where('variant_id', $line->variant_id)
                            ->firstOrFail();

                        $this->inventory->release($inventory, $line->quantity);
                    });
            }

            $checkout->forceFill([
                'status' => CheckoutStatus::Expired,
            ])->save();

            return $checkout->refresh();
        });
    }

    /**
     * @param  array<string, mixed>  $paymentMethodData
     */
    public function completeCheckout(Checkout $checkout, array $paymentMethodData = []): Order
    {
        return $this->orders->createFromCheckout($checkout, $paymentMethodData);
    }

    private function freshCheckout(Checkout $checkout): Checkout
    {
        return Checkout::withoutGlobalScopes()
            ->with(['cart', 'store'])
            ->whereKey($checkout->getKey())
            ->lockForUpdate()
            ->firstOrFail();
    }

    /**
     * @param  array<int, CheckoutStatus>  $allowed
     */
    private function assertStatus(Checkout $checkout, array $allowed): void
    {
        if (! in_array($checkout->status, $allowed, true)) {
            throw InvalidCheckoutTransitionException::because("Checkout cannot transition from {$checkout->status->value}.");
        }
    }
}


cmd: sed -n '1,180p' database/factories/DiscountFactory.php


command: sed -n '1,180p' database/factories/DiscountFactory.php

aggregated_output:
<?php

namespace Database\Factories;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Discount>
 */
class DiscountFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'type' => DiscountType::Code,
            'code' => fake()->unique()->bothify('SAVE##'),
            'value_type' => DiscountValueType::Percent,
            'value_amount' => 10,
            'starts_at' => now()->subDay(),
            'ends_at' => now()->addMonth(),
            'usage_limit' => null,
            'usage_count' => 0,
            'rules_json' => ['customer_eligibility' => 'all'],
            'status' => DiscountStatus::Active,
        ];
    }

    public function fixed(int $amount = 500): static
    {
        return $this->state(fn (array $attributes): array => [
            'value_type' => DiscountValueType::Fixed,
            'value_amount' => $amount,
        ]);
    }

    public function freeShipping(): static
    {
        return $this->state(fn (array $attributes): array => [
            'value_type' => DiscountValueType::FreeShipping,
            'value_amount' => 0,
        ]);
    }
}


output:
Chunk ID: f037b8
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 369
Output:
<?php

namespace Database\Factories;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Discount>
 */
class DiscountFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'type' => DiscountType::Code,
            'code' => fake()->unique()->bothify('SAVE##'),
            'value_type' => DiscountValueType::Percent,
            'value_amount' => 10,
            'starts_at' => now()->subDay(),
            'ends_at' => now()->addMonth(),
            'usage_limit' => null,
            'usage_count' => 0,
            'rules_json' => ['customer_eligibility' => 'all'],
            'status' => DiscountStatus::Active,
        ];
    }

    public function fixed(int $amount = 500): static
    {
        return $this->state(fn (array $attributes): array => [
            'value_type' => DiscountValueType::Fixed,
            'value_amount' => $amount,
        ]);
    }

    public function freeShipping(): static
    {
        return $this->state(fn (array $attributes): array => [
            'value_type' => DiscountValueType::FreeShipping,
            'value_amount' => 0,
        ]);
    }
}


message: The current order service only has a line-level aggregate and assumes that aggregate belongs to the entered code. I’m changing it to replay the same discount sequence in memory, then write one allocation entry per applied line-item discount.

phase: commentary


type: message

role: assistant

text: The current order service only has a line-level aggregate and assumes that aggregate belongs to the entered code. I’m changing it to replay the same discount sequence in memory, then write one allocation entry per applied line-item discount.


status: completed

input:
*** Begin Patch
*** Update File: app/Services/OrderService.php
@@
     public function __construct(
         private readonly PaymentService $payments,
         private readonly InventoryService $inventory,
         private readonly PricingEngine $pricing,
         private readonly FulfillmentService $fulfillments,
+        private readonly DiscountService $discounts,
     ) {}
@@
-                $this->createOrderLines($checkout, $order);
+                $this->createOrderLines($checkout, $order, $this->discountAllocationsByCartLine($checkout));
@@
-    private function createOrderLines(Checkout $checkout, Order $order): void
+    /**
+     * @param  array<int, list<array{discount_id: int|null, code: string|null, amount: int}>>  $discountAllocations
+     */
+    private function createOrderLines(Checkout $checkout, Order $order, array $discountAllocations): void
     {
-        $this->cartLines($checkout)->each(function (CartLine $line) use ($checkout, $order): void {
+        $this->cartLines($checkout)->each(function (CartLine $line) use ($discountAllocations, $order): void {
             $variant = $line->variant;

             $order->lines()->create([
@@
                 'total_amount' => $line->line_total_amount,
                 'tax_lines_json' => [],
-                'discount_allocations_json' => $this->discountAllocations($checkout, $line),
+                'discount_allocations_json' => $discountAllocations[$line->getKey()] ?? [],
             ]);
         });
     }
@@
-     * @return array<int, array{discount_id: int|null, code: string, amount: int}>
+     * @return array<int, list<array{discount_id: int|null, code: string|null, amount: int}>>
      */
-    private function discountAllocations(Checkout $checkout, CartLine $line): array
+    private function discountAllocationsByCartLine(Checkout $checkout): array
     {
-        if ($checkout->discount_code === null || $line->line_discount_amount <= 0) {
-            return [];
-        }
-
-        $code = trim($checkout->discount_code);
-        $discount = Discount::withoutGlobalScopes()
-            ->where('store_id', $checkout->store_id)
-            ->whereRaw('lower(code) = ?', [mb_strtolower($code)])
-            ->first();
+        $lines = $this->cartLines($checkout)
+            ->map(function (CartLine $line): CartLine {
+                $simulatedLine = clone $line;
+                $simulatedLine->forceFill([
+                    'line_discount_amount' => 0,
+                    'line_total_amount' => $line->line_subtotal_amount,
+                ]);

-        return [[
-            'discount_id' => $discount?->getKey(),
-            'code' => $code,
-            'amount' => $line->line_discount_amount,
-        ]];
+                return $simulatedLine;
+            });
+        $allocations = [];
+
+        foreach ($this->appliedDiscounts($checkout) as $discount) {
+            $result = $this->discounts->calculate($discount, $lines->sum('line_subtotal_amount'), $lines->all());
+
+            foreach ($result->allocations as $lineId => $amount) {
+                if ($amount <= 0) {
+                    continue;
+                }
+
+                $allocations[(int) $lineId][] = [
+                    'discount_id' => $discount->getKey(),
+                    'code' => $discount->code,
+                    'amount' => $amount,
+                ];
+            }
+
+            $lines->each(function (CartLine $line) use ($result): void {
+                $discountAmount = $result->allocations[$line->getKey()] ?? 0;
+
+                $line->forceFill([
+                    'line_discount_amount' => $line->line_discount_amount + $discountAmount,
+                    'line_total_amount' => max(0, $line->line_total_amount - $discountAmount),
+                ]);
+            });
+        }
+
+        return $allocations;
+    }
+
+    /**
+     * @return Collection<int, Discount>
+     */
+    private function appliedDiscounts(Checkout $checkout): Collection
+    {
+        $discounts = collect();
+        $code = trim((string) $checkout->discount_code);
+
+        if ($code !== '') {
+            $discount = Discount::withoutGlobalScopes()
+                ->where('store_id', $checkout->store_id)
+                ->whereRaw('lower(code) = ?', [mb_strtolower($code)])
+                ->first();
+
+            if ($discount instanceof Discount) {
+                $discounts->push($discount);
+            }
+        }
+
+        return $discounts
+            ->merge($this->discounts->automaticForCart($checkout->store, $checkout->cart))
+            ->values();
     }
*** End Patch


stdout:
Success. Updated the following files:
M app/Services/OrderService.php

type: update

unified_diff:
@@ -38,2 +38,3 @@
         private readonly FulfillmentService $fulfillments,
+        private readonly DiscountService $discounts,
     ) {}
@@ -98,3 +99,3 @@

-                $this->createOrderLines($checkout, $order);
+                $this->createOrderLines($checkout, $order, $this->discountAllocationsByCartLine($checkout));

@@ -250,5 +251,8 @@

-    private function createOrderLines(Checkout $checkout, Order $order): void
+    /**
+     * @param  array<int, list<array{discount_id: int|null, code: string|null, amount: int}>>  $discountAllocations
+     */
+    private function createOrderLines(Checkout $checkout, Order $order, array $discountAllocations): void
     {
-        $this->cartLines($checkout)->each(function (CartLine $line) use ($checkout, $order): void {
+        $this->cartLines($checkout)->each(function (CartLine $line) use ($discountAllocations, $order): void {
             $variant = $line->variant;
@@ -264,3 +268,3 @@
                 'tax_lines_json' => [],
-                'discount_allocations_json' => $this->discountAllocations($checkout, $line),
+                'discount_allocations_json' => $discountAllocations[$line->getKey()] ?? [],
             ]);
@@ -282,21 +286,68 @@
     /**
-     * @return array<int, array{discount_id: int|null, code: string, amount: int}>
+     * @return array<int, list<array{discount_id: int|null, code: string|null, amount: int}>>
      */
-    private function discountAllocations(Checkout $checkout, CartLine $line): array
+    private function discountAllocationsByCartLine(Checkout $checkout): array
     {
-        if ($checkout->discount_code === null || $line->line_discount_amount <= 0) {
-            return [];
+        $lines = $this->cartLines($checkout)
+            ->map(function (CartLine $line): CartLine {
+                $simulatedLine = clone $line;
+                $simulatedLine->forceFill([
+                    'line_discount_amount' => 0,
+                    'line_total_amount' => $line->line_subtotal_amount,
+                ]);
+
+                return $simulatedLine;
+            });
+        $allocations = [];
+
+        foreach ($this->appliedDiscounts($checkout) as $discount) {
+            $result = $this->discounts->calculate($discount, $lines->sum('line_subtotal_amount'), $lines->all());
+
+            foreach ($result->allocations as $lineId => $amount) {
+                if ($amount <= 0) {
+                    continue;
+                }
+
+                $allocations[(int) $lineId][] = [
+                    'discount_id' => $discount->getKey(),
+                    'code' => $discount->code,
+                    'amount' => $amount,
+                ];
+            }
+
+            $lines->each(function (CartLine $line) use ($result): void {
+                $discountAmount = $result->allocations[$line->getKey()] ?? 0;
+
+                $line->forceFill([
+                    'line_discount_amount' => $line->line_discount_amount + $discountAmount,
+                    'line_total_amount' => max(0, $line->line_total_amount - $discountAmount),
+                ]);
+            });
         }

-        $code = trim($checkout->discount_code);
-        $discount = Discount::withoutGlobalScopes()
-            ->where('store_id', $checkout->store_id)
-            ->whereRaw('lower(code) = ?', [mb_strtolower($code)])
-            ->first();
+        return $allocations;
+    }

-        return [[
-            'discount_id' => $discount?->getKey(),
-            'code' => $code,
-            'amount' => $line->line_discount_amount,
-        ]];
+    /**
+     * @return Collection<int, Discount>
+     */
+    private function appliedDiscounts(Checkout $checkout): Collection
+    {
+        $discounts = collect();
+        $code = trim((string) $checkout->discount_code);
+
+        if ($code !== '') {
+            $discount = Discount::withoutGlobalScopes()
+                ->where('store_id', $checkout->store_id)
+                ->whereRaw('lower(code) = ?', [mb_strtolower($code)])
+                ->first();
+
+            if ($discount instanceof Discount) {
+                $discounts->push($discount);
+            }
+        }
+
+        return $discounts
+            ->merge($this->discounts->automaticForCart($checkout->store, $checkout->cart))
+            ->values();
     }


output: {"output":"Success. Updated the following files:\nM app/Services/OrderService.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: tests/Feature/Orders/OrderServiceTest.php
@@
 use App\Enums\CartStatus;
 use App\Enums\CheckoutStatus;
+use App\Enums\DiscountType;
+use App\Enums\DiscountValueType;
 use App\Enums\FinancialStatus;
@@
 test('checkout completion creates a paid order and commits inventory', function () {
@@
 });
+
+test('checkout completion attributes automatic discounts on order lines', function () {
+    $store = orderCompletionStore();
+    $automatic = Discount::factory()
+        ->create([
+            'store_id' => $store->getKey(),
+            'type' => DiscountType::Automatic,
+            'code' => null,
+            'value_type' => DiscountValueType::Percent,
+            'value_amount' => 10,
+        ]);
+    [$checkout] = orderCompletionCheckout($store);
+
+    $order = app(CheckoutService::class)->completeCheckout($checkout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+
+    expect($order->discount_amount)->toBe(500)
+        ->and($order->lines->first()->discount_allocations_json)->toBe([[
+            'discount_id' => $automatic->getKey(),
+            'code' => null,
+            'amount' => 500,
+        ]]);
+});
+
+test('checkout completion keeps explicit and automatic discount allocations separate', function () {
+    $store = orderCompletionStore();
+    $codeDiscount = Discount::factory()
+        ->create([
+            'store_id' => $store->getKey(),
+            'code' => 'SAVE10',
+            'value_type' => DiscountValueType::Percent,
+            'value_amount' => 10,
+        ]);
+    $automatic = Discount::factory()
+        ->create([
+            'store_id' => $store->getKey(),
+            'type' => DiscountType::Automatic,
+            'code' => null,
+            'value_type' => DiscountValueType::Percent,
+            'value_amount' => 10,
+        ]);
+    [$checkout] = orderCompletionCheckout($store, discountCode: 'SAVE10');
+
+    $order = app(CheckoutService::class)->completeCheckout($checkout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+
+    expect($order->discount_amount)->toBe(950)
+        ->and($order->lines->first()->discount_allocations_json)->toBe([
+            [
+                'discount_id' => $codeDiscount->getKey(),
+                'code' => 'SAVE10',
+                'amount' => 500,
+            ],
+            [
+                'discount_id' => $automatic->getKey(),
+                'code' => null,
+                'amount' => 450,
+            ],
+        ]);
+});

 test('checkout completion is idempotent for the same checkout', function () {
*** End Patch


stdout:
Success. Updated the following files:
M tests/Feature/Orders/OrderServiceTest.php

unified_diff:
@@ -4,2 +4,4 @@
 use App\Enums\CheckoutStatus;
+use App\Enums\DiscountType;
+use App\Enums\DiscountValueType;
 use App\Enums\FinancialStatus;
@@ -185,2 +187,64 @@

+test('checkout completion attributes automatic discounts on order lines', function () {
+    $store = orderCompletionStore();
+    $automatic = Discount::factory()
+        ->create([
+            'store_id' => $store->getKey(),
+            'type' => DiscountType::Automatic,
+            'code' => null,
+            'value_type' => DiscountValueType::Percent,
+            'value_amount' => 10,
+        ]);
+    [$checkout] = orderCompletionCheckout($store);
+
+    $order = app(CheckoutService::class)->completeCheckout($checkout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+
+    expect($order->discount_amount)->toBe(500)
+        ->and($order->lines->first()->discount_allocations_json)->toBe([[
+            'discount_id' => $automatic->getKey(),
+            'code' => null,
+            'amount' => 500,
+        ]]);
+});
+
+test('checkout completion keeps explicit and automatic discount allocations separate', function () {
+    $store = orderCompletionStore();
+    $codeDiscount = Discount::factory()
+        ->create([
+            'store_id' => $store->getKey(),
+            'code' => 'SAVE10',
+            'value_type' => DiscountValueType::Percent,
+            'value_amount' => 10,
+        ]);
+    $automatic = Discount::factory()
+        ->create([
+            'store_id' => $store->getKey(),
+            'type' => DiscountType::Automatic,
+            'code' => null,
+            'value_type' => DiscountValueType::Percent,
+            'value_amount' => 10,
+        ]);
+    [$checkout] = orderCompletionCheckout($store, discountCode: 'SAVE10');
+
+    $order = app(CheckoutService::class)->completeCheckout($checkout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+
+    expect($order->discount_amount)->toBe(950)
+        ->and($order->lines->first()->discount_allocations_json)->toBe([
+            [
+                'discount_id' => $codeDiscount->getKey(),
+                'code' => 'SAVE10',
+                'amount' => 500,
+            ],
+            [
+                'discount_id' => $automatic->getKey(),
+                'code' => null,
+                'amount' => 450,
+            ],
+        ]);
+});
+
 test('checkout completion is idempotent for the same checkout', function () {


output: {"output":"Success. Updated the following files:\nM tests/Feature/Orders/OrderServiceTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


message: The order tests now cover automatic-only attribution and mixed code-plus-automatic attribution. I’m formatting the PHP and running the focused pricing/order tests first.


text: The order tests now cover automatic-only attribution and mixed code-plus-automatic attribution. I’m formatting the PHP and running the focused pricing/order tests first.


cmd: vendor/bin/pint --dirty --format agent && php artisan test --compact tests/Feature/Orders/OrderServiceTest.php tests/Feature/Checkout/PricingServicesTest.php


output:
Chunk ID: 38f159
Wall time: 1.0010 seconds
Process running with session ID 3417
Original token count: 5
Output:
{"result":"pass"}


command: vendor/bin/pint --dirty --format agent && php artisan test --compact tests/Feature/Orders/OrderServiceTest.php tests/Feature/Checkout/PricingServicesTest.php

type: unknown

aggregated_output:
{"result":"pass"}
  .............

  Tests:    13 passed (67 assertions)
  Duration: 0.65s


output:
Chunk ID: 8c9b8a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 19
Output:

  .............

  Tests:    13 passed (67 assertions)
  Duration: 0.65s


message: Focused coverage passes: 13 tests and 67 assertions. I’m running the adjacent checkout, order, API, and storefront checkout tests next because this changes the data copied from checkout into orders.


text: Focused coverage passes: 13 tests and 67 assertions. I’m running the adjacent checkout, order, API, and storefront checkout tests next because this changes the data copied from checkout into orders.


cmd: php artisan test --compact tests/Feature/Checkout tests/Feature/Orders tests/Feature/Api/StorefrontCheckoutApiTest.php tests/Feature/Storefront/CartCheckoutUiTest.php


output:
Chunk ID: 1ba537
Wall time: 1.0018 seconds
Process running with session ID 15811
Original token count: 8
Output:

  ............................


command: php artisan test --compact tests/Feature/Checkout tests/Feature/Orders tests/Feature/Api/StorefrontCheckoutApiTest.php tests/Feature/Storefront/CartCheckoutUiTest.php


output:
Chunk ID: fc0aff
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 17
Output:
.......

  Tests:    35 passed (200 assertions)
  Duration: 4.35s


message: The adjacent checkout/order surface is green: 35 tests, 200 assertions. I’m running the full Pest suite before updating the progress ledger and committing.


text: The adjacent checkout/order surface is green: 35 tests, 200 assertions. I’m running the full Pest suite before updating the progress ledger and committing.


output:
Chunk ID: d40824
Wall time: 1.0014 seconds
Process running with session ID 48346
Original token count: 1
Output:

  .


output:
Chunk ID: 2a4b05
Wall time: 5.0019 seconds
Process running with session ID 48346
Original token count: 7
Output:
.........................


output:
Chunk ID: 0fd377
Wall time: 5.0027 seconds
Process running with session ID 48346
Original token count: 9
Output:
...................................


output:
Chunk ID: ddadff
Wall time: 5.0015 seconds
Process running with session ID 48346
Original token count: 30
Output:
...............
  ............................................................................
  .......................


output:
Chunk ID: 3a1361
Wall time: 0.9793 seconds
Process exited with code 0
Original token count: 21
Output:
.....................

  Tests:    196 passed (974 assertions)
  Duration: 37.99s


command:
/bin/zsh
-lc
php artisan test --compact

cmd: php artisan test --compact


message: The full suite is green after the allocation change: 196 tests, 974 assertions. I’m updating `specs/progress.md` now so the remaining-gaps list matches the code.


text: The full suite is green after the allocation change: 196 tests, 974 assertions. I’m updating `specs/progress.md` now so the remaining-gaps list matches the code.


status: completed

input:
*** Begin Patch
*** Update File: specs/progress.md
@@
-| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SearchService` with per-store synonym expansion and stop-word removal, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener, developer token generation backed by `oauth_tokens`, `CartService`, `DiscountService` including one-per-customer code discount redemption checks, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, `PaymentService`, mock PSP, idempotent order creation, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, admin dashboard order aggregates, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/developers orchestration, admin order UI/API action orchestration, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. |
+| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SearchService` with per-store synonym expansion and stop-word removal, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener, developer token generation backed by `oauth_tokens`, `CartService`, `DiscountService` including one-per-customer code discount redemption checks and automatic/code order-line attribution, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, `PaymentService`, mock PSP, idempotent order creation, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, admin dashboard order aggregates, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/developers orchestration, admin order UI/API action orchestration, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. |
@@
 - 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the search synonym/stop-word parser changes.
 - 2026-05-04: `php artisan test --compact tests/Feature/Search/SearchServiceTest.php` passed after the search synonym/stop-word parser changes: 5 tests, 15 assertions.
 - 2026-05-04: `php artisan test --compact tests/Feature/Search/SearchServiceTest.php tests/Feature/Api/StorefrontSearchApiTest.php tests/Feature/Admin/SearchSettingsTest.php` passed after the search synonym/stop-word parser changes: 12 tests, 50 assertions.
 - 2026-05-04: `php artisan test --compact` passed after the search synonym/stop-word parser changes: 194 tests, 970 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 database/JSON testing docs and Pest 4 docs before the automatic discount attribution changes.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the automatic discount attribution changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Orders/OrderServiceTest.php tests/Feature/Checkout/PricingServicesTest.php` passed after the automatic discount attribution changes: 13 tests, 67 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Checkout tests/Feature/Orders tests/Feature/Api/StorefrontCheckoutApiTest.php tests/Feature/Storefront/CartCheckoutUiTest.php` passed after the automatic discount attribution changes: 35 tests, 200 assertions.
+- 2026-05-04: `php artisan test --compact` passed after the automatic discount attribution changes: 196 tests, 974 assertions.
@@
 - Admin discount forms persist minimum purchase, product eligibility, collection eligibility, and `one_per_customer` in the existing `discounts.rules_json` keys consumed by `DiscountService`; code discounts now check customer order-line allocation history before allowing another use.
 - `PricingEngine` applies an explicit checkout discount code first, then active automatic discounts returned by `DiscountService`.
+- Order creation replays the same code-then-automatic discount sequence in memory so `order_lines.discount_allocations_json` keeps separate `{discount_id, code, amount}` entries for every line-item discount while preserving aggregate cart line totals.
@@
-- Order line `discount_allocations_json` now records explicit checkout discount code and discount id; automatic discount attribution still needs to be recorded before final discount reporting is complete.
*** End Patch