Output:
Chunk ID: e51126
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1218
Output:
<?php

namespace App\Services;

use App\Exceptions\InvalidCheckoutTransitionException;
use App\Models\CartLine;
use App\Models\Checkout;
use App\Models\ShippingRate;
use App\Models\TaxSettings;
use App\ValueObjects\DiscountResult;
use App\ValueObjects\PricingResult;
use Illuminate\Support\Facades\DB;

class PricingEngine
{
    public function __construct(
        private readonly DiscountService $discounts,
        private readonly ShippingCalculator $shipping,
        private readonly TaxCalculator $taxes,
    ) {}

    public function calculate(Checkout $checkout): PricingResult
    {
        return DB::transaction(function () use ($checkout): PricingResult {
            $checkout = Checkout::withoutGlobalScopes()
                ->with(['cart.lines'])
                ->whereKey($checkout->getKey())
                ->firstOrFail();
            $cart = $checkout->cart;

            $this->resetLineAmounts($checkout);

            $lines = CartLine::withoutGlobalScopes()
                ->where('cart_id', $cart->getKey())
                ->orderBy('id')
                ->get();
            $subtotal = $lines->sum('line_subtotal_amount');
            $discountResult = new DiscountResult(0, []);

            if ($checkout->discount_code) {
                $discount = $this->discounts->validate($checkout->discount_code, $checkout->store, $cart);
                $discountResult = $this->discounts->applyToCart($cart, $discount);
                $lines = CartLine::withoutGlobalScopes()
                    ->where('cart_id', $cart->getKey())
                    ->orderBy('id')
                    ->get();
            }

            $shippingAmount = $this->shippingAmount($checkout, $discountResult);
            $taxSettings = $this->taxSettings($checkout);
            $taxResult = $this->taxes->calculateForAmounts(
                $lines->pluck('line_total_amount')->map(fn (int $amount): int => $amount)->all(),
                $shippingAmount,
                $taxSettings,
                $checkout->shipping_address_json ?? [],
            );
            $discountedSubtotal = $subtotal - $discountResult->amount;
            $total = $taxSettings->prices_include_tax
                ? $discountedSubtotal + $shippingAmount
                : $discountedSubtotal + $shippingAmount + $taxResult->totalAmount;

            $result = new PricingResult(
                subtotal: $subtotal,
                discount: $discountResult->amount,
                shipping: $shippingAmount,
                taxLines: $taxResult->taxLines,
                taxTotal: $taxResult->totalAmount,
                total: $total,
                currency: $cart->currency,
            );

            $checkout->forceFill([
                'tax_provider_snapshot_json' => $taxResult->toArray(),
                'totals_json' => $result->toArray(),
            ])->save();

            return $result;
        });
    }

    private function resetLineAmounts(Checkout $checkout): void
    {
        CartLine::withoutGlobalScopes()
            ->where('cart_id', $checkout->cart_id)
            ->get()
            ->each(function (CartLine $line): void {
                $subtotal = $line->unit_price_amount * $line->quantity;

                $line->forceFill([
                    'line_subtotal_amount' => $subtotal,
                    'line_discount_amount' => 0,
                    'line_total_amount' => $subtotal,
                ])->save();
            });
    }

    private function shippingAmount(Checkout $checkout, DiscountResult $discountResult): int
    {
        if (! $this->shipping->requiresShipping($checkout->cart)) {
            return 0;
        }

        if ($checkout->shipping_method_id === null) {
            return 0;
        }

        $rate = ShippingRate::withoutGlobalScopes()->find($checkout->shipping_method_id);

        if (! $rate instanceof ShippingRate) {
            throw InvalidCheckoutTransitionException::because('Shipping rate is not available.');
        }

        $amount = $this->shipping->calculate($rate, $checkout->cart);

        if ($amount === null) {
            throw InvalidCheckoutTransitionException::because('Shipping rate is not available for this cart.');
        }

        return $discountResult->freeShipping ? 0 : $amount;
    }

    private function taxSettings(Checkout $checkout): TaxSettings
    {
        return TaxSettings::withoutGlobalScopes()
            ->where('store_id', $checkout->store_id)
            ->first() ?? new TaxSettings([
                'store_id' => $checkout->store_id,
                'mode' => 'manual',
                'provider' => 'none',
                'prices_include_tax' => false,
                'config_json' => [
                    'default_rate_bps' => 0,
                    'shipping_taxable' => false,
                ],
            ]);
    }
}


sed -n '1,280p' app/Services/CheckoutService.php

CheckoutService.php

<?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\ShippingRate;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use RuntimeException;

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

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

    public function completeCheckout(Checkout $checkout, array $paymentMethodData = []): never
    {
        throw new RuntimeException('Order creation is implemented in the payments and orders phase.');
    }

    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: ec9f6d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2033
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\ShippingRate;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use RuntimeException;

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

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

    public function completeCheckout(Checkout $checkout, array $paymentMethodData = []): never
    {
        throw new RuntimeException('Order creation is implemented in the payments and orders phase.');
    }

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


rg -n "mergeOnLogin|merge|guest cart|customer cart|cart version|CartService" specs/05-BUSINESS-LOGIC.md specs/09-IMPLEMENTATION-ROADMAP.md specs/04-STOREFRONT-UI.md


sed -n '480,520p' specs/09-IMPLEMENTATION-ROADMAP.md


sed -n '240,330p' specs/05-BUSINESS-LOGIC.md


sed -n '480,520p' specs/09-IMPLEMENTATION-ROADMAP.md

| `Discount` | `belongsTo(Store)` |

**Enums to create:**

| Enum | Values |
|------|--------|
| `CartStatus` | `Active`, `Converted`, `Abandoned` |
| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |
| `DiscountType` | `Code`, `Automatic` |
| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |
| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |
| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |
| `TaxMode` | `Manual`, `Provider` |

#### Step 4.3: Cart Service

**File to create:** `App\Services\CartService`

| Method | Description |
|--------|-------------|
| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |
| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |
| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |
| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |
| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |
| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |

All mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.

#### Step 4.4: Discount Service

**File to create:** `App\Services\DiscountService`

| Method | Description |
|--------|-------------|
| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |
| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |

Case-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.

#### Step 4.5: Shipping Calculator


sed -n '240,330p' specs/05-BUSINESS-LOGIC.md

| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |
| **Restock** | Refund processed with restock flag | +quantity | No change | None |

### 3.4 Concurrency

SQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.

---

## 4. Cart Service

### 4.1 Cart Lifecycle

**Service:** `App\Services\CartService`

```mermaid
stateDiagram-v2
    [*] --> active : Cart created
    active --> converted : Checkout completed successfully
    active --> abandoned : Cleanup job (14 days inactive)
```

**Cart identification:**
- Guest users: cart ID stored in session cookie.
- Authenticated customers: cart linked via `customer_id` foreign key.

The guest cart is bound to the session using the key `cart_id`. When a guest visits the storefront, a cart is created on first add-to-cart and the cart's ID is stored in `session('cart_id')`. On customer login, if a session cart exists, it is merged with any existing customer cart (the session cart's items are added to the customer cart, preferring the higher quantity for duplicate variants). After merge, the session key is cleared.

**Cart merge on login:**

```
PROCEDURE mergeGuestCart(guestCart, customerCart):
    FOR EACH line IN guestCart.lines:
        existingLine = customerCart.findLineByVariant(line.variant_id)
        IF existingLine EXISTS:
            existingLine.quantity = MAX(existingLine.quantity, line.quantity)
            RECALCULATE existingLine amounts
        ELSE:
            MOVE line to customerCart
    MARK guestCart as abandoned
    RECALCULATE all line amounts on customerCart
```

### 4.2 Cart Operations

**Create cart:**
- Accepts: store, optional customer
- Sets: store_id, currency from store's default_currency, initial cart_version = 1

**Add line - validations (in order):**

1. Variant exists and belongs to the current store (through its product).
2. Product has status = `active`.
3. Variant has status = `active`.
4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.
5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.
6. Set `unit_price_amount` from the variant's `price_amount`.
7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.
8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).
9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.
10. Increment `cart_version`.

**Update quantity:**
- Validate new quantity > 0. If 0, remove the line instead.
- Re-check inventory policy for the new quantity.
- Recalculate line amounts.
- Increment `cart_version`.

**Remove line:**
- Delete the `cart_lines` record.
- Increment `cart_version`.

### 4.3 Cart Version (Optimistic Concurrency)

Every cart mutation increments `cart_version` by 1.

**For API clients:**
- Client sends `expected_version` with each mutation request.
- If cart's current version does not match the expected version, return HTTP 409 Conflict with the current cart state in the response body.
- This prevents race conditions when multiple browser tabs or devices modify the same cart.

**For Livewire/session-based flows:**
- Still implemented for consistency, but less critical since requests are serialized per session.

### 4.4 Line Amount Calculation

```
line_subtotal_amount = unit_price_amount * quantity
line_discount_amount = 0  (updated by discount engine during checkout)


rg -n "mergeOnLogin|merge|guest cart|customer cart|cart version|CartService" specs/05-BUSINESS-LOGIC.md specs/09-IMPLEMENTATION-ROADMAP.md specs/04-STOREFRONT-UI.md

rg -n 'mergeOnLogin|merge|guest cart|customer cart|cart version|CartService' specs/05-BUSINESS-LOGIC.md specs/09-IMPLEMENTATION-ROADMAP.md specs/04-STOREFRONT-UI.md

Question: mergeOnLogin|merge|guest cart|customer cart|cart version|CartService

specs/05-BUSINESS-LOGIC.md:255:**Service:** `App\Services\CartService`
specs/05-BUSINESS-LOGIC.md:268:The guest cart is bound to the session using the key `cart_id`. When a guest visits the storefront, a cart is created on first add-to-cart and the cart's ID is stored in `session('cart_id')`. On customer login, if a session cart exists, it is merged with any existing customer cart (the session cart's items are added to the customer cart, preferring the higher quantity for duplicate variants). After merge, the session key is cleared.
specs/05-BUSINESS-LOGIC.md:270:**Cart merge on login:**
specs/05-BUSINESS-LOGIC.md:273:PROCEDURE mergeGuestCart(guestCart, customerCart):
specs/05-BUSINESS-LOGIC.md:1641:| CartService | `App\Services` | Cart CRUD, line management, merge on login |
specs/09-IMPLEMENTATION-ROADMAP.md:496:**File to create:** `App\Services\CartService`
specs/09-IMPLEMENTATION-ROADMAP.md:505:| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |
specs/09-IMPLEMENTATION-ROADMAP.md:993:| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |
specs/09-IMPLEMENTATION-ROADMAP.md:1092:**Setup:** Instantiate Cart model or CartService.
specs/09-IMPLEMENTATION-ROADMAP.md:1185:| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1278:#### tests/Feature/Cart/CartServiceTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1291:| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1293:| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1894:| `Cart/CartServiceTest` | Cart service feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1958:- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)
specs/09-IMPLEMENTATION-ROADMAP.md:1967:| `CartService` | Cart CRUD, line management, version control, session binding |


Output:
Chunk ID: cf3c74
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 588
Output:
specs/05-BUSINESS-LOGIC.md:255:**Service:** `App\Services\CartService`
specs/05-BUSINESS-LOGIC.md:268:The guest cart is bound to the session using the key `cart_id`. When a guest visits the storefront, a cart is created on first add-to-cart and the cart's ID is stored in `session('cart_id')`. On customer login, if a session cart exists, it is merged with any existing customer cart (the session cart's items are added to the customer cart, preferring the higher quantity for duplicate variants). After merge, the session key is cleared.
specs/05-BUSINESS-LOGIC.md:270:**Cart merge on login:**
specs/05-BUSINESS-LOGIC.md:273:PROCEDURE mergeGuestCart(guestCart, customerCart):
specs/05-BUSINESS-LOGIC.md:1641:| CartService | `App\Services` | Cart CRUD, line management, merge on login |
specs/09-IMPLEMENTATION-ROADMAP.md:496:**File to create:** `App\Services\CartService`
specs/09-IMPLEMENTATION-ROADMAP.md:505:| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |
specs/09-IMPLEMENTATION-ROADMAP.md:993:| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |
specs/09-IMPLEMENTATION-ROADMAP.md:1092:**Setup:** Instantiate Cart model or CartService.
specs/09-IMPLEMENTATION-ROADMAP.md:1185:| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1278:#### tests/Feature/Cart/CartServiceTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1291:| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1293:| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1894:| `Cart/CartServiceTest` | Cart service feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1958:- Bind services to container (PaymentProvider, CartService, PricingEngine, etc.)
specs/09-IMPLEMENTATION-ROADMAP.md:1967:| `CartService` | Cart CRUD, line management, version control, session binding |


Output:
Chunk ID: 11f970
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 506
Output:
| `Discount` | `belongsTo(Store)` |

**Enums to create:**

| Enum | Values |
|------|--------|
| `CartStatus` | `Active`, `Converted`, `Abandoned` |
| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |
| `DiscountType` | `Code`, `Automatic` |
| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |
| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |
| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |
| `TaxMode` | `Manual`, `Provider` |

#### Step 4.3: Cart Service

**File to create:** `App\Services\CartService`

| Method | Description |
|--------|-------------|
| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |
| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |
| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |
| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |
| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |
| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |

All mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.

#### Step 4.4: Discount Service

**File to create:** `App\Services\DiscountService`

| Method | Description |
|--------|-------------|
| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |
| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |

Case-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.

#### Step 4.5: Shipping Calculator


Output:
Chunk ID: 86cd27
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 957
Output:
| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |
| **Restock** | Refund processed with restock flag | +quantity | No change | None |

### 3.4 Concurrency

SQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.

---

## 4. Cart Service

### 4.1 Cart Lifecycle

**Service:** `App\Services\CartService`

```mermaid
stateDiagram-v2
    [*] --> active : Cart created
    active --> converted : Checkout completed successfully
    active --> abandoned : Cleanup job (14 days inactive)
