text:
Script completed
Wall time 0.1 seconds
Output:

text:
Warning: truncated output (original token count: 13207)
Total output lines: 260

<?php

namespace App\Enums;

enum DiscountType: string
{
    case Code = 'code';
    case Automatic = 'automatic';
}
<?php

namespace App\Enums;

enum DiscountValueType: string
{
    case Percent = 'percent';
    case Fixed = 'fixed';
    case FreeShipping = 'free_shipping';
}
zsh:1: no matches found: database/migrations/*create_discounts_table.php
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:98:| Manage shipping/tax | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md:105:- Register gate definitions in a service provider.
specs/05-BUSINESS-LOGIC.md:241:| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
specs/05-BUSINESS-LOGIC.md:369:    + shipping if taxable (Section 8)"]
specs/05-BUSINESS-LOGIC.md:371:    discounted_subtotal + shipping + tax_total"]
specs/05-BUSINESS-LOGIC.md:382:| `shipping` | integer (cents) | Shipping cost |
specs/05-BUSINESS-LOGIC.md:385:| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |
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:415:**Enum:** `App\Enums\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`
specs/05-BUSINESS-LOGIC.md:421:    addressed --> shipping_selected : Shipping method chosen
specs/05-BUSINESS-LOGIC.md:422:    shipping_selected --> payment_selected : Payment method selected
specs/05-BUSINESS-LOGIC.md:428:    shipping_selected --> expired : Timeout (24h)
specs/05-BUSINESS-LOGIC.md:431:    note right of shipping_selected : Triggers pricing recalculation
specs/05-BUSINESS-LOGIC.md:447:- `shipping_address` with required fields:
specs/05-BUSINESS-LOGIC.md:465:2. Save email and shipping_address_json on the checkout.
specs/05-BUSINESS-LOGIC.md:466:3. Copy shipping address to billing_address_json by default (can be overridden later).
specs/05-BUSINESS-LOGIC.md:467:4. Recalculate pricing (shipping zones and tax regions may change based on address).
specs/05-BUSINESS-LOGIC.md:471:#### Transition: addressed -> shipping_selected
specs/05-BUSINESS-LOGIC.md:473:**Method:** `setShippingMethod(checkout, shippingRateId)`
specs/05-BUSINESS-LOGIC.md:476:- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.
specs/05-BUSINESS-LOGIC.md:477:- **Skip condition:** If no cart lines have `requires_shipping = true` on their variant, skip this step. Set status to `shipping_selected` with shipping_method_id = null and shipping_amount = 0.
specs/05-BUSINESS-LOGIC.md:480:1. Resolve available shipping zones for the address (see Section 9).
specs/05-BUSINESS-LOGIC.md:482:3. Save shipping_method_id on the checkout.
specs/05-BUSINESS-LOGIC.md:483:4. Recalculate pricing with the selected shipping rate.
specs/05-BUSINESS-LOGIC.md:484:5. Transition status to `shipping_selected`.
specs/05-BUSINESS-LOGIC.md:487:#### Transition: shipping_selected -> payment_selected
specs/05-BUSINESS-LOGIC.md:492:- All previous data is valid (address, shipping method).
specs/05-BUSINESS-LOGIC.md:494:- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.
specs/05-BUSINESS-LOGIC.md:499:3. Set `expires_at` on the checkout to current time + 24 hours.
specs/05-BUSINESS-LOGIC.md:532:       ELSE IF checkout.payment_method = 'bank_transfer':
specs/05-BUSINESS-LOGIC.md:553:       - Link to order, provider = 'mock', method = checkout.payment_method
specs/05-BUSINESS-LOGIC.md:554:       - provider_payment_id = mock reference ID
specs/05-BUSINESS-LOGIC.md:560:       // Note: for bank_transfer, inventory stays reserved until admin confirms payment
specs/05-BUSINESS-LOGIC.md:632:**Enum:** `App\Enums\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`
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:737:- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.
specs/05-BUSINESS-LOGIC.md:740:**Service:** `App\Services\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.
specs/05-BUSINESS-LOGIC.md:749:1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).
specs/05-BUSINESS-LOGIC.md:800:- Sends line items and shipping address to Stripe Tax API.
specs/05-BUSINESS-LOGIC.md:801:- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.
specs/05-BUSINESS-LOGIC.md:833:    zones = load all shipping zones for store
specs/05-BUSINESS-LOGIC.md:862:For the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:
specs/05-BUSINESS-LOGIC.md:878:        IF line.variant.requires_shipping = true:
specs/05-BUSINESS-LOGIC.md:902:A range without `max_amount` matches all subtotals above `min_amount` (useful for "free shipping over X" rules).
specs/05-BUSINESS-LOGIC.md:913:- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.
specs/05-BUSINESS-LOGIC.md:914:- shipping_amount = 0 in the pricing result.
specs/05-BUSINESS-LOGIC.md:915:- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).
specs/05-BUSINESS-LOGIC.md:923:If a validated discount has `value_type = 'free_shipping'`:
specs/05-BUSINESS-LOGIC.md:924:- Override the calculated shipping amount to 0.
specs/05-BUSINESS-LOGIC.md:925:- The shipping method is still selected (for fulfillment routing), but the customer is not charged.
specs/05-BUSINESS-LOGIC.md:947:**Enum:** `App\Enums\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)
specs/05-BUSINESS-LOGIC.md:953:| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |
specs/05-BUSINESS-LOGIC.md:957:When payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:
specs/05-BUSINESS-LOGIC.md:966:The mock provider does **not** validate card expiry, CVC, or cardholder name. These fields exist in the UI for realism but are ignored by the backend.
specs/05-BUSINESS-LOGIC.md:1016:When `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:
specs/05-BUSINESS-LOGIC.md:1030:Admin users can confirm that a bank transfer payment has been received via a "Confirm Payment" button on the order detail page (visible only when `payment_method = bank_transfer` AND `financial_status = pending`).
specs/05-BUSINESS-LOGIC.md:1038:6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).
specs/05-BUSINESS-LOGIC.md:1046:- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.
specs/05-BUSINESS-LOGIC.md:1047:- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.
specs/05-BUSINESS-LOGIC.md:1077:| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |
specs/05-BUSINESS-LOGIC.md:1150:    5. On provider confirmation:
specs/05-BUSINESS-LOGIC.md:1173:**CRITICAL:** A fulfillment cannot be created unless the order's `financial_status` is `paid` or `partially_refunded`. This prevents shipping goods before payment is confirmed.
specs/05-BUSINESS-LOGIC.md:1260:| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |
specs/05-BUSINESS-LOGIC.md:1266:When a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):
specs/05-BUSINESS-LOGIC.md:1268:1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).
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:1313:- Configure a dedicated `customers` provider using the Customer model.
specs/05-BUSINESS-LOGIC.md:1606:| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |
specs/05-BUSINESS-LOGIC.md:1645:| TaxCalculator | `App\Services` | Tax calculation orchestration (delegates to providers) |
specs/05-BUSINESS-LOGIC.md:1679:| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |
specs/05-BUSINESS-LOGIC.md:1681:| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentSelected (`payment_selected`), Completed (`completed`), Expired (`expired`) |
specs/05-BUSINESS-LOGIC.md:1682:| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |
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:1702:| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |
specs/05-BUSINESS-LOGIC.md:1703:| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |
specs/05-BUSINESS-LOGIC.md:1707:| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |
specs/05-BUSINESS-LOGIC.md:1709:| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |
app/ValueObjects/TaxCalculationRequest.php:5:use App\Models\TaxSettings;
app/ValueObjects/TaxCalculationRequest.php:14:        public int $shippingAmount,
app/ValueObjects/TaxCalculationRequest.php:16:        public TaxSettings $settings,
app/ValueObjects/PricingResult.php:8:    public function __construct(public int $subtotal, public int $discount, public int $shipping, public array $taxLines, public int $taxTotal, public int $total, public string $currency) {}
app/ValueObjects/PricingResult.php:12:        return ['subtotal' => $this->subtotal, 'discount' => $this->discount, 'shipping' => $this->shipping, 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), 'tax' => $this->taxTotal, 'total' => $this->total, 'currency' => $this->currency];
app/Services/PricingEngine.php:7:use App\Models\TaxSettings;
app/Services/PricingEngine.php:12:    public function __construct(private readonly DiscountService $discounts, private readonly ShippingCalculator $shipping, private readonly TaxCalculator $taxes) {}
app/Services/PricingEngine.php:16:        $checkout->load(['cart.lines.variant.product.collections', 'shippingRate']);
app/Services/PricingEngine.php:47:        $shippingAmount = $checkout->shippingRate !== null && ! $freeShipping ? $this->shipping->calculate($checkout->shippingRate, $cart) : 0;
app/Services/PricingEngine.php:48:        $taxSettings = TaxSettings::withoutGlobalScopes()->firstOrCreate(['store_id' => $checkout->store_id], ['default_rate_basis_points' => 0]);
app/Services/PricingEngine.php:53:            $taxLines = [...$taxLines, ...$this->taxes->calculate($lineAmount, $taxSettings, $checkout->shipping_address_json ?? [])->lines];
app/Services/PricingEngine.php:56:        if ($shippingAmount > 0) {
app/Services/PricingEngine.php:57:            $taxLines = [...$taxLines, ...$this->taxes->calculate($shippingAmount, $taxSettings, $checkout->shipping_address_json ?? [])->lines];
app/Services/PricingEngine.php:63:        $total = max(0, $discountedSubtotal + $shippingAmount + ($pricesIncludeTax ? 0 : $taxTotal));
app/Services/PricingEngine.php:64:        $result = new PricingResult($subtotal, $discountAmount, $shippingAmount, $taxLines, $taxTotal, $total, $cart->currency);
app/Services/ShippingCalculator.php:41:        $lines = $lines->filter(fn ($line): bool => (bool) $line->variant->requires_shipping);
app/Services/RefundService.php:16:    public function __construct(private readonly PaymentProvider $provider, private readonly InventoryService $inventory, private readonly AuditLogger $audit) {}
app/Services/RefundService.php:73:            $result = $this->provider->refund($payment, $amount);
app/Services/RefundService.php:74:            $refund = $order->refunds()->create(['payment_id' => $payment->getKey(), 'amount' => $amount, 'status' => $result->successful ? 'processed' : 'failed', 'provider_refund_id' => $result->reference, 'reason' => $reason, 'restock' => $restock, 'lines_json' => $lines !== [] ? $lines : null]);
app/Services/Tax/StripeTaxProvider.php:15:        if (($request->settings->provider_config_json['fallback'] ?? 'allow') === 'block') {
app/Services/Tax/ManualTaxProvider.php:29:        if ($request->shippingAmount > 0) {
app/Services/Tax/ManualTaxProvider.php:31:                ? $this->extractInclusive($request->shippingAmount, $rate)
app/Services/Tax/ManualTaxProvider.php:32:                : $this->roundTax($request->shippingAmount, $rate);
app/Services/TaxCalculator.php:6:use App\Models\TaxSettings;
app/Services/TaxCalculator.php:12:    public function __construct(private readonly ?TaxProvider $provider = null) {}
app/Services/TaxCalculator.php:14:    public function calculate(int $amount, TaxSettings $settings, array $address): TaxResult
app/Services/TaxCalculator.php:16:        return ($this->provider ?? new \App\Services\Tax\ManualTaxProvider)->calculate(new TaxCalculationRequest([['amount' => $amount]], 0, $address, $settings));
app/Services/CheckoutService.php:17:    public function __construct(private readonly PricingEngine $pricing, private readonly ShippingCalculator $shipping, private readonly InventoryService $inventory) {}
app/Services/CheckoutService.php:29:        return Checkout::create(['store_id' => $cart->store_id, 'cart_id' => $cart->getKey(), 'customer_id' => $customer?->getKey(), 'status' => CheckoutStatus::Started, 'email' => $email, 'discount_code' => $cart->discount_code, 'expires_at' => now()->addHours(24)]);
app/Services/CheckoutService.php:39:        $checkout->update(['shipping_address_json' => $address, 'billing_address_json' => $useShippingAsBilling ? $address : $billing, 'status' => CheckoutStatus::Addressed]);
app/Services/CheckoutService.php:51:            throw new \LogicException('Checkout must have an address before selecting shipping.');
app/Services/CheckoutService.php:55:            $checkout->update(['shipping_rate_id' => null, 'shipping_method_id' => null, 'status' => CheckoutStatus::ShippingSelected]);
app/Services/CheckoutService.php:62:        if ($checkout->shipping_address_json === null) {
app/Services/CheckoutService.php:63:            throw new \LogicException('An address is required before selecting shipping.');
app/Services/CheckoutService.php:66:        $rate = $checkout->shipping_address_json === null ? null : $this->shipping->getAvailableRates($checkout->store, $checkout->shipping_address_json)->firstWhere('id', $rateId);
app/Services/CheckoutService.php:69:            throw new \InvalidArgumentException('The selected shipping method is unavailable.');
app/Services/CheckoutService.php:72:        $checkout->update(['shipping_rate_id' => $rate->getKey(), 'shipping_method_id' => $rate->getKey(), 'status' => CheckoutStatus::ShippingSelected]);
app/Services/CheckoutService.php:81:        Validator::make(['method' => $method], ['method' => ['required', 'in:credit_card,paypal,bank_transfer']])->validate();
app/Services/CheckoutService.php:85:        if ($checkout->shipping_address_json === null) {
app/Services/CheckoutService.php:89:        if ($this->requiresShipping($checkout) && $checkout->shipping_rate_id === null) {
app/Services/CheckoutService.php:90:            throw new \LogicException('A shipping method is required before selecting payment.');
app/Services/CheckoutService.php:94:            throw new \LogicException('Checkout must have a selected shipping method before selecting payment.');
app/Services/CheckoutService.php:106:            $checkout->update(['payment_method' => $method, 'status' => CheckoutStatus::PaymentSelected, 'expires_at' => now()->addHours(24)]);
app/Services/CheckoutService.php:136:        return $checkout->cart->lines->contains(fn ($line): bool => (bool) $line->variant->requires_shipping);
app/Services/PaymentService.php:18:    public function __construct(private readonly PaymentProvider $provider, private readonly InventoryService $inventory, private readonly OrderService $orders, private readonly PricingEngine $pricing) {}
app/Services/PaymentService.php:38:            $result = $this->provider->charge($checkout, $method, $details);
app/Services/PaymentService.php:54:            Payment::create(['order_id' => $order->getKey(), 'provider' => 'mock', 'provider_payment_id' => $result->reference, 'method' => $method, 'status' => $result->status, 'amount' => $order->total_amount, 'currency' => $order->currency, 'raw_json' => ['reference' => $result->reference, 'message' => $result->message]]);
app/Services/OrderService.php:29:            $checkout->load(['cart.lines.variant.product', 'cart.lines.variant.inventory', 'shippingRate']);
app/Services/OrderService.php:36:            $totals = $checkout->totals_json ?? ['subtotal' => 0, 'discount' => 0, 'shipping' => 0, 'tax' => 0, 'total' => 0, 'currency' => $checkout->cart->currency];
app/Services/OrderService.php:59:                'shipping_address_json' => $checkout->shipping_address_json,
app/Services/OrderService.php:63:                'shipping_amount' => $totals['shipping'] ?? 0,
app/Services/OrderService.php:103:                if ($checkout->cart->lines->every(fn ($line): bool => ! $line->variant->requires_shipping)) {
app/Services/OrderService.php:150:        if ($order->payment_method !== 'bank_transfer' || $order->financial_status !== FinancialStatus::Pending) {
app/Services/OrderService.php:167:            if ($order->lines->every(fn ($line): bool => ! $line->variant?->requires_shipping)) {
database/factories/StoreInvitationFactory.php:25:            'expires_at' => now()->addDays(7),
app/Jobs/CancelUnpaidBankTransferOrders.php:19:        $days = (int) config('shop.bank_transfer_expiry_days', 7);
app/Jobs/CancelUnpaidBankTransferOrders.php:20:        Order::withoutGlobalScopes()->where('payment_method', 'bank_transfer')->where('financial_status', FinancialStatus::Pending)->where('placed_at', '<', now()->subDays($days))->with('lines.variant.inventory')->each(fn (Order $order): mixed => $orders->cancel($order, 'Bank transfer payment expired.'));
app/Jobs/GenerateOrderExport.php:29:                ->with(['customer', 'checkout.shippingRate'])
app/Jobs/GenerateOrderExport.php:38:            fputcsv($handle, ['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']);
app/Jobs/GenerateOrderExport.php:51:                    $order->shipping_amount,
app/Jobs/GenerateOrderExport.php:55:                    $order->checkou…3207 tokens truncated…tus::Cancelled, 'financial_status' => FinancialStatus::Refunded, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDays(15), 'lines' => [['handle' => 'classic-cotton-t-shirt', 'variant' => 'M / Navy', 'quantity' => 1]], 'subtotal_amount' => 2499, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 399, 'total_amount' => 2998, 'provider_payment_id' => 'mock_test_order1004', 'payment_status' => PaymentStatus::Refunded, 'refund' => ['amount' => 2998, 'reason' => 'Customer requested cancellation', 'provider_refund_id' => 'mock_re_test_order1004', 'line_indexes' => [0]]]),
database/seeders/OrderSeeder.php:113:            array_merge($base, ['order_number' => '#1005', 'customer' => 'jane@example.com', 'payment_method' => PaymentMethod::BankTransfer, 'status' => OrderStatus::Pending, 'financial_status' => FinancialStatus::Pending, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subHours(2), 'lines' => [['handle' => 'leather-belt', 'variant' => 'S/M / Black', 'quantity' => 1]], 'subtotal_amount' => 3499, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 559, 'total_amount' => 3998, 'provider_payment_id' => 'mock_test_order1005', 'payment_status' => PaymentStatus::Pending]),
database/seeders/OrderSeeder.php:114:            array_merge($base, ['order_number' => '#1006', 'customer' => 'michael@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDay(), 'lines' => [['handle' => 'running-sneakers', 'variant' => 'EU 42 / Black', 'quantity' => 1]], 'subtotal_amount' => 11999, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 1916, 'total_amount' => 12498, 'provider_payment_id' => 'mock_test_order1006', 'payment_status' => PaymentStatus::Captured]),
database/seeders/OrderSeeder.php:115:            array_merge($base, ['order_number' => '#1007', 'customer' => 'sarah@example.com', 'payment_method' => PaymentMethod::Paypal, 'status' => OrderStatus::Fulfilled, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Fulfilled, 'placed_at' => now()->subDays(20), 'lines' => [['handle' => 'v-neck-linen-tee', 'variant' => 'M / Beige', 'quantity' => 2], ['handle' => 'wool-scarf', 'variant' => 'Grey', 'quantity' => 1]], 'subtotal_amount' => 9997, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 1596, 'total_amount' => 10496, 'provider_payment_id' => 'mock_test_order1007', 'payment_status' => PaymentStatus::Captured, 'fulfillment' => ['status' => FulfillmentShipmentStatus::Delivered, 'tracking_company' => 'DHL', 'tracking_number' => 'DHL1112223334', 'shipped_at' => now()->subDays(18), 'delivered_at' => now()->subDays(12), 'line_indexes' => [0, 1]]]),
database/seeders/OrderSeeder.php:116:            array_merge($base, ['order_number' => '#1008', 'customer' => 'david@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::PartiallyRefunded, 'fulfillment_status' => FulfillmentStatus::Fulfilled, 'placed_at' => now()->subDays(12), 'lines' => [['handle' => 'cargo-pants', 'variant' => '32 / Khaki', 'quantity' => 1], ['handle' => 'graphic-print-tee', 'variant' => 'L', 'quantity' => 1]], 'subtotal_amount' => 8498, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 1357, 'total_amount' => 8997, 'provider_payment_id' => 'mock_test_order1008', 'payment_status' => PaymentStatus::Captured, 'refund' => ['amount' => 2999, 'reason' => 'Item returned', 'provider_refund_id' => 'mock_re_test_order1008', 'line_indexes' => [1]], 'fulfillment' => ['status' => FulfillmentShipmentStatus::Delivered, 'tracking_company' => 'UPS', 'tracking_number' => 'UPS5556667778', 'shipped_at' => now()->subDays(10), 'delivered_at' => now()->subDays(4), 'line_indexes' => [0, 1]]]),
database/seeders/OrderSeeder.php:117:            array_merge($base, ['order_number' => '#1009', 'customer' => 'emma@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDays(3), 'lines' => [['handle' => 'canvas-tote-bag', 'variant' => 'Natural', 'quantity' => 1], ['handle' => 'bucket-hat', 'variant' => 'S/M / Black', 'quantity' => 1]], 'subtotal_amount' => 4498, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 718, 'total_amount' => 4997, 'provider_payment_id' => 'mock_test_order1009', 'payment_status' => PaymentStatus::Captured]),
database/seeders/OrderSeeder.php:118:            array_merge($base, ['order_number' => '#1010', 'customer' => 'customer@acme.test', 'payment_method' => PaymentMethod::Paypal, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDay(), 'lines' => [['handle' => 'cashmere-overcoat', 'variant' => 'M / Camel', 'quantity' => 1]], 'subtotal_amount' => 49999, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 7983, 'total_amount' => 50498, 'provider_payment_id' => 'mock_test_order1010', 'payment_status' => PaymentStatus::Captured]),
database/seeders/OrderSeeder.php:119:            array_merge($base, ['order_number' => '#1011', 'customer' => 'james@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Fulfilled, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Fulfilled, 'placed_at' => now()->subDays(25), 'lines' => [['handle' => 'striped-polo-shirt', 'variant' => 'XL', 'quantity' => 1]], 'subtotal_amount' => 2799, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 447, 'total_amount' => 3298, 'provider_payment_id' => 'mock_test_order1011', 'payment_status' => PaymentStatus::Captured, 'fulfillment' => ['status' => FulfillmentShipmentStatus::Delivered, 'tracking_company' => 'FedEx', 'tracking_number' => 'FX9998887776', 'shipped_at' => now()->subDays(23), 'delivered_at' => now()->subDays(20), 'line_indexes' => [0]]]),
database/seeders/OrderSeeder.php:120:            array_merge($base, ['order_number' => '#1012', 'customer' => 'lisa@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDays(4), 'lines' => [['handle' => 'chino-shorts', 'variant' => '34 / Navy', 'quantity' => 2]], 'subtotal_amount' => 7998, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 1277, 'total_amount' => 8497, 'provider_payment_id' => 'mock_test_order1012', 'payment_status' => PaymentStatus::Captured]),
database/seeders/OrderSeeder.php:121:            array_merge($base, ['order_number' => '#1013', 'customer' => 'robert@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDay(), 'lines' => [['handle' => 'wide-leg-trousers', 'variant' => 'M', 'quantity' => 1], ['handle' => 'wool-scarf', 'variant' => 'Burgundy', 'quantity' => 1]], 'subtotal_amount' => 7998, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 1277, 'total_amount' => 8497, 'provider_payment_id' => 'mock_test_order1013', 'payment_status' => PaymentStatus::Captured]),
database/seeders/OrderSeeder.php:122:            array_merge($base, ['order_number' => '#1014', 'customer' => 'anna@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Fulfilled, 'placed_at' => now()->subDays(14), 'lines' => [['handle' => 'gift-card', 'variant' => '50 EUR', 'quantity' => 1]], 'subtotal_amount' => 5000, 'discount_amount' => 0, 'shipping_amount' => 0, 'tax_amount' => 798, 'total_amount' => 5000, 'provider_payment_id' => 'mock_test_order1014', 'payment_status' => PaymentStatus::Captured, 'fulfillment' => ['status' => FulfillmentShipmentStatus::Delivered, 'tracking_company' => null, 'tracking_number' => null, 'shipped_at' => now()->subDays(14), 'delivered_at' => now()->subDays(14), 'line_indexes' => [0]]]),
database/seeders/OrderSeeder.php:123:            array_merge($base, ['order_number' => '#1015', 'customer' => 'customer@acme.test', 'payment_method' => PaymentMethod::BankTransfer, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now(), 'lines' => [['handle' => 'classic-cotton-t-shirt', 'variant' => 'M / White', 'quantity' => 1, 'discount_amount' => 250, 'discount_id' => $discountId], ['handle' => 'graphic-print-tee', 'variant' => 'M', 'quantity' => 1, 'discount_amount' => 300, 'discount_id' => $discountId]], 'subtotal_amount' => 5498, 'discount_amount' => 550, 'shipping_amount' => 499, 'tax_amount' => 790, 'total_amount' => 5447, 'provider_payment_id' => 'mock_test_order1015', 'payment_status' => PaymentStatus::Captured]),
database/seeders/OrderSeeder.php:132:            ['store_id' => $storeId, 'order_number' => '#5001', 'customer' => 'techfan@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Fulfilled, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Fulfilled, 'placed_at' => now()->subDays(6), 'lines' => [['handle' => 'pro-laptop-15', 'variant' => '512GB', 'quantity' => 1], ['handle' => 'usb-c-cable-2m', 'variant' => 'Default', 'quantity' => 1]], 'subtotal_amount' => 121298, 'discount_amount' => 0, 'shipping_amount' => 0, 'tax_amount' => 0, 'total_amount' => 121298, 'provider_payment_id' => 'mock_test_order5001', 'payment_status' => PaymentStatus::Captured],
database/seeders/OrderSeeder.php:133:            ['store_id' => $storeId, 'order_number' => '#5002', 'customer' => 'gadgetlover@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDays(2), 'lines' => [['handle' => 'wireless-headphones', 'variant' => 'Black', 'quantity' => 1]], 'subtotal_amount' => 14999, 'discount_amount' => 0, 'shipping_amount' => 0, 'tax_amount' => 0, 'total_amount' => 14999, 'provider_payment_id' => 'mock_test_order5002', 'payment_status' => PaymentStatus::Captured],
database/seeders/OrderSeeder.php:134:            ['store_id' => $storeId, 'order_number' => '#5003', 'customer' => 'techfan@example.com', 'payment_method' => PaymentMethod::BankTransfer, 'status' => OrderStatus::Pending, 'financial_status' => FinancialStatus::Pending, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subHours(4), 'lines' => [['handle' => 'monitor-stand', 'variant' => 'Default', 'quantity' => 1]], 'subtotal_amount' => 4999, 'discount_amount' => 0, 'shipping_amount' => 0, 'tax_amount' => 0, 'total_amount' => 4999, 'provider_payment_id' => 'mock_test_order5003', 'payment_status' => PaymentStatus::Pending],
database/factories/CheckoutFactory.php:30:            'shipping_address_json' => ['first_name' => fake()->firstName(), 'last_name' => fake()->lastName(), 'address1' => fake()->streetAddress(), 'city' => fake()->city(), 'country_code' => 'DE', 'zip' => fake()->postcode()],
database/factories/CheckoutFactory.php:32:            'shipping_rate_id' => null,
database/factories/CheckoutFactory.php:33:            'shipping_method_id' => null,
database/factories/CheckoutFactory.php:36:            'tax_provider_snapshot_json' => null,
database/factories/CheckoutFactory.php:38:            'expires_at' => now()->addDay(),
database/factories/CheckoutFactory.php:49:        return $this->state(['status' => CheckoutStatus::Expired, 'expires_at' => now()->subHour()]);
database/factories/CheckoutFactory.php:64:        return $this->state(['payment_method' => 'bank_transfer']);
app/Livewire/Admin/Developers/Index.php:55:        $expiresAt = empty($data['tokenExpiresAt']) ? null : CarbonImmutable::parse($data['tokenExpiresAt']);
app/Livewire/Admin/Developers/Index.php:56:        $this->plainTextToken = auth()->user()->createToken($data['tokenName'], $abilities, $expiresAt)->plainTextToken;
app/Http/Requests/StoreProductRequest.php:51:            'variants.*.requires_shipping' => ['nullable', 'boolean'],
app/Http/Requests/UpdateDiscountRequest.php:30:            'value_type' => ['sometimes', 'in:fixed,percent,free_shipping'],
app/Http/Requests/StoreDiscountRequest.php:30:            'value_type' => ['required', 'in:fixed,percent,free_shipping'],
app/Http/Requests/StoreDiscountRequest.php:31:            'value_amount' => ['required_unless:value_type,free_shipping', 'integer', 'min:0'],
database/seeders/ProductSeeder.php:60:                ['title' => $variantTitle, 'sku' => $sku, 'barcode' => null, 'price_amount' => $price, 'compare_at_amount' => $productData['compare_at'], 'currency' => 'EUR', 'cost_amount' => null, 'weight_grams' => $productData['weight_g'], 'weight_g' => $productData['weight_g'], 'requires_shipping' => $productData['requires_shipping'], 'is_default' => $position === 0, 'status' => $productData['variant_status'], 'metadata' => []],
database/seeders/ProductSeeder.php:157:        return ['title' => $title, 'handle' => $handle, 'sku_prefix' => $skuPrefix, 'vendor' => $vendor, 'product_type' => $productType, 'tags' => $tags, 'description_html' => '<p>'.str_replace('.', '. ', $title).' is made for comfortable everyday use with thoughtful details.</p>', 'status' => $status, 'published_at' => $publishedAt ?? now(), 'price' => $price, 'compare_at' => $compareAt, 'weight_g' => $weight, 'inventory' => $inventory, 'policy' => $policy, 'requires_shipping' => $requiresShipping, 'variant_status' => $status === ProductStatus::Archived ? VariantStatus::Archived : VariantStatus::Active, 'options' => collect($options)->map(fn (array $values, string $name): array => ['name' => $name, 'values' => $values])->values()->all(), 'collections' => $collections];
database/seeders/ShippingSeeder.php:46:                ['shipping_zone_id' => $zone->getKey(), 'name' => $rate['name']],
database/seeders/ShopSeeder.php:25:            TaxSettingsSeeder::class,
database/seeders/ShopSeeder.php:62:            ['title' => 'Default', 'sku' => 'ACME-LEGACY-SOLD-OUT', 'price_amount' => 3999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_grams' => 250, 'weight_g' => 250, 'requires_shipping' => true, 'is_default' => true, 'status' => VariantStatus::Active, 'metadata' => []],
database/seeders/TaxSettingsSeeder.php:6:use App\Models\TaxSettings;
database/seeders/TaxSettingsSeeder.php:9:class TaxSettingsSeeder extends Seeder
database/seeders/TaxSettingsSeeder.php:17:            TaxSettings::withoutGlobalScopes()->updateOrCreate(
database/seeders/TaxSettingsSeeder.php:19:                ['mode' => 'manual', 'provider' => 'none', 'prices_include_tax' => true, 'config_json' => ['default_rate_bps' => 1900], 'default_rate_basis_points' => 1900, 'rates_json' => ['DE' => 1900], 'provider_config_json' => []],
app/Http/Requests/UpdateProductRequest.php:52:            'variants.*.requires_shipping' => ['sometimes', 'boolean'],
app/Http/Requests/UpdateTaxSettingsRequest.php:8:class UpdateTaxSettingsRequest extends FormRequest
app/Http/Requests/UpdateTaxSettingsRequest.php:25:        return ['mode' => ['required', 'in:manual,provider'], 'provider' => ['required_if:mode,provider', 'nullable', 'in:none,stripe_tax'], 'prices_include_tax' => ['required', 'boolean'], 'config_json' => ['required', 'array'], 'default_rate_basis_points' => ['sometimes', 'integer', 'min:0', 'max:10000'], 'rates_json' => ['sometimes', 'array'], 'provider_config_json' => ['sometimes', 'array']];
database/migrations/2026_08_20_000000_create_shop_schema.php:116:            $table->boolean('requires_shipping')->default(true);
database/migrations/2026_08_20_000000_create_shop_schema.php:261:        Schema::create('shipping_zones', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:271:        Schema::create('shipping_rates', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:273:            $table->foreignId('shipping_zone_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php:283:            $table->index(['shipping_zone_id', 'is_active']);
database/migrations/2026_08_20_000000_create_shop_schema.php:291:            $table->json('provider_config_json')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php:374:            $table->json('shipping_address_json')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php:376:            $table->foreignId('shipping_rate_id')->nullable()->constrained('shipping_rates')->nullOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php:379:            $table->json('tax_provider_snapshot_json')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php:380:            $table->timestamp('expires_at')->nullable()->index();
database/migrations/2026_08_20_000000_create_shop_schema.php:398:            $table->json('shipping_address_json')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php:402:            $table->unsignedInteger('shipping_amount')->default(0);
database/migrations/2026_08_20_000000_create_shop_schema.php:436:            $table->string('provider')->default('mock');
database/migrations/2026_08_20_000000_create_shop_schema.php:437:            $table->string('provider_payment_id')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php:443:            $table->index(['provider', 'provider_payment_id']);
database/migrations/2026_08_20_000000_create_shop_schema.php:544:            $table->timestamp('expires_at')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php:581:            'checkouts', 'cart_lines', 'carts', 'discounts', 'tax_settings', 'shipping_rates',
database/migrations/2026_08_20_000000_create_shop_schema.php:582:            'shipping_zones', 'search_queries', 'search_settings', 'navigation_items',
app/Livewire/Admin/Discounts/Form.php:118:            'valueType' => ['required', 'in:percent,fixed,free_shipping'],
app/Livewire/Admin/Discounts/Form.php:119:            'valueAmount' => ['nullable', 'integer', 'min:0', 'required_unless:valueType,free_shipping'],
database/factories/ProductVariantFactory.php:27:            'requires_shipping' => true,
database/factories/ProductVariantFactory.php:45:        return $this->state(['requires_shipping' => false, 'weight_grams' => 0, 'weight_g' => 0]);
database/factories/ShippingRateFactory.php:23:            'shipping_zone_id' => ShippingZoneFactory::new(),
database/factories/OrderFactory.php:21:        return ['store_id' => Store::factory(), 'customer_id' => Customer::factory(), 'order_number' => '#'.fake()->unique()->numberBetween(1001, 9999), 'currency' => 'EUR', 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'payment_method' => 'credit_card', 'email' => fake()->safeEmail(), 'shipping_address_json' => $address, 'billing_address_json' => $address, 'subtotal_amount' => 4998, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 798, 'total_amount' => 5497, 'placed_at' => now(), 'metadata' => []];
database/factories/OrderFactory.php:66:        return $this->state(['payment_method' => 'bank_transfer']);
database/factories/RefundFactory.php:30:            'provider_refund_id' => 'mock_re_'.fake()->unique()->bothify('????????????????????'),


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "sed -n '600,820p' specs/05-BUSINESS-LOGIC.md; sed -n '825,930p' specs/05-BUSINESS-LOGIC.md; sed -n '1025,1065p' specs/05-BUSINESS-LOGIC.md; sed -n '1180,1305p' specs/05-BUSINESS-LOGIC.md",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 1000,
  max_output_tokens: 36000
});
text(r.output);


type: CommandExecution

command: sed -n '600,820p' specs/05-BUSINESS-LOGIC.md; sed -n '825,930p' specs/05-BUSINESS-LOGIC.md; sed -n '1025,1065p' specs/05-BUSINESS-LOGIC.md; sed -n '1180,1305p' specs/05-BUSINESS-LOGIC.md

type: read

status: completed

stdout:
## 7. Discount Engine

### 7.1 Discount Types

**Enum:** `App\Enums\DiscountType` - values: `code`, `automatic`

| Type | Behavior |
|---|---|
| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |
| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |

**Stacking rules:**
- Only one code discount per checkout.
- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).

### Discount Status Lifecycle

| Status | Description | Transition |
|--------|-------------|------------|
| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |
| `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. |
| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |
| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |

**Allowed transitions:**
- `draft` -> `active`
- `active` -> `disabled`
- `active` -> `expired` (automatic)
- `disabled` -> `active`

### 7.2 Value Types

**Enum:** `App\Enums\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`

| Value Type | Calculation | Notes |
|---|---|---|
| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |
| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |
| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |

### 7.3 Rules

Rules are stored in `discounts.rules_json` with the following structure:

| Rule Key | Type | Description |
|---|---|---|
| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |
| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |
| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |
| `customer_eligibility` | string | `"all"` for all customers. Future expansion: `"returning"`, `"specific"`. |

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

### 7.4 Validation Flow

**Service:** `App\Services\DiscountService`

**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object

```mermaid
flowchart TD
    A[Input: discount code + cart] --> B{Code exists in store?
    Case-insensitive lookup}
    B -->|No| C["Error: discount_not_found"]
    B -->|Yes| D{Status = active?}
    D -->|No| E["Error: discount_expired"]
    D -->|Yes| F{starts_at <= now?}
    F -->|No| G["Error: discount_not_yet_active"]
    F -->|Yes| H{ends_at is null OR ends_at >= now?}
    H -->|No| I["Error: discount_expired"]
    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}
    J -->|Yes| K["Error: discount_usage_limit_reached"]
    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}
    L -->|Yes| M["Error: discount_min_purchase_not_met"]
    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}
    N -->|Yes| O["Error: discount_not_applicable"]
    N -->|No| P["Valid - return discount"]
```

### 7.5 Error Codes

| Code | Description |
|---|---|
| `discount_not_found` | Code does not exist in this store |
| `discount_expired` | Past `ends_at` date or status is not active |
| `discount_not_yet_active` | Before `starts_at` date |
| `discount_usage_limit_reached` | `usage_count >= usage_limit` |
| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |
| `discount_not_applicable` | No qualifying products in cart |

### 7.6 Discount Allocation

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

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

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

    remainingDiscount = totalDiscount

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

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

    RECALCULATE line_total_amount for each affected line
```

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

### 7.7 Usage Tracking

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

---

## 8. Tax Calculation

### 8.1 Tax Provider Architecture

**Interface:** `App\Contracts\TaxProvider`

- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.
- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.
- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.

**Service:** `App\Services\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.

**Implementations:**
- `App\Services\Tax\ManualTaxProvider` - default, uses manually configured rates
- `App\Services\Tax\StripeTaxProvider` - stub for Stripe Tax API integration

### 8.2 Manual Tax Mode

**Rate resolution:**
1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).
2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.
3. Rate stored as integer basis points (e.g., 1900 = 19.00%).

**Calculation per line:**

```
line_tax = ROUND(line_total_after_discount * rate / 10000)
```

Round each line's tax independently, then sum for the total tax amount.

### 8.3 Prices Include Tax

Controlled by `tax_settings.prices_include_tax` (boolean).

#### Tax-inclusive pricing (prices_include_tax = true)

- Displayed prices on the storefront are gross (include tax).
- Subtotal shown to customer is gross.
- Tax is "extracted" from the gross amount:

```
net_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)
tax = gross_amount - net_amount
```

> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.

**Example:** Price = 1190 cents, rate = 1900 (19%).
- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000
- tax = 1190 - 1000 = 190

#### Tax-exclusive pricing (prices_include_tax = false)

- Displayed prices are net (excluding tax).
- Tax added on top:

```
tax = ROUND(net_amount * rate / 10000)
total = net_amount + tax
```

**Example:** Price = 1000 cents, rate = 1900.
- tax = ROUND(1000 * 1900 / 10000) = 190
- total = 1190

### 8.4 Stripe Tax Provider (Stub)

**Implementation:** `App\Services\Tax\StripeTaxProvider`

- Sends line items and shipping address to Stripe Tax API.
- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.
- Fallback behavior (configurable per store in `tax_settings.config_json`):

| Fallback Setting | Behavior |
|---|---|
| `"block"` | Block checkout progression if Stripe Tax API errors |
| `"allow"` | Allow checkout to proceed without tax (log a warning) |

### 8.5 Discount-Before-Tax Semantics

Discounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.

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.

**Calculation order within the pricing pipeline:**
1. Calculate line subtotals.
2. Apply discount allocations to lines.
3. Calculate tax on discounted line amounts.
4. Sum everything for the total.

### 9.1 Zone Matching

**Service:** `App\Services\ShippingCalculator`

**Zone matching algorithm:**

```
PROCEDURE getMatchingZone(address, store):
    zones = load all shipping zones for store

    bestMatch = null
    bestSpecificity = -1

    FOR EACH zone IN zones:
        countryMatch = address.country_code IN zone.countries_json
        regionMatch = address.province_code IN zone.regions_json

        IF countryMatch AND regionMatch:
            specificity = 2  // Most specific
        ELSE IF countryMatch:
            specificity = 1  // Country-only match
        ELSE:
            CONTINUE  // No match

        IF specificity > bestSpecificity:
            bestMatch = zone
            bestSpecificity = specificity
        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:
            bestMatch = zone  // Tie-break: lowest ID wins

    RETURN bestMatch  // null if no zone matches
```

**When no zone matches:** The address is unserviceable. Block checkout progression with error "Cannot ship to this address".

### 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)

These are static mock values. No real bank interaction occurs.

### 10.7 Admin: Confirm Payment (Bank Transfer)

Admin users can confirm that a bank transfer payment has been received via a "Confirm Payment" button on the order detail page (visible only when `payment_method = bank_transfer` AND `financial_status = pending`).

**Action flow:**
1. Admin clicks "Confirm Payment" button.
2. Backend updates `payments.status` from `pending` to `captured`.
3. Backend updates `orders.financial_status` from `pending` to `paid`.
4. Backend updates `orders.status` from `pending` to `paid`.
5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).
6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).
7. Dispatch `OrderPaid` event.

### 10.8 Auto-Cancel Unpaid Bank Transfer Orders

**Job:** `App\Jobs\CancelUnpaidBankTransferOrders`

- Schedule: daily
- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.
- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.

**Cancel action:**
1. Release reserved inventory via `InventoryService.release()`.
2. Update `orders.financial_status` to `voided`.
3. Update `orders.status` to `cancelled`.
4. Update `payments.status` to `failed`.
5. Dispatch `OrderCancelled` event.

---

## 11. Order Management

### 11.1 Order Creation (from Completed Checkout)

See Section 6.2 "Transition: payment_selected -> completed" for the full procedure. The order creation is the central action within the completeCheckout transaction.

**Key requirements for the order record:**
- Snapshot all checkout data (addresses, email, totals) at time of order creation.
| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |
| `refunded` | **No** | Fully refunded, nothing to ship |
| `voided` | **No** | Payment voided / cancelled |

When fulfillment is blocked by the guard, the admin UI shows an info callout: "Fulfillment cannot be created until payment is confirmed." The "Create fulfillment" button is disabled.

#### Creating a Fulfillment

Input:
- `lines` - map of order_line_id to quantity, specifying which items are in this shipment
- `trackingData` - tracking_company, tracking_number, tracking_url

**Process:**

```
PROCEDURE createFulfillment(order, lines, trackingData):
    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')
       IF NOT: throw FulfillmentGuardException

    2. FOR EACH line in request:
       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)
       unfulfilled = order_line.quantity - fulfilledSoFar
       VALIDATE requested quantity <= unfulfilled

    3. CREATE Fulfillment record with status = 'pending' and tracking data

    4. CREATE fulfillment_lines for each order line and quantity

    5. Determine new fulfillment status:
       FOR EACH order_line in order:
           totalFulfilled = SUM(all fulfillment_lines for this order_line)
           IF totalFulfilled < order_line.quantity:
               allFulfilled = false

       IF allFulfilled:
           SET order.fulfillment_status = 'fulfilled'
           SET order.status = 'fulfilled'
       ELSE:
           SET order.fulfillment_status = 'partial'

    6. DISPATCH FulfillmentCreated event
```

#### Fulfillment Status Transitions (Admin Button Actions)

Fulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.

```mermaid
stateDiagram-v2
    [*] --> pending : Fulfillment created (admin clicks "Create fulfillment")
    pending --> shipped : Admin clicks "Mark as shipped"
    shipped --> delivered : Admin clicks "Mark as delivered"

    note right of pending : Fulfillment record created with tracking data
    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer
    note right of delivered : Sets delivered_at timestamp
```

**Admin buttons on each fulfillment card:**

| Button | Shown When | Action |
|---|---|---|
| "Mark as shipped" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |
| "Mark as delivered" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |

When transitioning to `shipped`:
- Set `shipped_at` timestamp.
- Dispatch `FulfillmentShipped` event.
- Send email notification to customer with tracking information.

When transitioning to `delivered`:
- Dispatch `FulfillmentDelivered` event.

### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method

The fulfillment guard interacts with inventory differently depending on the payment method:

| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |
|---|---|---|---|
| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |
| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |

For bank transfer orders, inventory is held in a reserved state until the admin confirms payment. If the order is auto-cancelled (see Section 10.8), the reserved inventory is released back.

### 11.7 Auto-Fulfillment for Digital Products

When a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):

1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).
2. If yes: automatically create a fulfillment record covering all order lines:
   - `status = 'delivered'` (skip pending and shipped)
   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`
   - `shipped_at = now`
   - Set `order.fulfillment_status = 'fulfilled'`
   - Set `order.status = 'fulfilled'`
3. If the order contains a mix of physical and digital products: do **not** auto-fulfill. The admin must create fulfillments manually (they can fulfill digital items immediately and physical items when shipped).

This ensures digital product orders are immediately marked as complete after payment.

### 11.6 Order Export

`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.

**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`

All monetary values are exported in minor units (cents). Dates use ISO-8601 format. The response uses `Content-Type: text/csv` with `Content-Disposition: attachment; filename="orders-{date}.csv"`.

---

## 12. Customer Accounts

### 12.1 Registration

**Service:** `App\Services\CustomerService`

**Required fields:**

| Field | Validation |
|---|---|
| `name` | Required, string |
| `email` | Required, valid email, unique per store |
| `password` | Required, minimum 8 characters |
| `marketing_opt_in` | Optional, boolean, default false |

- Email uniqueness enforced by the database unique index on `(store_id, email)`.
- Password hashed using Laravel's Hash facade (bcrypt by default).


text:
Script completed
Wall time 0.1 seconds
Output:

text:
## 7. Discount Engine

### 7.1 Discount Types

**Enum:** `App\Enums\DiscountType` - values: `code`, `automatic`

| Type | Behavior |
|---|---|
| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |
| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |

**Stacking rules:**
- Only one code discount per checkout.
- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).

### Discount Status Lifecycle

| Status | Description | Transition |
|--------|-------------|------------|
| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |
| `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. |
| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |
| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |

**Allowed transitions:**
- `draft` -> `active`
- `active` -> `disabled`
- `active` -> `expired` (automatic)
- `disabled` -> `active`

### 7.2 Value Types

**Enum:** `App\Enums\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`

| Value Type | Calculation | Notes |
|---|---|---|
| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |
| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |
| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |

### 7.3 Rules

Rules are stored in `discounts.rules_json` with the following structure:

| Rule Key | Type | Description |
|---|---|---|
| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |
| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |
| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |
| `customer_eligibility` | string | `"all"` for all customers. Future expansion: `"returning"`, `"specific"`. |

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

### 7.4 Validation Flow

**Service:** `App\Services\DiscountService`

**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object

```mermaid
flowchart TD
    A[Input: discount code + cart] --> B{Code exists in store?
    Case-insensitive lookup}
    B -->|No| C["Error: discount_not_found"]
    B -->|Yes| D{Status = active?}
    D -->|No| E["Error: discount_expired"]
    D -->|Yes| F{starts_at <= now?}
    F -->|No| G["Error: discount_not_yet_active"]
    F -->|Yes| H{ends_at is null OR ends_at >= now?}
    H -->|No| I["Error: discount_expired"]
    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}
    J -->|Yes| K["Error: discount_usage_limit_reached"]
    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}
    L -->|Yes| M["Error: discount_min_purchase_not_met"]
    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}
    N -->|Yes| O["Error: discount_not_applicable"]
    N -->|No| P["Valid - return discount"]
```

### 7.5 Error Codes

| Code | Description |
|---|---|
| `discount_not_found` | Code does not exist in this store |
| `discount_expired` | Past `ends_at` date or status is not active |
| `discount_not_yet_active` | Before `starts_at` date |
| `discount_usage_limit_reached` | `usage_count >= usage_limit` |
| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |
| `discount_not_applicable` | No qualifying products in cart |

### 7.6 Discount Allocation

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

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

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

    remainingDiscount = totalDiscount

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

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

    RECALCULATE line_total_amount for each affected line
```

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

### 7.7 Usage Tracking

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

---

## 8. Tax Calculation

### 8.1 Tax Provider Architecture

**Interface:** `App\Contracts\TaxProvider`

- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.
- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.
- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.

**Service:** `App\Services\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.

**Implementations:**
- `App\Services\Tax\ManualTaxProvider` - default, uses manually configured rates
- `App\Services\Tax\StripeTaxProvider` - stub for Stripe Tax API integration

### 8.2 Manual Tax Mode

**Rate resolution:**
1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).
2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.
3. Rate stored as integer basis points (e.g., 1900 = 19.00%).

**Calculation per line:**

```
line_tax = ROUND(line_total_after_discount * rate / 10000)
```

Round each line's tax independently, then sum for the total tax amount.

### 8.3 Prices Include Tax

Controlled by `tax_settings.prices_include_tax` (boolean).

#### Tax-inclusive pricing (prices_include_tax = true)

- Displayed prices on the storefront are gross (include tax).
- Subtotal shown to customer is gross.
- Tax is "extracted" from the gross amount:

```
net_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)
tax = gross_amount - net_amount
```

> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.

**Example:** Price = 1190 cents, rate = 1900 (19%).
- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000
- tax = 1190 - 1000 = 190

#### Tax-exclusive pricing (prices_include_tax = false)

- Displayed prices are net (excluding tax).
- Tax added on top:

```
tax = ROUND(net_amount * rate / 10000)
total = net_amount + tax
```

**Example:** Price = 1000 cents, rate = 1900.
- tax = ROUND(1000 * 1900 / 10000) = 190
- total = 1190

### 8.4 Stripe Tax Provider (Stub)

**Implementation:** `App\Services\Tax\StripeTaxProvider`

- Sends line items and shipping address to Stripe Tax API.
- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.
- Fallback behavior (configurable per store in `tax_settings.config_json`):

| Fallback Setting | Behavior |
|---|---|
| `"block"` | Block checkout progression if Stripe Tax API errors |
| `"allow"` | Allow checkout to proceed without tax (log a warning) |

### 8.5 Discount-Before-Tax Semantics

Discounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.

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.

**Calculation order within the pricing pipeline:**
1. Calculate line subtotals.
2. Apply discount allocations to lines.
3. Calculate tax on discounted line amounts.
4. Sum everything for the total.

### 9.1 Zone Matching

**Service:** `App\Services\ShippingCalculator`

**Zone matching algorithm:**

```
PROCEDURE getMatchingZone(address, store):
    zones = load all shipping zones for store

    bestMatch = null
    bestSpecificity = -1

    FOR EACH zone IN zones:
        countryMatch = address.country_code IN zone.countries_json
        regionMatch = address.province_code IN zone.regions_json

        IF countryMatch AND regionMatch:
            specificity = 2  // Most specific
        ELSE IF countryMatch:
            specificity = 1  // Country-only match
        ELSE:
            CONTINUE  // No match

        IF specificity > bestSpecificity:
            bestMatch = zone
            bestSpecificity = specificity
        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:
            bestMatch = zone  // Tie-break: lowest ID wins

    RETURN bestMatch  // null if no zone matches
```

**When no zone matches:** The address is unserviceable. Block checkout progression with error "Cannot ship to this address".

### 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)

These are static mock values. No real bank interaction occurs.

### 10.7 Admin: Confirm Payment (Bank Transfer)

Admin users can confirm that a bank transfer payment has been received via a "Confirm Payment" button on the order detail page (visible only when `payment_method = bank_transfer` AND `financial_status = pending`).

**Action flow:**
1. Admin clicks "Confirm Payment" button.
2. Backend updates `payments.status` from `pending` to `captured`.
3. Backend updates `orders.financial_status` from `pending` to `paid`.
4. Backend updates `orders.status` from `pending` to `paid`.
5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).
6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).
7. Dispatch `OrderPaid` event.

### 10.8 Auto-Cancel Unpaid Bank Transfer Orders

**Job:** `App\Jobs\CancelUnpaidBankTransferOrders`

- Schedule: daily
- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.
- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.

**Cancel action:**
1. Release reserved inventory via `InventoryService.release()`.
2. Update `orders.financial_status` to `voided`.
3. Update `orders.status` to `cancelled`.
4. Update `payments.status` to `failed`.
5. Dispatch `OrderCancelled` event.

---

## 11. Order Management

### 11.1 Order Creation (from Completed Checkout)

See Section 6.2 "Transition: payment_selected -> completed" for the full procedure. The order creation is the central action within the completeCheckout transaction.

**Key requirements for the order record:**
- Snapshot all checkout data (addresses, email, totals) at time of order creation.
| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |
| `refunded` | **No** | Fully refunded, nothing to ship |
| `voided` | **No** | Payment voided / cancelled |

When fulfillment is blocked by the guard, the admin UI shows an info callout: "Fulfillment cannot be created until payment is confirmed." The "Create fulfillment" button is disabled.

#### Creating a Fulfillment

Input:
- `lines` - map of order_line_id to quantity, specifying which items are in this shipment
- `trackingData` - tracking_company, tracking_number, tracking_url

**Process:**

```
PROCEDURE createFulfillment(order, lines, trackingData):
    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')
       IF NOT: throw FulfillmentGuardException

    2. FOR EACH line in request:
       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)
       unfulfilled = order_line.quantity - fulfilledSoFar
       VALIDATE requested quantity <= unfulfilled

    3. CREATE Fulfillment record with status = 'pending' and tracking data

    4. CREATE fulfillment_lines for each order line and quantity

    5. Determine new fulfillment status:
       FOR EACH order_line in order:
           totalFulfilled = SUM(all fulfillment_lines for this order_line)
           IF totalFulfilled < order_line.quantity:
               allFulfilled = false

       IF allFulfilled:
           SET order.fulfillment_status = 'fulfilled'
           SET order.status = 'fulfilled'
       ELSE:
           SET order.fulfillment_status = 'partial'

    6. DISPATCH FulfillmentCreated event
```

#### Fulfillment Status Transitions (Admin Button Actions)

Fulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.

```mermaid
stateDiagram-v2
    [*] --> pending : Fulfillment created (admin clicks "Create fulfillment")
    pending --> shipped : Admin clicks "Mark as shipped"
    shipped --> delivered : Admin clicks "Mark as delivered"

    note right of pending : Fulfillment record created with tracking data
    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer
    note right of delivered : Sets delivered_at timestamp
```

**Admin buttons on each fulfillment card:**

| Button | Shown When | Action |
|---|---|---|
| "Mark as shipped" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |
| "Mark as delivered" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |

When transitioning to `shipped`:
- Set `shipped_at` timestamp.
- Dispatch `FulfillmentShipped` event.
- Send email notification to customer with tracking information.

When transitioning to `delivered`:
- Dispatch `FulfillmentDelivered` event.

### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method

The fulfillment guard interacts with inventory differently depending on the payment method:

| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |
|---|---|---|---|
| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |
| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |

For bank transfer orders, inventory is held in a reserved state until the admin confirms payment. If the order is auto-cancelled (see Section 10.8), the reserved inventory is released back.

### 11.7 Auto-Fulfillment for Digital Products

When a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):

1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).
2. If yes: automatically create a fulfillment record covering all order lines:
   - `status = 'delivered'` (skip pending and shipped)
   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`
   - `shipped_at = now`
   - Set `order.fulfillment_status = 'fulfilled'`
   - Set `order.status = 'fulfilled'`
3. If the order contains a mix of physical and digital products: do **not** auto-fulfill. The admin must create fulfillments manually (they can fulfill digital items immediately and physical items when shipped).

This ensures digital product orders are immediately marked as complete after payment.

### 11.6 Order Export

`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.

**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`

All monetary values are exported in minor units (cents). Dates use ISO-8601 format. The response uses `Content-Type: text/csv` with `Content-Disposition: attachment; filename="orders-{date}.csv"`.

---

## 12. Customer Accounts

### 12.1 Registration

**Service:** `App\Services\CustomerService`

**Required fields:**

| Field | Validation |
|---|---|
| `name` | Required, string |
| `email` | Required, valid email, unique per store |
| `password` | Required, minimum 8 characters |
| `marketing_opt_in` | Optional, boolean, default false |

- Email uniqueness enforced by the database unique index on `(store_id, email)`.
- Password hashed using Laravel's Hash facade (bcrypt by default).


status: completed

input:
const r = await tools.exec_command({
  cmd: "sed -n '1,220p' app/Services/Tax/StripeTaxProvider.php && sed -n '1,170p' app/ValueObjects/TaxCalculationRequest.php && sed -n '1,100p' app/ValueObjects/TaxResult.php && sed -n '1,150p' database/seeders/TaxSettingsSeeder.php && sed -n '1,180p' app/Jobs/CancelUnpaidBankTransferOrders.php && sed -n '1,180p' app/Jobs/CleanupAbandonedCarts.php && sed -n '1,180p' app/Jobs/ExpireAbandonedCheckouts.php",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 1000,
  max_output_tokens: 20000
});
text(r.output);


command: sed -n '1,220p' app/Services/Tax/StripeTaxProvider.php && sed -n '1,170p' app/ValueObjects/TaxCalculationRequest.php && sed -n '1,100p' app/ValueObjects/TaxResult.php && sed -n '1,150p' database/seeders/TaxSettingsSeeder.php && sed -n '1,180p' app/Jobs/CancelUnpaidBankTransferOrders.php && sed -n '1,180p' app/Jobs/CleanupAbandonedCarts.php && sed -n '1,180p' app/Jobs/ExpireAbandonedCheckouts.php

ExpireAbandonedCheckouts.php

stdout:
<?php

namespace App\Services\Tax;

use App\Contracts\TaxProvider;
use App\ValueObjects\TaxCalculationRequest;
use App\ValueObjects\TaxResult;

class StripeTaxProvider implements TaxProvider
{
    public function __construct(private readonly ManualTaxProvider $fallback) {}

    public function calculate(TaxCalculationRequest $request): TaxResult
    {
        if (($request->settings->provider_config_json['fallback'] ?? 'allow') === 'block') {
            throw new \RuntimeException('Stripe Tax is not configured for this environment.');
        }

        return new TaxResult(0, []);
    }
}
<?php

namespace App\ValueObjects;

use App\Models\TaxSettings;

final readonly class TaxCalculationRequest
{
    /**
     * @param  array<int, array<string, mixed>>  $lineItems
     */
    public function __construct(
        public array $lineItems,
        public int $shippingAmount,
        public array $address,
        public TaxSettings $settings,
    ) {}
}
<?php

