# Layer 5: Order Fulfillment & Shipping — Design Spec

**Date:** 2026-04-16
**Status:** Approved
**Depends on:** Layer 4 (Cart & Checkout)
**Spec location:** `Alqove/docs/superpowers/specs/2026-04-16-layer-5-fulfillment-shipping-design.md`

---

## Overview

Layer 5 turns paid Orders into shipped packages. It adds EasyPost label purchase, tracking webhooks, buyer-initiated cancellation with item relisting, seller-initiated cancellation with reason codes, ship-by reminders via per-Order delayed jobs, and a minimal Stripe dispute handler. Stripe Transfers move from Layer 4's payment-time event to Layer 5's ship-time event to match the master spec.

Seller-facing UI is intentionally deferred to Layer 7 (Seller Dashboard). Buyer-facing purchase tracking UI ships in this layer because no later layer absorbs it.

## Key Design Decisions

| Decision | Choice | Rationale |
|----------|--------|-----------|
| Shipping provider | EasyPost only, behind a `LabelProvider` contract | Avoids speculative multi-provider abstraction; interface lets us swap later without restructuring |
| UI scope | API + buyer tracking UI. No seller UI. | Seller dashboard shell lives in Layer 7; building seller order UI now would duplicate work |
| Stripe Transfer timing | On `OrderShipped`, not on `payment_intent.succeeded` | Matches master spec; pre-ship cancellations just reverse the charge with no Transfer to unwind |
| Cancellation rules | Either party may cancel pre-ship; buyer cancel relists items, seller cancel removes them | Matches master spec tone ("tooling gap, not punitive"); avoids new "accepted" state |
| Delivery confirmation | EasyPost `tracker.updated` webhook only | Buyer manual "mark received" adds friction with no downstream value |
| Returns & disputes | Returns deferred; disputes minimal handler only | Stripe fires dispute webhooks regardless of readiness — a minimal handler is non-optional; returns have no external forcing function |
| Ship-by timing | Per-Order delayed jobs (reminder, buyer-delayed notice, auto-cancel) that no-op on state change | More durable than a nightly scan; pattern reused for future dispute/return windows |
| Rate selection | Auto-select cheapest rate | Seller charges buyer a flat shipping rate; label cost is seller margin, not buyer experience |
| Ship-from address | New columns on `stores` (`street1, street2, zip, country`) | One-to-one with Store; no multi-address use case |
| Parcel dimensions | `store_parcel_presets` table; seller picks at label time | Resale stores ship mostly clothing — 2-3 presets cover ~95% of orders |
| Business-day math | Carbon `addBusinessDays`, no holiday calendar | Simple, deterministic, good enough for a 7-day window |

## Architecture

### Modules

**Shipping Module** (currently an empty scaffold — fills in this layer)
- `Contracts/LabelProvider.php` — `rates(Shipment)`, `buyCheapest(Shipment)`, `parseTrackerEvent(payload)`
- `Services/EasyPostProvider.php` — production adapter
- `Services/FakeLabelProvider.php` — test/dev adapter with deterministic fixtures
- `Services/TrackingService.php` — maps EasyPost statuses to internal Order states
- `Controllers/ShippingWebhookController.php` — `easypost` action with HMAC verification
- `Jobs/BuyLabelForOrder.php` — optional async path if we later want label purchase off the request thread
- `routes.php` — registers the EasyPost webhook endpoint
- `Tests/Feature/EasyPostWebhookTest.php`, `Tests/Unit/TrackingServiceTest.php`