```

**Cart identification:**
- Guest users: cart ID stored in session cookie.
- Authenticated customers: cart linked via `customer_id` foreign key.

The guest cart is bound to the session using the key `cart_id`. When a guest visits the storefront, a cart is created on first add-to-cart and the cart's ID is stored in `session('cart_id')`. On customer login, if a session cart exists, it is merged with any existing customer cart (the session cart's items are added to the customer cart, preferring the higher quantity for duplicate variants). After merge, the session key is cleared.

**Cart merge on login:**

```
PROCEDURE mergeGuestCart(guestCart, customerCart):
    FOR EACH line IN guestCart.lines:
        existingLine = customerCart.findLineByVariant(line.variant_id)
        IF existingLine EXISTS:
            existingLine.quantity = MAX(existingLine.quantity, line.quantity)
            RECALCULATE existingLine amounts
        ELSE:
            MOVE line to customerCart
    MARK guestCart as abandoned
    RECALCULATE all line amounts on customerCart
```

### 4.2 Cart Operations

**Create cart:**
- Accepts: store, optional customer
- Sets: store_id, currency from store's default_currency, initial cart_version = 1

**Add line - validations (in order):**

1. Variant exists and belongs to the current store (through its product).
2. Product has status = `active`.
3. Variant has status = `active`.
4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.
5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.
6. Set `unit_price_amount` from the variant's `price_amount`.
7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.
8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).
9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.
10. Increment `cart_version`.

**Update quantity:**
- Validate new quantity > 0. If 0, remove the line instead.
- Re-check inventory policy for the new quantity.
- Recalculate line amounts.
- Increment `cart_version`.

**Remove line:**
- Delete the `cart_lines` record.
- Increment `cart_version`.

### 4.3 Cart Version (Optimistic Concurrency)

Every cart mutation increments `cart_version` by 1.

**For API clients:**
- Client sends `expected_version` with each mutation request.
- If cart's current version does not match the expected version, return HTTP 409 Conflict with the current cart state in the response body.
- This prevents race conditions when multiple browser tabs or devices modify the same cart.

**For Livewire/session-based flows:**
- Still implemented for consistency, but less critical since requests are serialized per session.

### 4.4 Line Amount Calculation

```
line_subtotal_amount = unit_price_amount * quantity
line_discount_amount = 0  (updated by discount engine during checkout)


sed -n '1,220p' database/migrations/2026_05_03_225427_create_discounts_table.php


rg -n "discounts|starts_at|ends_at|discount" specs/01-DATABASE-SCHEMA.md specs/05-BUSINESS-LOGIC.md


sed -n '1,220p' database/migrations/2026_05_03_225427_create_discounts_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('discounts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->enum('type', ['code', 'automatic'])->default('code');
            $table->string('code')->nullable();
            $table->enum('value_type', ['fixed', 'percent', 'free_shipping']);
            $table->integer('value_amount')->default(0);
            $table->timestamp('starts_at');
            $table->timestamp('ends_at')->nullable();
            $table->unsignedInteger('usage_limit')->nullable();
            $table->unsignedInteger('usage_count')->default(0);
            $table->text('rules_json')->default('{}');
            $table->enum('status', ['draft', 'active', 'expired', 'disabled'])->default('active');
            $table->timestamps();

            $table->unique(['store_id', 'code']);
            $table->index('store_id');
            $table->index(['store_id', 'status']);
            $table->index(['store_id', 'type']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('discounts');
    }
};


Output:
Chunk ID: 89974a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 360
Output:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('discounts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->enum('type', ['code', 'automatic'])->default('code');
            $table->string('code')->nullable();
            $table->enum('value_type', ['fixed', 'percent', 'free_shipping']);
            $table->integer('value_amount')->default(0);
            $table->timestamp('starts_at');
            $table->timestamp('ends_at')->nullable();
            $table->unsignedInteger('usage_limit')->nullable();
            $table->unsignedInteger('usage_count')->default(0);
            $table->text('rules_json')->default('{}');
            $table->enum('status', ['draft', 'active', 'expired', 'disabled'])->default('active');
            $table->timestamps();

            $table->unique(['store_id', 'code']);
            $table->index('store_id');
            $table->index(['store_id', 'status']);
            $table->index(['store_id', 'type']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('discounts');
    }
};


/bin/zsh
-lc
sed -n '1,220p' app/Models/Discount.php

<?php