namespace App\ValueObjects;

readonly class TaxResult
{
    /** @param list<TaxLine> $lines */
    public function __construct(public int $total, public array $lines = []) {}
}
<?php

namespace Database\Seeders;

use App\Models\Store;
use App\Models\TaxSettings;
use Illuminate\Database\Seeder;

class TaxSettingsSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        foreach (Store::query()->whereIn('handle', ['acme-fashion', 'acme-electronics'])->get() as $store) {
            TaxSettings::withoutGlobalScopes()->updateOrCreate(
                ['store_id' => $store->getKey()],
                ['mode' => 'manual', 'provider' => 'none', 'prices_include_tax' => true, 'config_json' => ['default_rate_bps' => 1900], 'default_rate_basis_points' => 1900, 'rates_json' => ['DE' => 1900], 'provider_config_json' => []],
            );
        }
    }
}
<?php

namespace App\Jobs;

use App\Enums\FinancialStatus;
use App\Models\Order;
use App\Services\OrderService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class CancelUnpaidBankTransferOrders implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function handle(OrderService $orders): void
    {
        $days = (int) config('shop.bank_transfer_expiry_days', 7);
        Order::withoutGlobalScopes()->where('payment_method', 'bank_transfer')->where('financial_status', FinancialStatus::Pending)->where('placed_at', '<', now()->subDays($days))->with('lines.variant.inventory')->each(fn (Order $order): mixed => $orders->cancel($order, 'Bank transfer payment expired.'));
    }
}
<?php