**Orders Module** (currently read-only — gains write operations)
- `Services/OrderFulfillmentService.php` — `purchaseLabel(Order, preset)`, `markDelivered(Order)`
- `Services/CancellationService.php` — `buyerCancel(Order, user)`, `sellerCancel(Order, store, reason, note)`, `systemCancel(Order, reason)`
- `Controllers/OrderController.php` — extend with `cancel` (buyer) and (seller) variants; add `purchaseLabel` action
- `Controllers/PurchaseController.php` — extend resources with tracking fields and `is_delayed`
- `Requests/BuyLabelRequest.php`, `BuyerCancelRequest.php`, `SellerCancelRequest.php`
- `Resources/OrderResource.php`, `PurchaseResource.php` — extend with new fields
- `Jobs/SendShipByReminderJob.php`, `NotifyOrderDelayedJob.php`, `AutoCancelOverdueOrderJob.php`
- `Events/OrderShipped.php`, `OrderDelivered.php`, `OrderCancelled.php`, `OrderAutoCancelled.php`, `OrderDeliveryFailed.php`, `ShipByReminderDue.php`, `OrderDelayed.php`, `PurchaseDisputed.php`
- `Listeners/TransferFundsToStore.php` — on `OrderShipped`, creates Stripe Transfer (checks `Purchase.disputed` flag first; skips if disputed)
- `Listeners/ScheduleShipByJobsOnPaid.php` — on `OrderPaid`, dispatches the three delayed jobs
- `Listeners/RecomputePurchaseStatusOnOrderChange.php` — on `OrderShipped`, `OrderDelivered`, `OrderCancelled`, rolls up child Order statuses into Purchase status

Item status transitions (relist on buyer cancel, removal on seller cancel) happen atomically inside `CancellationService` DB transactions — not as event listeners — to guarantee consistency with Order state. Scout reindexing hangs off the existing `Item` model observer pattern from Layer 2.

**Stores Module** (extended)
- Store migration adds `street1, street2, zip, country`
- New `ParcelPresetController.php` with CRUD actions, nested under `/stores/{store}/parcel-presets`
- New `StoreParcelPreset` model
- Store update request and resource extended to include address fields

**Checkout Module** (modified)
- Layer 4 webhook stops creating Transfers. Instead, dispatches `OrderPaid` event that Orders Module listens to.
- The existing `stripe_transfer_id` column on `orders` is populated by `TransferFundsToStore` listener on ship, not at checkout time.
- Webhook gains a handler for `charge.dispute.created`, which locates the affected Purchase and dispatches `PurchaseDisputed`.

### Data model changes

**Migrations**
1. `add_ship_from_address_to_stores_table` — adds `street1, street2, zip, country` as nullable (existing stores seeded without it; seller must set before buying first label).
2. `create_store_parcel_presets_table`:
   - `id` (uuid), `store_id` (uuid FK, cascadeOnDelete), `name` (string), `weight_oz` (unsigned smallint), `length_in` (unsigned smallint), `width_in` (unsigned smallint), `height_in` (unsigned smallint), `is_default` (bool), timestamps
   - Partial unique index on `(store_id, is_default) WHERE is_default = true` to enforce a single default per store.
3. `add_fulfillment_fields_to_orders_table` — audit Layer 4's `orders` migration and add whatever is missing from:
   - `tracker_id` (string, nullable) — EasyPost tracker reference
   - `tracking_url` (string, nullable), `carrier` (string, nullable), `service` (string, nullable) — `tracking_number` may exist from Layer 4
   - `label_url` (string, nullable), `label_purchased_at` (timestamp, nullable) — `shipping_label_url` may exist
   - `transferred_at` (timestamp, nullable) — populated when Stripe Transfer completes; `stripe_transfer_id` exists from Layer 4
   - `ship_by` (timestamp, nullable) — exists from Layer 4, populated on `OrderPaid` here
4. `add_disputed_to_purchases_table` — adds `disputed` (bool default false) to `purchases` for the minimal dispute flag.

**`is_delayed`** is a computed attribute on the Order model, not a column. Logic: `now() > ship_by + 2 days AND status IN (pending, processing)`.

### Event flow