namespace App\Models;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Discount extends Model
{
    /** @use HasFactory<\Database\Factories\DiscountFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'type',
        'code',
        'value_type',
        'value_amount',
        'starts_at',
        'ends_at',
        'usage_limit',
        'usage_count',
        'rules_json',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'type' => 'code',
        'value_amount' => 0,
        'usage_count' => 0,
        'rules_json' => '{}',
        'status' => 'active',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'type' => DiscountType::class,
            'value_type' => DiscountValueType::class,
            'value_amount' => 'integer',
            'starts_at' => 'datetime',
            'ends_at' => 'datetime',
            'usage_limit' => 'integer',
            'usage_count' => 'integer',
            'rules_json' => 'array',
            'status' => DiscountStatus::class,
        ];
    }
}


rg -n "discounts|starts_at|ends_at|discount" specs/01-DATABASE-SCHEMA.md specs/05-BUSINESS-LOGIC.md

rg -n 'discounts|starts_at|ends_at|discount' specs/01-DATABASE-SCHEMA.md specs/05-BUSINESS-LOGIC.md

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: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: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:608:| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |
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:620:| `active` | Live and applicable to carts (subject to date range and usage limits). | Automatic transition when `starts_at` is reached (if set). Admin can manually activate. |
specs/05-BUSINESS-LOGIC.md:621:| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |
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: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: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:666:    D -->|Yes| F{starts_at <= now?}
specs/05-BUSINESS-LOGIC.md:667:    F -->|No| G["Error: discount_not_yet_active"]
specs/05-BUSINESS-LOGIC.md:668:    F -->|Yes| H{ends_at is null OR ends_at >= now?}
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 …1202 chars truncated…nt = 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: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: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 |
specs/01-DATABASE-SCHEMA.md:565:| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |
specs/01-DATABASE-SCHEMA.md:566:| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |
specs/01-DATABASE-SCHEMA.md:573:**Notes:** All `_amount` columns are in minor units (cents). `line_subtotal_amount = unit_price_amount * quantity`. `line_total_amount = line_subtotal_amount - line_discount_amount`.
specs/01-DATABASE-SCHEMA.md:593:| discount_code | TEXT | Yes | NULL | - | Applied discount code |
specs/01-DATABASE-SCHEMA.md:595:| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |
specs/01-DATABASE-SCHEMA.md:608:**Notes:** `totals_json` contains a snapshot: `{"subtotal": 5000, "discount": 500, "shipping": 799, "tax": 430, "total": 5729}`. `tax_provider_snapshot_json` stores the full tax calculation response for audit purposes.
specs/01-DATABASE-SCHEMA.md:672:### discounts
specs/01-DATABASE-SCHEMA.md:674:Discount codes and automatic discounts.
specs/01-DATABASE-SCHEMA.md:684:| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |
specs/01-DATABASE-SCHEMA.md:685:| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |
specs/01-DATABASE-SCHEMA.md:695:- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store
specs/01-DATABASE-SCHEMA.md:696:- `idx_discounts_store_id` on (`store_id`) - FK lookup
specs/01-DATABASE-SCHEMA.md:697:- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status
specs/01-DATABASE-SCHEMA.md:698:- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic
specs/01-DATABASE-SCHEMA.md:767:| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |
specs/01-DATABASE-SCHEMA.md:806:| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |
specs/01-DATABASE-SCHEMA.md:814:**Notes:** `product_id` and `variant_id` are nullable with SET NULL on delete so that order history survives product deletion. `tax_lines_json` example: `[{"title": "State Tax", "rate": 800, "amount": 400}]` (rate in basis points, e.g. 800 = 8%). `discount_allocations_json` example: `[{"discount_id": 5, "amount": 250}]`.
specs/01-DATABASE-SCHEMA.md:1534:        discounts
specs/01-DATABASE-SCHEMA.md:1594:    stores --> discounts
specs/01-DATABASE-SCHEMA.md:1645:- **Batch 3** (depends on Batch 2): `store_domains`, `store_users`, `store_settings`, `customers`, `themes`, `pages`, `navigation_menus`, `search_settings`, `shipping_zones`, `tax_settings`, `discounts`, `products`, `collections`, `app_installations`, `oauth_clients`
specs/01-DATABASE-SCHEMA.md:1677:| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |


Output:
Chunk ID: 552160
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2801
Output:
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: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: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:608:| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |
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:620:| `active` | Live and applicable to carts (subject to date range and usage limits). | Automatic transition when `starts_at` is reached (if set). Admin can manually activate. |
specs/05-BUSINESS-LOGIC.md:621:| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |
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: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: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:666:    D -->|Yes| F{starts_at <= now?}
specs/05-BUSINESS-LOGIC.md:667:    F -->|No| G["Error: discount_not_yet_active"]
specs/05-BUSINESS-LOGIC.md:668:    F -->|Yes| H{ends_at is null OR ends_at >= now?}
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: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: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 |
specs/01-DATABASE-SCHEMA.md:565:| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |
specs/01-DATABASE-SCHEMA.md:566:| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |
specs/01-DATABASE-SCHEMA.md:573:**Notes:** All `_amount` columns are in minor units (cents). `line_subtotal_amount = unit_price_amount * quantity`. `line_total_amount = line_subtotal_amount - line_discount_amount`.
specs/01-DATABASE-SCHEMA.md:593:| discount_code | TEXT | Yes | NULL | - | Applied discount code |
specs/01-DATABASE-SCHEMA.md:595:| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |
specs/01-DATABASE-SCHEMA.md:608:**Notes:** `totals_json` contains a snapshot: `{"subtotal": 5000, "discount": 500, "shipping": 799, "tax": 430, "total": 5729}`. `tax_provider_snapshot_json` stores the full tax calculation response for audit purposes.
specs/01-DATABASE-SCHEMA.md:672:### discounts
specs/01-DATABASE-SCHEMA.md:674:Discount codes and automatic discounts.
specs/01-DATABASE-SCHEMA.md:684:| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |
specs/01-DATABASE-SCHEMA.md:685:| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |
specs/01-DATABASE-SCHEMA.md:695:- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store
specs/01-DATABASE-SCHEMA.md:696:- `idx_discounts_store_id` on (`store_id`) - FK lookup
specs/01-DATABASE-SCHEMA.md:697:- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status
specs/01-DATABASE-SCHEMA.md:698:- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic
specs/01-DATABASE-SCHEMA.md:767:| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |
specs/01-DATABASE-SCHEMA.md:806:| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |
specs/01-DATABASE-SCHEMA.md:814:**Notes:** `product_id` and `variant_id` are nullable with SET NULL on delete so that order history survives product deletion. `tax_lines_json` example: `[{"title": "State Tax", "rate": 800, "amount": 400}]` (rate in basis points, e.g. 800 = 8%). `discount_allocations_json` example: `[{"discount_id": 5, "amount": 250}]`.
specs/01-DATABASE-SCHEMA.md:1534:        discounts
specs/01-DATABASE-SCHEMA.md:1594:    stores --> discounts
specs/01-DATABASE-SCHEMA.md:1645:- **Batch 3** (depends on Batch 2): `store_domains`, `store_users`, `store_settings`, `customers`, `themes`, `pages`, `navigation_menus`, `search_settings`, `shipping_zones`, `tax_settings`, `discounts`, `products`, `collections`, `app_installations`, `oauth_clients`
specs/01-DATABASE-SCHEMA.md:1677:| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |


Output:
Chunk ID: 421d4c
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 410
Output:
<?php

namespace App\Models;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Discount extends Model
{
    /** @use HasFactory<\Database\Factories\DiscountFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'type',
        'code',
        'value_type',
        'value_amount',
        'starts_at',
        'ends_at',
        'usage_limit',
        'usage_count',
        'rules_json',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'type' => 'code',
        'value_amount' => 0,
        'usage_count' => 0,
        'rules_json' => '{}',
        'status' => 'active',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'type' => DiscountType::class,
            'value_type' => DiscountValueType::class,
            'value_amount' => 'integer',
            'starts_at' => 'datetime',
            'ends_at' => 'datetime',
            'usage_limit' => 'integer',
            'usage_count' => 'integer',
            'rules_json' => 'array',
            'status' => DiscountStatus::class,
        ];
    }
}


sed -n '1,260p' app/Services/TaxCalculator.php


sed -n '1,260p' app/Services/Tax/ManualTaxProvider.php


sed -n '1,220p' app/Services/ShippingCalculator.php


sed -n '1,220p' app/Services/Tax/StripeTaxProvider.php


sed -n '1,260p' app/Services/TaxCalculator.php

<?php

namespace App\Services;

use App\Enums\TaxMode;
use App\Models\TaxSettings;
use App\Services\Tax\ManualTaxProvider;
use App\Services\Tax\StripeTaxProvider;
use App\ValueObjects\TaxResult;

class TaxCalculator
{
    public function __construct(
        private readonly ManualTaxProvider $manual,
        private readonly StripeTaxProvider $stripe,
    ) {}

    /**
     * @param  array<string, mixed>  $address
     */
    public function calculate(int $amount, TaxSettings $settings, array $address): TaxResult
    {
        return $this->calculateForAmounts([$amount], 0, $settings, $address);
    }