namespace App\Jobs;

use App\Enums\CheckoutStatus;
use App\Models\Cart;
use App\Models\Checkout;
use App\Services\InventoryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;

class CleanupAbandonedCarts implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function handle(InventoryService $inventory): void
    {
        Cart::withoutGlobalScopes()->where('status', 'active')->where('updated_at', '<', now()->subDays(14))->each(function (Cart $cart) use ($inventory): void {
            DB::transaction(function () use ($cart, $inventory): void {
                $cart->load('lines.variant.inventory');
                $checkouts = Checkout::withoutGlobalScopes()
                    ->where('cart_id', $cart->getKey())
                    ->where('status', CheckoutStatus::PaymentSelected->value)
                    ->lockForUpdate()
                    ->get();

                foreach ($checkouts as $checkout) {
                    foreach ($cart->lines as $line) {
                        if ($line->variant?->inventory !== null) {
                            $inventory->release($line->variant->inventory, $line->quantity);
                        }
                    }

                    $checkout->update(['status' => CheckoutStatus::Expired]);
                }

                $cart->update(['status' => 'abandoned']);
            });
        });
    }
}
<?php

namespace App\Jobs;

use App\Enums\CheckoutStatus;
use App\Events\CheckoutExpired;
use App\Models\Checkout;
use App\Services\InventoryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class ExpireAbandonedCheckouts implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function handle(InventoryService $inventory): void
    {
        Checkout::withoutGlobalScopes()->whereNotIn('status', [CheckoutStatus::Completed, CheckoutStatus::Expired])->where('expires_at', '<', now())->with('cart.lines.variant.inventory')->each(function (Checkout $checkout) use ($inventory): void {
            foreach ($checkout->cart->lines as $line) {
                if ($line->variant->inventory !== null && $checkout->status === CheckoutStatus::PaymentSelected) {
                    $inventory->release($line->variant->inventory, $line->quantity);
                }
            }

            $checkout->update(['status' => CheckoutStatus::Expired]);
            CheckoutExpired::dispatch($checkout->refresh());
        });
    }
}


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Services\Tax;