```
Checkout webhook: payment_intent.succeeded
  → creates Purchase, Orders, OrderItems (existing Layer 4 logic, transfer creation removed)
  → dispatches OrderPaid per Order
    → ScheduleShipByJobsOnPaid: dispatches SendShipByReminderJob, NotifyOrderDelayedJob, AutoCancelOverdueOrderJob with delays

Seller: POST /stores/{store}/orders/{order}/labels
  → OrderFulfillmentService::purchaseLabel → EasyPost → Order fields updated → status=shipped
  → dispatches OrderShipped
    → TransferFundsToStore: Stripe Transfer, sets stripe_transfer_id + transferred_at
    → RecomputePurchaseStatusOnOrderChange: parent Purchase rolled up

EasyPost webhook: tracker.updated (status=delivered)
  → TrackingService → OrderFulfillmentService::markDelivered → status=delivered, delivered_at
  → dispatches OrderDelivered
    → RecomputePurchaseStatusOnOrderChange

Buyer: POST /orders/{order}/cancel
  → CancellationService::buyerCancel → status=cancelled, cancelled_by=buyer, cancellation_reason=buyer_requested
  → Stripe refund for this Order's portion (no Transfer to reverse)
  → dispatches OrderCancelled
    → RelistItemsOnBuyerCancel: items back to active
    → RecomputePurchaseStatusOnOrderChange

Seller: POST /stores/{store}/orders/{order}/cancel { reason, note? }
  → CancellationService::sellerCancel → status=cancelled, cancelled_by=seller, cancellation_reason=reason
  → Stripe refund for this Order's portion
  → dispatches OrderCancelled
    → RemoveItemsOnSellerCancel: items removed (not relisted)
    → RecomputePurchaseStatusOnOrderChange

Scheduled AutoCancelOverdueOrderJob (runs at ship_by + 7 business days)
  → re-load Order; no-op if status changed
  → CancellationService::systemCancel(order, 'ship_deadline_exceeded')
  → same refund + item-relist path as buyer cancel (items relist; seller didn't ship, buyer waited long enough)
  → dispatches OrderAutoCancelled

Stripe webhook: charge.dispute.created
  → Checkout webhook controller locates Purchase by payment_intent
  → Sets Purchase.disputed=true
  → Spatie Activity Log entry with dispute amount, reason, and evidence deadline
  → Dispatches PurchaseDisputed event (Layer 8 Admin will add resolution workflow)
  → TransferFundsToStore listener checks Purchase.disputed before firing — pending Transfers are blocked platform-wide on this Purchase until admin resolves
  → Transfers already sent are NOT auto-reversed (would be unfair to uninvolved sellers in the same Purchase); admin handles those in Layer 8
```

## API Surface

All endpoints prefixed with `/v1/`. Seller endpoints use the existing `/stores/{store}/...` pattern with `store.owner` middleware.

### Order fulfillment (seller)

| Method | Path | Purpose |
|---|---|---|
| POST | `/stores/{store}/orders/{order}/labels` | Buy EasyPost label with chosen parcel preset. Marks Order shipped. Response includes `label_url` for printing. |
| POST | `/stores/{store}/orders/{order}/cancel` | Seller cancel. Body: `{ reason: sold_locally \| item_damaged \| other, note? }`. |

### Parcel presets (seller)

| Method | Path | Purpose |
|---|---|---|
| GET | `/stores/{store}/parcel-presets` | List presets for the store. |
| POST | `/stores/{store}/parcel-presets` | Create preset. |
| PATCH | `/stores/{store}/parcel-presets/{preset}` | Update preset. |
| DELETE | `/stores/{store}/parcel-presets/{preset}` | Delete preset. Returns 409 if it would leave the store with zero presets. |

### Store ship-from address

Extends existing `PUT /v1/stores/{store}` request and resource with `street1, street2, zip, country` fields. No new endpoint.

### Order/Purchase reads (buyer, extended from Layer 4)

Response shapes expand with: `tracking_number`, `tracking_url`, `carrier`, `service`, `ship_by`, `shipped_at`, `delivered_at`, `label_url` (seller-only), `is_delayed`, `cancelled_by`, `cancellation_reason`, `cancelled_at`.

### Buyer action