    /**
     * @param  array<int, int>  $amounts
     * @param  array<string, mixed>  $address
     */
    public function calculateForAmounts(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult
    {
        if ($settings->mode === TaxMode::Provider && $settings->provider === 'stripe_tax') {
            return $this->stripe->calculate($amounts, $shippingAmount, $settings, $address);
        }

        return $this->manual->calculate($amounts, $shippingAmount, $settings, $address);
    }

    public function extractInclusive(int $grossAmount, int $rateBasisPoints): int
    {
        return $this->manual->extractInclusive($grossAmount, $rateBasisPoints);
    }

    public function addExclusive(int $netAmount, int $rateBasisPoints): int
    {
        return $this->manual->addExclusive($netAmount, $rateBasisPoints);
    }
}


sed -n '1,260p' app/Services/Tax/ManualTaxProvider.php

<?php

namespace App\Services\Tax;

use App\Contracts\TaxProvider;
use App\Models\TaxSettings;
use App\ValueObjects\TaxLine;
use App\ValueObjects\TaxResult;

class ManualTaxProvider implements TaxProvider
{
    /**
     * @param  array<int, int>  $amounts
     * @param  array<string, mixed>  $address
     */
    public function calculate(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult
    {
        $rate = $this->rateFor($settings, $address);

        if ($rate <= 0) {
            return new TaxResult([], 0, 0);
        }

        if ((bool) data_get($settings->config_json, 'shipping_taxable', true) && $shippingAmount > 0) {
            $amounts[] = $shippingAmount;
        }

        $taxAmount = collect($amounts)->sum(fn (int $amount): int => $settings->prices_include_tax
            ? $this->extractInclusive($amount, $rate)
            : $this->addExclusive($amount, $rate)
        );

        return new TaxResult([
            new TaxLine($this->nameFor($settings, $address), $rate, $taxAmount),
        ], $taxAmount, $rate);
    }

    /**
     * @param  array<string, mixed>  $address
     */
    public function rateFor(TaxSettings $settings, array $address): int
    {
        $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country')));
        $province = strtoupper((string) data_get($address, 'province_code'));

        foreach (data_get($settings->config_json, 'rates', []) as $rate) {
            $rateCountry = strtoupper((string) data_get($rate, 'country'));
            $rateProvince = strtoupper((string) data_get($rate, 'province_code'));

            if ($rateCountry !== '' && $rateCountry !== $country) {
                continue;
            }

            if ($rateProvince !== '' && $rateProvince !== $province) {
                continue;
            }

            return (int) data_get($rate, 'rate_bps', 0);
        }

        return (int) data_get($settings->config_json, 'default_rate_bps', 0);
    }

    /**
     * @param  array<string, mixed>  $address
     */
    public function nameFor(TaxSettings $settings, array $address): string
    {
        $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country')));

        foreach (data_get($settings->config_json, 'rates', []) as $rate) {
            if (strtoupper((string) data_get($rate, 'country')) === $country && data_get($rate, 'name')) {
                return (string) data_get($rate, 'name');
            }
        }

        return (string) data_get($settings->config_json, 'name', 'Tax');
    }

    public function extractInclusive(int $grossAmount, int $rateBasisPoints): int
    {
        if ($grossAmount <= 0 || $rateBasisPoints <= 0) {
            return 0;
        }

        return $grossAmount - intdiv($grossAmount * 10000, 10000 + $rateBasisPoints);
    }

    public function addExclusive(int $netAmount, int $rateBasisPoints): int
    {
        if ($netAmount <= 0 || $rateBasisPoints <= 0) {
            return 0;
        }

        return (int) round($netAmount * $rateBasisPoints / 10000);
    }
}


sed -n '1,220p' app/Services/ShippingCalculator.php

<?php

namespace App\Services;

use App\Enums\ShippingRateType;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\ProductVariant;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\Store;
use Illuminate\Support\Collection;

class ShippingCalculator
{
    /**
     * @param  array<string, mixed>  $address
     * @return Collection<int, ShippingRate>
     */
    public function getAvailableRates(Store $store, array $address): Collection
    {
        $zone = $this->matchingZone($store, $address);

        if (! $zone instanceof ShippingZone) {
            return collect();
        }

        return ShippingRate::withoutGlobalScopes()
            ->where('zone_id', $zone->getKey())
            ->where('is_active', true)
            ->orderBy('id')
            ->get();
    }