use App\Contracts\TaxProvider;
use App\ValueObjects\TaxCalculationRequest;
use App\ValueObjects\TaxResult;

class StripeTaxProvider implements TaxProvider
{
    public function __construct(private readonly ManualTaxProvider $fallback) {}

    public function calculate(TaxCalculationRequest $request): TaxResult
    {
        if (($request->settings->provider_config_json['fallback'] ?? 'allow') === 'block') {
            throw new \RuntimeException('Stripe Tax is not configured for this environment.');
        }

        return new TaxResult(0, []);
    }
}
<?php

namespace App\ValueObjects;

use App\Models\TaxSettings;

final readonly class TaxCalculationRequest
{
    /**
     * @param  array<int, array<string, mixed>>  $lineItems
     */
    public function __construct(
        public array $lineItems,
        public int $shippingAmount,
        public array $address,
        public TaxSettings $settings,
    ) {}
}
<?php

namespace App\ValueObjects;

readonly class TaxResult
{
    /** @param list<TaxLine> $lines */
    public function __construct(public int $total, public array $lines = []) {}
}
<?php

namespace Database\Seeders;

use App\Models\Store;
use App\Models\TaxSettings;
use Illuminate\Database\Seeder;

class TaxSettingsSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        foreach (Store::query()->whereIn('handle', ['acme-fashion', 'acme-electronics'])->get() as $store) {
            TaxSettings::withoutGlobalScopes()->updateOrCreate(
                ['store_id' => $store->getKey()],
                ['mode' => 'manual', 'provider' => 'none', 'prices_include_tax' => true, 'config_json' => ['default_rate_bps' => 1900], 'default_rate_basis_points' => 1900, 'rates_json' => ['DE' => 1900], 'provider_config_json' => []],
            );
        }
    }
}
<?php

