# Orders Module

Post-purchase fulfillment and money movement. Purchase = buyer-facing parent; Order = seller-facing child (one per store).

## Cancellation matrix

| Actor  | Route                                        | Items become | Refund | Notes                                   |
|--------|----------------------------------------------|--------------|--------|-----------------------------------------|
| Buyer  | `POST /v1/orders/{order}/cancel`             | `active`     | Yes    | Only allowed while `pending`/`processing` |
| Seller | `POST /v1/stores/{store}/orders/{order}/cancel` (reason, note) | `removed` | Yes | Activity Log entry `seller_cancelled_order` |
| System | `AutoCancelOverdueOrderJob`                  | `active`     | Yes    | Fires at `ship_by + 7 weekdays`         |

Per-Order. The Purchase status is recomputed via `RecomputePurchaseStatusOnOrderChange`.

## Transfer-on-ship contract

1. Checkout webhook (`payment_intent.succeeded`) creates Purchase + Orders. No Stripe Transfer.
2. Seller calls `POST /v1/stores/{store}/orders/{order}/labels` → `OrderShipped` dispatched.
3. `TransferFundsToStore` listener runs the Stripe Transfer, sets `stripe_transfer_id` + `transferred_at`. Skipped if `Purchase.disputed`, no Connect account, or `seller_payout <= 0`.
4. Failures are logged only; `orders:reconcile-money` artisan command (scheduled hourly) retries shipped orders without a Transfer.

Dispute path: `charge.dispute.created` webhook sets `Purchase.disputed = true` and dispatches `PurchaseDisputed`. Any future `OrderShipped` under that Purchase no-ops the Transfer.

## Ship-by job pipeline

On `OrderPaid`, `ScheduleShipByJobsOnPaid` dispatches three delayed jobs keyed on the Order's `ship_by` timestamp:

| Job                         | Fires at            | Dispatches                     | No-op if status is… |
|-----------------------------|---------------------|--------------------------------|---------------------|
| `SendShipByReminderJob`     | `ship_by`           | `ShipByReminderDue`            | not pending/processing |
| `NotifyOrderDelayedJob`     | `ship_by + 2 days`  | `OrderDelayed`                 | not pending/processing |
| `AutoCancelOverdueOrderJob` | `ship_by + 7 weekdays` | `systemCancel` + `OrderAutoCancelled` | not pending/processing |

Jobs re-read the Order at execution time — a shipped or already-cancelled Order makes all three jobs no-op.

`ship_by` is computed at Purchase time from the store's `store_settings.processing_days` (falls back to 3).

## `is_delayed`

Computed Order attribute (no column). True when `ship_by IS NOT NULL` AND status is `pending`/`processing` AND `now() > ship_by + 2 days`. The buyer UI surfaces this as a delay banner and the "Cancel for full refund" CTA.

Purchase responses carry a rollup: `is_delayed = any child Order.is_delayed`.

## Events

- `OrderPaid` → `ScheduleShipByJobsOnPaid`
- `OrderShipped` → `TransferFundsToStore`, `RecomputePurchaseStatusOnOrderChange`
- `OrderDelivered` → `RecomputePurchaseStatusOnOrderChange`
- `OrderCancelled` → `RecomputePurchaseStatusOnOrderChange`
- `OrderAutoCancelled`, `OrderDelayed`, `ShipByReminderDue`, `OrderDeliveryFailed`, `PurchaseDisputed` — surfaced for later notification listeners.