    /**
     * @param  array<string, mixed>  $address
     */
    public function matchingZone(Store $store, array $address): ?ShippingZone
    {
        $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country')));
        $region = strtoupper((string) data_get($address, 'province_code'));
        $bestZone = null;
        $bestSpecificity = -1;

        ShippingZone::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->orderBy('id')
            ->get()
            ->each(function (ShippingZone $zone) use ($country, $region, &$bestZone, &$bestSpecificity): void {
                $countries = collect($zone->countries_json)->map(fn (string $code): string => strtoupper($code));
                $regions = collect($zone->regions_json)->map(fn (string $code): string => strtoupper($code));

                if (! $countries->contains($country)) {
                    return;
                }

                $specificity = $regions->contains($region) ? 2 : 1;

                if ($specificity > $bestSpecificity) {
                    $bestZone = $zone;
                    $bestSpecificity = $specificity;
                }
            });

        return $bestZone;
    }

    public function calculate(ShippingRate $rate, Cart $cart): ?int
    {
        if (! $this->requiresShipping($cart)) {
            return 0;
        }

        return match ($rate->type) {
            ShippingRateType::Flat => (int) data_get($rate->config_json, 'amount', 0),
            ShippingRateType::Weight => $this->weightRate($rate, $cart),
            ShippingRateType::Price => $this->priceRate($rate, $cart),
            ShippingRateType::Carrier => (int) data_get($rate->config_json, 'amount', 1299),
        };
    }

    public function requiresShipping(Cart $cart): bool
    {
        return $this->physicalLines($cart)->isNotEmpty();
    }

    private function weightRate(ShippingRate $rate, Cart $cart): ?int
    {
        $totalWeight = $this->physicalLines($cart)
            ->sum(function (CartLine $line): int {
                $variant = $this->variant($line);

                return ($variant?->weight_g ?? 0) * $line->quantity;
            });

        foreach (data_get($rate->config_json, 'ranges', []) as $range) {
            $minimum = (int) data_get($range, 'min_g', 0);
            $maximum = data_get($range, 'max_g');

            if ($totalWeight >= $minimum && ($maximum === null || $totalWeight <= (int) $maximum)) {
                return (int) data_get($range, 'amount', 0);
            }
        }

        return null;
    }

    private function priceRate(ShippingRate $rate, Cart $cart): ?int
    {
        $subtotal = CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->sum('line_subtotal_amount');

        foreach (data_get($rate->config_json, 'ranges', []) as $range) {
            $minimum = (int) data_get($range, 'min_amount', 0);
            $maximum = data_get($range, 'max_amount');

            if ($subtotal >= $minimum && ($maximum === null || $subtotal <= (int) $maximum)) {
                return (int) data_get($range, 'amount', 0);
            }
        }

        return null;
    }

    /**
     * @return Collection<int, CartLine>
     */
    private function physicalLines(Cart $cart): Collection
    {
        return CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->get()
            ->filter(function (CartLine $line): bool {
                return (bool) $this->variant($line)?->requires_shipping;
            });
    }

    private function variant(CartLine $line): ?ProductVariant
    {
        return ProductVariant::withoutGlobalScopes()
            ->whereKey($line->variant_id)
            ->first();
    }
}


Output:
Chunk ID: 9ca19f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 370
Output:
<?php

namespace App\Services;

use App\Enums\TaxMode;
use App\Models\TaxSettings;
use App\Services\Tax\ManualTaxProvider;
use App\Services\Tax\StripeTaxProvider;
use App\ValueObjects\TaxResult;

class TaxCalculator
{
    public function __construct(
        private readonly ManualTaxProvider $manual,
        private readonly StripeTaxProvider $stripe,
    ) {}

    /**
     * @param  array<string, mixed>  $address
     */
    public function calculate(int $amount, TaxSettings $settings, array $address): TaxResult
    {
        return $this->calculateForAmounts([$amount], 0, $settings, $address);
    }

    /**
     * @param  array<int, int>  $amounts
     * @param  array<string, mixed>  $address
     */
    public function calculateForAmounts(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult
    {
        if ($settings->mode === TaxMode::Provider && $settings->provider === 'stripe_tax') {
            return $this->stripe->calculate($amounts, $shippingAmount, $settings, $address);
        }

        return $this->manual->calculate($amounts, $shippingAmount, $settings, $address);
    }

    public function extractInclusive(int $grossAmount, int $rateBasisPoints): int
    {
        return $this->manual->extractInclusive($grossAmount, $rateBasisPoints);
    }

    public function addExclusive(int $netAmount, int $rateBasisPoints): int
    {
        return $this->manual->addExclusive($netAmount, $rateBasisPoints);
    }
}


Output:
Chunk ID: 3cbed8
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 787
Output:
<?php

namespace App\Services\Tax;

use App\Contracts\TaxProvider;
use App\Models\TaxSettings;
use App\ValueObjects\TaxLine;
use App\ValueObjects\TaxResult;

class ManualTaxProvider implements TaxProvider
{
    /**
     * @param  array<int, int>  $amounts
     * @param  array<string, mixed>  $address
     */
    public function calculate(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult
    {
        $rate = $this->rateFor($settings, $address);

        if ($rate <= 0) {
            return new TaxResult([], 0, 0);
        }

        if ((bool) data_get($settings->config_json, 'shipping_taxable', true) && $shippingAmount > 0) {
            $amounts[] = $shippingAmount;
        }

        $taxAmount = collect($amounts)->sum(fn (int $amount): int => $settings->prices_include_tax
            ? $this->extractInclusive($amount, $rate)
            : $this->addExclusive($amount, $rate)
        );

        return new TaxResult([
            new TaxLine($this->nameFor($settings, $address), $rate, $taxAmount),
        ], $taxAmount, $rate);
    }

    /**
     * @param  array<string, mixed>  $address
     */
    public function rateFor(TaxSettings $settings, array $address): int
    {
        $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country')));
        $province = strtoupper((string) data_get($address, 'province_code'));

        foreach (data_get($settings->config_json, 'rates', []) as $rate) {
            $rateCountry = strtoupper((string) data_get($rate, 'country'));
            $rateProvince = strtoupper((string) data_get($rate, 'province_code'));

            if ($rateCountry !== '' && $rateCountry !== $country) {
                continue;
            }

            if ($rateProvince !== '' && $rateProvince !== $province) {
                continue;
            }

            return (int) data_get($rate, 'rate_bps', 0);
        }

        return (int) data_get($settings->config_json, 'default_rate_bps', 0);
    }

    /**
     * @param  array<string, mixed>  $address
     */
    public function nameFor(TaxSettings $settings, array $address): string
    {
        $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country')));

        foreach (data_get($settings->config_json, 'rates', []) as $rate) {
            if (strtoupper((string) data_get($rate, 'country')) === $country && data_get($rate, 'name')) {
                return (string) data_get($rate, 'name');
            }
        }

        return (string) data_get($settings->config_json, 'name', 'Tax');
    }

    public function extractInclusive(int $grossAmount, int $rateBasisPoints): int
    {
        if ($grossAmount <= 0 || $rateBasisPoints <= 0) {
            return 0;
        }

        return $grossAmount - intdiv($grossAmount * 10000, 10000 + $rateBasisPoints);
    }

    public function addExclusive(int $netAmount, int $rateBasisPoints): int
    {
        if ($netAmount <= 0 || $rateBasisPoints <= 0) {
            return 0;
        }

        return (int) round($netAmount * $rateBasisPoints / 10000);
    }
}


Output:
Chunk ID: 84dbb3
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1162
Output:
<?php

namespace App\Services;

use App\Enums\ShippingRateType;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\ProductVariant;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\Store;
use Illuminate\Support\Collection;

class ShippingCalculator
{
    /**
     * @param  array<string, mixed>  $address
     * @return Collection<int, ShippingRate>
     */
    public function getAvailableRates(Store $store, array $address): Collection
    {
        $zone = $this->matchingZone($store, $address);

        if (! $zone instanceof ShippingZone) {
            return collect();
        }

        return ShippingRate::withoutGlobalScopes()
            ->where('zone_id', $zone->getKey())
            ->where('is_active', true)
            ->orderBy('id')
            ->get();
    }