namespace App\Jobs;

use App\Enums\FinancialStatus;
use App\Models\Order;
use App\Services\OrderService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class CancelUnpaidBankTransferOrders implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function handle(OrderService $orders): void
    {
        $days = (int) config('shop.bank_transfer_expiry_days', 7);
        Order::withoutGlobalScopes()->where('payment_method', 'bank_transfer')->where('financial_status', FinancialStatus::Pending)->where('placed_at', '<', now()->subDays($days))->with('lines.variant.inventory')->each(fn (Order $order): mixed => $orders->cancel($order, 'Bank transfer payment expired.'));
    }
}
<?php

namespace App\Jobs;

use App\Enums\CheckoutStatus;
use App\Models\Cart;
use App\Models\Checkout;
use App\Services\InventoryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;

class CleanupAbandonedCarts implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function handle(InventoryService $inventory): void
    {
        Cart::withoutGlobalScopes()->where('status', 'active')->where('updated_at', '<', now()->subDays(14))->each(function (Cart $cart) use ($inventory): void {
            DB::transaction(function () use ($cart, $inventory): void {
                $cart->load('lines.variant.inventory');
                $checkouts = Checkout::withoutGlobalScopes()
                    ->where('cart_id', $cart->getKey())
                    ->where('status', CheckoutStatus::PaymentSelected->value)
                    ->lockForUpdate()
                    ->get();

                foreach ($checkouts as $checkout) {
                    foreach ($cart->lines as $line) {
                        if ($line->variant?->inventory !== null) {
                            $inventory->release($line->variant->inventory, $line->quantity);
                        }
                    }

                    $checkout->update(['status' => CheckoutStatus::Expired]);
                }

                $cart->update(['status' => 'abandoned']);
            });
        });
    }
}
<?php