| Method | Path | Purpose |
|---|---|---|
| POST | `/orders/{order}/cancel` | Buyer cancel. Only permitted while Order status is `pending` or `processing`. |

### Webhooks (public, signature-verified)

| Method | Path | Purpose |
|---|---|---|
| POST | `/webhooks/easypost` | Handle `tracker.updated`. HMAC header verification. Idempotent by EasyPost event ID. |
| POST | `/webhooks/stripe` | *(extends Layer 4)* Add handler for `charge.dispute.created`. Existing signature verification applies. |

### Request/response examples

**POST `/stores/{store}/orders/{order}/labels`**
```json
// request
{ "parcel_preset_id": "uuid" }
// response (200)
{
  "data": {
    "order_id": "uuid",
    "status": "shipped",
    "carrier": "USPS",
    "service": "Priority",
    "tracking_number": "9400...",
    "tracking_url": "https://...",
    "label_url": "https://...",
    "shipped_at": "2026-04-16T14:00:00Z"
  }
}
```

**POST `/orders/{order}/cancel` (buyer)**
```json
// request: {}
// response (200): updated Order resource with status=cancelled, cancelled_by=buyer, cancellation_reason=buyer_requested
```

**POST `/stores/{store}/orders/{order}/cancel` (seller)**
```json
// request
{ "reason": "sold_locally", "note": "Sold on the showroom floor this morning" }
// response (200): updated Order resource
```

### Error responses

- `409` when Order state prohibits the operation (e.g., cancel after ship, buy label on already-shipped Order)
- `422` on validation failures (missing fields, invalid reason code, preset not owned by store)
- `502` when EasyPost is unreachable or rejects the shipment; Order is unchanged, seller can retry
- `403` when requester isn't authorized for the Order (buyer not owner; seller not in store)

## Key Flows

### 1. Label purchase → ship (happy path)

1. Seller calls `POST /stores/{store}/orders/{order}/labels { parcel_preset_id }`.
2. `OrderFulfillmentController` validates request; `store.owner` middleware has already confirmed authorization.
3. `OrderFulfillmentService::purchaseLabel`:
   - Re-checks `Order.status ∈ {pending, processing}` (else throws 409).
   - Re-checks `preset.store_id === Order.store_id`.
   - Builds an EasyPost Shipment: `ship_from` from Store address, `ship_to` from Order shipping address snapshot, `parcel` from preset dimensions.
   - `LabelProvider::buyCheapest(shipment)` — fetches rates, filters to deliverable, picks minimum cost, buys the label, returns the tracker.
   - Inside a DB transaction: sets `tracker_id`, `tracking_number`, `tracking_url`, `carrier`, `service`, `label_url`, `label_purchased_at`, `shipped_at`, `status=shipped`.
   - Dispatches `OrderShipped` event.
4. `TransferFundsToStore` listener creates a Stripe Transfer on the connected account (amount = `seller_payout`), sets `stripe_transfer_id` and `transferred_at`.
5. `RecomputePurchaseStatusOnOrderChange` listener rolls up parent Purchase status (`pending → partially_shipped → shipped`).
6. Response returns the Order resource with `label_url` for the seller to print.

**Failure modes:**
- EasyPost rate/buy fails → no DB write, 502 to caller. Seller retries.
- EasyPost succeeds but DB commit fails → label was purchased but not recorded. Logged for admin reconciliation (rare, retriable).
- Stripe Transfer fails (listener) → Order remains `shipped`; `stripe_transfer_id` is null; `transferred_at` is null. A nightly reconciliation command retries failed Transfers. Buyer experience unaffected.

### 2. EasyPost tracker webhook → delivered