    /**
     * @param  array<string, mixed>  $address
     */
    public function matchingZone(Store $store, array $address): ?ShippingZone
    {
        $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country')));
        $region = strtoupper((string) data_get($address, 'province_code'));
        $bestZone = null;
        $bestSpecificity = -1;

        ShippingZone::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->orderBy('id')
            ->get()
            ->each(function (ShippingZone $zone) use ($country, $region, &$bestZone, &$bestSpecificity): void {
                $countries = collect($zone->countries_json)->map(fn (string $code): string => strtoupper($code));
                $regions = collect($zone->regions_json)->map(fn (string $code): string => strtoupper($code));

                if (! $countries->contains($country)) {
                    return;
                }

                $specificity = $regions->contains($region) ? 2 : 1;

                if ($specificity > $bestSpecificity) {
                    $bestZone = $zone;
                    $bestSpecificity = $specificity;
                }
            });

        return $bestZone;
    }

    public function calculate(ShippingRate $rate, Cart $cart): ?int
    {
        if (! $this->requiresShipping($cart)) {
            return 0;
        }

        return match ($rate->type) {
            ShippingRateType::Flat => (int) data_get($rate->config_json, 'amount', 0),
            ShippingRateType::Weight => $this->weightRate($rate, $cart),
            ShippingRateType::Price => $this->priceRate($rate, $cart),
            ShippingRateType::Carrier => (int) data_get($rate->config_json, 'amount', 1299),
        };
    }

    public function requiresShipping(Cart $cart): bool
    {
        return $this->physicalLines($cart)->isNotEmpty();
    }

    private function weightRate(ShippingRate $rate, Cart $cart): ?int
    {
        $totalWeight = $this->physicalLines($cart)
            ->sum(function (CartLine $line): int {
                $variant = $this->variant($line);

                return ($variant?->weight_g ?? 0) * $line->quantity;
            });

        foreach (data_get($rate->config_json, 'ranges', []) as $range) {
            $minimum = (int) data_get($range, 'min_g', 0);
            $maximum = data_get($range, 'max_g');

            if ($totalWeight >= $minimum && ($maximum === null || $totalWeight <= (int) $maximum)) {
                return (int) data_get($range, 'amount', 0);
            }
        }

        return null;
    }

    private function priceRate(ShippingRate $rate, Cart $cart): ?int
    {
        $subtotal = CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->sum('line_subtotal_amount');

        foreach (data_get($rate->config_json, 'ranges', []) as $range) {
            $minimum = (int) data_get($range, 'min_amount', 0);
            $maximum = data_get($range, 'max_amount');

            if ($subtotal >= $minimum && ($maximum === null || $subtotal <= (int) $maximum)) {
                return (int) data_get($range, 'amount', 0);
            }
        }

        return null;
    }

    /**
     * @return Collection<int, CartLine>
     */
    private function physicalLines(Cart $cart): Collection
    {
        return CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->get()
            ->filter(function (CartLine $line): bool {
                return (bool) $this->variant($line)?->requires_shipping;
            });
    }