namespace App\Jobs;

use App\Enums\CheckoutStatus;
use App\Events\CheckoutExpired;
use App\Models\Checkout;
use App\Services\InventoryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class ExpireAbandonedCheckouts implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function handle(InventoryService $inventory): void
    {
        Checkout::withoutGlobalScopes()->whereNotIn('status', [CheckoutStatus::Completed, CheckoutStatus::Expired])->where('expires_at', '<', now())->with('cart.lines.variant.inventory')->each(function (Checkout $checkout) use ($inventory): void {
            foreach ($checkout->cart->lines as $line) {
                if ($line->variant->inventory !== null && $checkout->status === CheckoutStatus::PaymentSelected) {
                    $inventory->release($line->variant->inventory, $line->quantity);
                }
            }

            $checkout->update(['status' => CheckoutStatus::Expired]);
            CheckoutExpired::dispatch($checkout->refresh());
        });
    }
}


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "sed -n '1,130p' database/seeders/ShippingSeeder.php; sed -n '1,120p' database/factories/ShippingRateFactory.php; sed -n '1,120p' database/factories/ShippingZoneFactory.php; sed -n '1,160p' app/Models/ShippingRate.php; sed -n '1,120p' app/Models/Cart.php; sed -n '1,130p' app/Models/StoreSetting.php 2>/dev/null || true; rg -n \"class StoreSetting|settings_json\" app/Models database/migrations/2026_08_20_000000_create_shop_schema.php | head -80",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 1000,
  max_output_tokens: 16000
});
text(r.output);