1. EasyPost signs and POSTs `tracker.updated` to `/v1/webhooks/easypost`.
2. `ShippingWebhookController::easypost` verifies the HMAC against `config('services.easypost.webhook_secret')`.
3. Checks Redis for `easypost:event:{id}` (24h TTL) — returns 200 without reprocessing if present.
4. Parses payload; locates Order by `tracker_id`. If not found, logs and returns 200 (webhooks are best-effort).
5. `TrackingService::applyUpdate(order, status)`:
   - `pre_transit | in_transit | out_for_delivery` → no state change, keep `shipped`.
   - `delivered` → `OrderFulfillmentService::markDelivered` sets status and `delivered_at`, dispatches `OrderDelivered`.
   - `return_to_sender | failure | error` → logs to Spatie Activity Log on the Order, dispatches `OrderDeliveryFailed` event for admin attention. Does not change Order status (admin reviews and decides whether to cancel + refund or reship).
6. `OrderDelivered` listener triggers Purchase status rollup.

### 3. Buyer cancel (pre-ship)

1. Buyer calls `POST /orders/{order}/cancel` (empty body).
2. Controller calls `CancellationService::buyerCancel(order, user)`.
3. Service authorizes `order.purchase.buyer_id === user.id`.
4. Service checks `order.status ∈ {pending, processing}` (else 409 `order_already_shipped`).
5. DB transaction:
   - Order: `status=cancelled`, `cancelled_by=buyer`, `cancellation_reason=buyer_requested`, `cancelled_at=now()`.
   - For each OrderItem: reload Item; set `status=active`, null `sold_at`, null `sold_to_user_id`. Scout reindexing happens via the existing Item model observer.
6. After the transaction commits, `CancellationService` calls Stripe to refund this Order's portion of the PaymentIntent: `refund_amount = subtotal + shipping_cost + tax_amount`. The `transfer_group` on the PaymentIntent lets this refund happen cleanly; there's no Transfer to reverse because the Order hadn't shipped. On refund failure, the service logs and returns success — a nightly reconciliation command retries failed refunds.
7. Dispatch `OrderCancelled`.
8. `RecomputePurchaseStatusOnOrderChange` listener updates parent Purchase status.

### 4. Seller cancel (pre-ship)

1. Seller calls `POST /stores/{store}/orders/{order}/cancel { reason, note? }`.
2. `store.owner` middleware authorizes.
3. `CancellationService::sellerCancel(order, store, reason, note)`:
   - Validates `reason ∈ {sold_locally, item_damaged, other}`.
   - Checks `order.status ∈ {pending, processing}`.
   - DB transaction: Order fields set as buyer cancel but `cancelled_by=seller`, `cancellation_reason=reason`. Each Item: `status=removed` (not relisted).
   - Spatie Activity Log entry with `reason` and `note` for dispute/audit trail.
4. Stripe refund (same as buyer cancel).
5. Dispatch `OrderCancelled`.

### 5. Ship-by reminder timeline

On `OrderPaid` (dispatched from Layer 4 webhook):
- `ship_by = paid_at + store_settings.processing_days`
- `Order.ship_by` persisted.
- Three delayed jobs dispatched:
  - `SendShipByReminderJob::dispatch($order)->delay(ship_by)` — fires at deadline. Re-loads Order; if `status ∉ {pending, processing}` no-op. Else dispatches `ShipByReminderDue` event.
  - `NotifyOrderDelayedJob::dispatch($order)->delay(ship_by->copy()->addDays(2))` — 2 days past deadline. Same no-op check. Dispatches `OrderDelayed` event.
  - `AutoCancelOverdueOrderJob::dispatch($order)->delay(ship_by->copy()->addBusinessDays(7))` — 7 business days past deadline. Re-loads, no-ops if shipped; else calls `CancellationService::systemCancel(order, 'ship_deadline_exceeded')`. Items relist (same path as buyer cancel). Dispatches `OrderAutoCancelled`.

Events are dispatched; Layer 5 does not attach notification listeners. Layer 6 will.

**Buyer UI** reads `is_delayed` (computed: `now() > ship_by + 2 days AND status ∈ {pending, processing}`). The delay banner renders regardless of whether the `NotifyOrderDelayedJob` fired — the UI doesn't depend on the job.

### 6. Dispute (minimal)