    private function variant(CartLine $line): ?ProductVariant
    {
        return ProductVariant::withoutGlobalScopes()
            ->whereKey($line->variant_id)
            ->first();
    }
}


sed -n '1,220p' app/Services/Tax/StripeTaxProvider.php

<?php

namespace App\Services\Tax;

use App\Contracts\TaxProvider;
use App\Models\TaxSettings;
use App\ValueObjects\TaxResult;
use RuntimeException;

class StripeTaxProvider implements TaxProvider
{
    /**
     * @param  array<int, int>  $amounts
     * @param  array<string, mixed>  $address
     */
    public function calculate(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult
    {
        if (data_get($settings->config_json, 'fallback') === 'block') {
            throw new RuntimeException('Stripe Tax provider is not configured.');
        }

        return new TaxResult([], 0, 0);
    }
}


Output:
Chunk ID: 6ff3a1
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 160
Output:
<?php

namespace App\Services\Tax;

use App\Contracts\TaxProvider;
use App\Models\TaxSettings;
use App\ValueObjects\TaxResult;
use RuntimeException;

class StripeTaxProvider implements TaxProvider
{
    /**
     * @param  array<int, int>  $amounts
     * @param  array<string, mixed>  $address
     */
    public function calculate(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult
    {
        if (data_get($settings->config_json, 'fallback') === 'block') {
            throw new RuntimeException('Stripe Tax provider is not configured.');
        }

        return new TaxResult([], 0, 0);
    }
}


sed -n '620,672p' specs/01-DATABASE-SCHEMA.md


sed -n '860,930p' specs/05-BUSINESS-LOGIC.md


rg -n "matchingZone|regions_json|shipping zone|zone" specs/05-BUSINESS-LOGIC.md specs/09-IMPLEMENTATION-ROADMAP.md specs/01-DATABASE-SCHEMA.md


sed -n '860,930p' specs/05-BUSINESS-LOGIC.md

### 9.2 Rate Types

For the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:

#### Flat rate

Config structure: `{"amount": 500}`

- Cost = fixed amount regardless of cart contents.

#### Weight-based rate

Config structure: `{"ranges": [{"min_g": 0, "max_g": 1000, "amount": 500}, {"min_g": 1001, "max_g": 5000, "amount": 1000}]}`

```
PROCEDURE calculateWeightRate(config, cart):
    totalWeight = 0
    FOR EACH line IN cart.lines:
        IF line.variant.requires_shipping = true:
            totalWeight += line.variant.weight_g * line.quantity

    FOR EACH range IN config.ranges:
        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:
            RETURN range.amount

    RETURN null  // Rate not available for this weight
```

#### Price-based rate

Config structure: `{"ranges": [{"min_amount": 0, "max_amount": 5000, "amount": 500}, {"min_amount": 5001, "amount": 0}]}`

```
PROCEDURE calculatePriceRate(config, cartSubtotal):
    FOR EACH range IN config.ranges:
        IF range.min_amount <= cartSubtotal:
            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:
                RETURN range.amount

    RETURN null  // Rate not available for this subtotal
```

A range without `max_amount` matches all subtotals above `min_amount` (useful for "free shipping over X" rules).

#### Carrier-calculated rate (stub)

Config structure: `{"carrier": "ups", "service": "ground"}`

- Placeholder for future carrier API integration.
- Returns a hardcoded rate or error until implemented.

### 9.3 Requires Shipping Check

- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.
- shipping_amount = 0 in the pricing result.
- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).

**Mixed carts** (some physical, some digital):
- Shipping is calculated only for physical items' weight.
- Digital items are excluded from weight calculations.

### 9.4 Free Shipping Discount Override

If a validated discount has `value_type = 'free_shipping'`:
- Override the calculated shipping amount to 0.
- The shipping method is still selected (for fulfillment routing), but the customer is not charged.

---

## 10. Payment Processing (Mock PSP)


sed -n '620,672p' specs/01-DATABASE-SCHEMA.md

| name | TEXT | No | - | - | Zone display name |
| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. ["US","CA"] |
| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. ["US-NY","US-CA"] |

**Indexes:**

- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store

**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.

---

### shipping_rates

Shipping rate options within a zone.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |
| name | TEXT | No | - | - | Rate display name |
| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |
| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |
| is_active | INTEGER | No | 1 | - | Boolean: active flag |

**Indexes:**

- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone
- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone

**Notes:** `config_json` structure depends on `type`:
- Flat: `{"amount": 799}`
- Weight: `{"ranges": [{"min_g": 0, "max_g": 500, "amount": 499}]}`
- Price: `{"ranges": [{"min_amount": 0, "max_amount": 5000, "amount": 799}]}`
- Carrier: `{"carrier": "ups", "service": "ground"}`

---

### tax_settings

Per-store tax configuration.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |
| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |
| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |
| config_json | TEXT | No | '{}' | - | Provider-specific config |

---

### discounts


Output:
Chunk ID: f8cfd5
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 531
Output:
| name | TEXT | No | - | - | Zone display name |
| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. ["US","CA"] |
| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. ["US-NY","US-CA"] |

**Indexes:**

- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store

**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.

---

### shipping_rates

Shipping rate options within a zone.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |
| name | TEXT | No | - | - | Rate display name |
| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |
| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |
| is_active | INTEGER | No | 1 | - | Boolean: active flag |

**Indexes:**

- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone
- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone

**Notes:** `config_json` structure depends on `type`:
- Flat: `{"amount": 799}`
- Weight: `{"ranges": [{"min_g": 0, "max_g": 500, "amount": 499}]}`
- Price: `{"ranges": [{"min_amount": 0, "max_amount": 5000, "amount": 799}]}`
- Carrier: `{"carrier": "ups", "service": "ground"}`

---

### tax_settings

Per-store tax configuration.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |
| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |
| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |
| config_json | TEXT | No | '{}' | - | Provider-specific config |

---

### discounts


Output:
Chunk ID: 77802f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 587
Output:
### 9.2 Rate Types

For the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:

#### Flat rate

Config structure: `{"amount": 500}`

- Cost = fixed amount regardless of cart contents.

#### Weight-based rate

Config structure: `{"ranges": [{"min_g": 0, "max_g": 1000, "amount": 500}, {"min_g": 1001, "max_g": 5000, "amount": 1000}]}`

```
PROCEDURE calculateWeightRate(config, cart):
    totalWeight = 0
    FOR EACH line IN cart.lines:
        IF line.variant.requires_shipping = true:
            totalWeight += line.variant.weight_g * line.quantity

    FOR EACH range IN config.ranges:
        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:
            RETURN range.amount

    RETURN null  // Rate not available for this weight
```

#### Price-based rate

Config structure: `{"ranges": [{"min_amount": 0, "max_amount": 5000, "amount": 500}, {"min_amount": 5001, "amount": 0}]}`

```
PROCEDURE calculatePriceRate(config, cartSubtotal):
    FOR EACH range IN config.ranges:
        IF range.min_amount <= cartSubtotal:
            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:
                RETURN range.amount

    RETURN null  // Rate not available for this subtotal
```

A range without `max_amount` matches all subtotals above `min_amount` (useful for "free shipping over X" rules).

#### Carrier-calculated rate (stub)

Config structure: `{"carrier": "ups", "service": "ground"}`

- Placeholder for future carrier API integration.
- Returns a hardcoded rate or error until implemented.

### 9.3 Requires Shipping Check

- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.
- shipping_amount = 0 in the pricing result.
- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).

**Mixed carts** (some physical, some digital):
- Shipping is calculated only for physical items' weight.
- Digital items are excluded from weight calculations.

### 9.4 Free Shipping Discount Override

If a validated discount has `value_type = 'free_shipping'`:
- Override the calculated shipping amount to 0.
- The shipping method is still selected (for fulfillment routing), but the customer is not charged.

---

## 10. Payment Processing (Mock PSP)