Stripe disputes apply to a whole charge, which in our model can span multiple Orders (multi-seller cart). V1 does not try to auto-attribute a dispute to a specific Order or OrderItem — that's part of Layer 8 (Admin) resolution. Layer 5 captures the dispute signal and blocks further platform-side movement on the affected Purchase until admin intervenes.

1. Stripe sends `charge.dispute.created` to the existing Layer 4 webhook.
2. Controller locates the Purchase by `payment_intent_id` and verifies the signed payload.
3. DB transaction:
   - `Purchase.disputed = true` (new boolean column added in this layer's Purchase migration).
   - Record dispute details (amount, reason, evidence deadline) in Spatie Activity Log attached to the Purchase.
4. Dispatches `PurchaseDisputed` event.
5. `TransferFundsToStore` listener (on `OrderShipped`) checks `Purchase.disputed` before creating a Transfer. If disputed, it skips the Transfer and marks the Order as pending-admin. This blocks all future payouts on the Purchase until admin resolves.
6. Transfers that already completed before the dispute arrived are NOT auto-reversed — reversing would be unfair to uninvolved sellers in the same Purchase, and we can't disambiguate which OrderItem the dispute targets. Admin handles manual reversal in Layer 8.
7. Dispute resolution (buyer vs seller win, partial refunds, Transfer re-send after platform wins the dispute) is Layer 8 (Admin).

**Migration addition:** `add_disputed_to_purchases_table` adds `disputed` (bool default false) to `purchases`.

## Buyer Tracking UI (Web)

Next.js 15 App Router, `(buyer)` route group.

### `/purchases` (extended from Layer 4)

- Purchase card expanded:
  - Rollup status string ("1 of 2 orders shipped")
  - `is_delayed` pill (amber) when any child Order is delayed
  - Total, purchase date, store count
- No inline actions — tap through to detail.

### `/purchases/[id]` (extended from Layer 4)

- Header: Purchase ID, date, grand total, shipping address
- Per-store Order card:
  - Store name and logo
  - Items (title, image thumb, price snapshot)
  - Order status pill (`pending | processing | shipped | delivered | cancelled`)
  - Fulfillment timeline (horizontal stepper): `Placed → Shipped → Delivered`, with timestamps where known
  - Tracking block (when shipped): carrier logo, tracking number as clickable `tracking_url`, "last updated" from `delivered_at` if delivered
  - Inline delay banner (when `is_delayed`): "Your order is delayed. Cancel for full refund." + Cancel button
  - Cancel link (when `status ∈ {pending, processing}` and not delayed): less prominent, opens confirmation dialog
  - Cancelled message (when cancelled): reason-specific copy
    - `buyer_requested` → "You cancelled this order. Refund processed."
    - `sold_locally` → "The store marked this item as unavailable. Full refund processed."
    - `item_damaged` → "The store could not fulfill this order. Full refund processed."
    - `ship_deadline_exceeded` → "This order was automatically cancelled because it wasn't shipped in time. Full refund processed."
    - `other` → "This order was cancelled by the store. Full refund processed."

### Data fetching

- TanStack Query hooks in `packages/shared/hooks/use-purchases.ts` (extended) and `use-order-cancel.ts` (new).
- Cancel mutation invalidates purchase list and detail queries.

### Styling

- Tailwind + shadcn/ui components (existing stack).
- Design tokens from `packages/design-tokens/` (existing).
- Light polish only — the visual design sprint is a separate tracked milestone between Layer 2 and Layer 3, and the full styling pass is Layer 10.

## Testing Strategy

| Area | Approach |
|---|---|
| `LabelProvider` contract | Interface with `EasyPostProvider` and `FakeLabelProvider`. Tests bind `FakeLabelProvider` via service container. |
| EasyPost real integration | `@group integration` tests using EasyPost test API key. Excluded from default `php artisan test`; run manually pre-release. |
| EasyPost webhooks | Feature tests with signed fixture payloads. Assert DB state, dispatched events (`Event::fake`), and idempotency (same event ID twice → no duplicate effect). |
| Stripe dispute webhook | Feature test with signed fixture. Assert Transfer freeze path and reverse path. |
| Delayed jobs | `Queue::fake`, `Bus::fake`, `Date::setTestNow`. Assert correct jobs dispatched with correct delays on `OrderPaid`; assert each job no-ops when Order state changed; assert auto-cancel fires full cancellation path. |
| Cancellation matrix | Table-driven feature test covering (buyer vs seller vs system) × (pre-ship vs shipped) × (with/without transfer sent). Asserts DB state, item status changes, refund call, event dispatch. |
| Transfer timing | Feature test: Layer 4 webhook handler no longer creates a Transfer. Label purchase triggers Transfer. Verify with `Http::fake` against Stripe. |
| `is_delayed` computation | Unit test on Order model: various `ship_by` and `status` combinations. |
| Buyer UI | Vitest + RTL for components. Playwright smoke test: login → purchases → detail → cancel → verify status updated. |
| Authorization | Policy tests: buyer cannot cancel another buyer's Order, seller cannot cancel another store's Order. |

## Scope Boundaries

### In Layer 5

- EasyPost label purchase (cheapest rate auto-selected)
- EasyPost tracker webhook handling
- Buyer + seller cancellation flows with reason codes
- Item relisting on buyer cancel, removal on seller cancel
- Ship-by reminder / delay / auto-cancel job pipeline
- Stripe Transfer timing moved to ship event
- Minimal Stripe dispute handler (flag + freeze/reverse Transfer)
- Buyer purchase tracking UI
- Ship-from address columns on Store + UI fields
- `store_parcel_presets` table + CRUD API
- `is_delayed` computed attribute

### NOT in Layer 5

- Seller dashboard UI (Layer 7)
- Email / push notification delivery — events are dispatched as integration seams (Layer 6)
- Return flow (no external forcing function; deferred)
- Full dispute UI / resolution workflow (Layer 8 Admin)
- Multi-parcel shipments (one Order = one label in V1)
- International shipping (V1 is US-only)
- POS integration for "sold locally" (future, master spec)
- Manual "mark as delivered" by buyer (rely on carrier webhooks)

## Risks and Mitigations

| Risk | Mitigation |
|---|---|
| Layer 4 webhook refactor breaks existing tests | Audit Layer 4 webhook tests first; update them alongside the refactor in the same task |
| EasyPost webhook signature edge cases | Use EasyPost's official SDK helpers where available; cover the verification path with fixture tests |
| Delayed jobs firing against deleted/mutated Orders | Every job reloads and checks state; no-ops are silent, not errors |
| Stripe Transfer failure on ship | Nightly reconciliation command (introduced here) retries failed Transfers; buyer experience unaffected |
| Business-day math drift across seasons | Carbon `addBusinessDays` is deterministic (no holidays); keep it simple, document the behavior |
| EasyPost local development without public URL | Document ngrok or Cloudflare Tunnel pattern in Shipping module README |
| `FakeLabelProvider` accidentally used in production | Bind via environment-driven service provider; production binds `EasyPostProvider`, dev/test binds `FakeLabelProvider` |

## Open Questions / Assumptions

- **Business-day definition:** US federal M-F, no holidays. Documented and can be upgraded later.
- **International shipping:** Out of scope. Ship-from `country` defaults to `US`; checkout shipping address validation will remain US-only (existing Layer 4 behavior).
- **Platform fee rate (15%):** Unchanged from Layer 4. Transfer amount = `seller_payout` which was computed at checkout and stored on the Order.
- **Dispute handling when dispute amount differs from charge:** V1 treats any dispute as full-charge. Partial disputes are rare in practice and can be handled manually via admin (Layer 8).
- **Webhook ordering:** EasyPost may deliver tracker updates out of order. Our state machine only transitions forward (`shipped → delivered`), and repeated `delivered` webhooks are idempotent.
