# Layer 11 — Seller Payouts & Financial Reporting (Design)

**Status:** Draft · 2026-05-11
**Predecessors:** Layer 4 (Cart/Checkout), Layer 5 (Fulfillment/Shipping), Layer 8 (Admin Console & Disputes — money-movement plan), Layer 10 (Returns & Proactive Refunds)
**Successors:** TBD (likely: reviews & ratings, or seller analytics)

---

## Purpose

Replace the marketplace's current per-order, immediate Stripe Connect transfer model with **aggregated biweekly payouts backed by an append-only ledger**. Three problems with the status quo motivate this:

1. **No buffer for refunds.** Today's flow transfers funds to the seller's Connect account at order capture. When a return refund fires, we issue both a Stripe refund (to the buyer) and a transfer reversal (clawing back from the seller's Connect balance). The reversal can fail if the seller has already withdrawn — which manifests as a Stripe debit on their bank account days later. A 14-day hold from delivery would catch the vast majority of returns *before* funds are released.

2. **No seller-facing financial surface.** Sellers can't answer "what will I be paid on Friday?" or "what did I earn last quarter?" without leaving the platform for the raw Stripe dashboard. EasyPost label costs (Layer 5 outbound, Layer 10 return shipping) are deducted from the seller's books *outside* of Stripe today, with no in-app visibility.

3. **Layer 10 left an explicit hole.** Plan 2 of returns recorded `easypost_shipment_cost_cents` on each return but flagged "doesn't deduct from payouts" as a future-infra item. This layer closes it.

Layer 11 introduces a `SellerLedger` (append-only) as the source of truth for what a seller has earned/owed, and a `Payout` model representing aggregated biweekly transfers. Refunds within the hold window become a simple debit entry on the ledger. Refunds after the hold window — when the original credit has already been bundled into a settled payout — require a Stripe transfer reversal in addition to the ledger debit, to claw funds back from the seller's Connect balance. (Today the buyer-return refund path does *not* call `reverseTransfer` at all; only dispute and admin force-refund paths do. Plan 3 extends the reversal logic to cover post-payout returns.)

## User model

- **Seller.** Mostly passive consumer of this layer. Checks dashboard widget for "available now" and "pending until [date]". Reviews per-period statements occasionally; opens past payout records during tax season or when reconciling against their bank. Cares deeply about *predictability* — payout day, payout amount, and what got deducted.
- **Admin.** Monitors aggregate outstanding balances across the marketplace, intervenes on failed transfers (Stripe Connect account on hold, debit failed, etc.), makes manual adjustments (goodwill credits, fee waivers, error corrections). Cadence: a few interventions per cycle once volume scales.
- **Buyer.** Not directly affected by this layer. Refund timing is unchanged from the buyer's perspective — they get their money back on the same schedule as today. The change is purely on the seller side of the ledger.

Implications:

- The ledger is the source of truth; balance reads always compute from it. No cached "available_balance" column on the store.
- Stripe Connect transfers become a side-effect of ledger state, not the primary event. The ledger writes happen first; transfers happen on schedule.
- Refund handling diverges by timing: within hold → ledger debit only; after hold → ledger debit + Stripe transfer reversal. Both paths converge to the same balance.

## Scope

### In scope

- **`SellerLedger` append-only model.** Every financial event — order earnings, refunds, label costs, payouts, manual adjustments — writes one row. No updates, no deletes. Balance = `SUM(amount_cents)` over the rows.
- **14-day hold from `delivered_at`.** Order earnings entries carry `available_at = delivered_at + 14 days`. Only entries with `available_at ≤ now` and `payout_id IS NULL` count toward the "available" balance. Pending earnings (still in hold) count toward "pending" balance.
- **Biweekly payout cron.** Configurable cadence (default: every other Sunday 9pm UTC); held in `config/payouts.php` so adjustment is a one-line change. Cron bundles available entries per store, creates a `Payout` record, calls `StripeService::createTransfer`, and writes a `payout_settled` debit entry once Stripe confirms.
- **`Payout` state machine.** `scheduled → in_flight → succeeded | failed | void`. Failed payouts retry with exponential backoff (3 attempts), then land in an admin queue. Voided payouts (admin action) return their entries to the available pool.
- **EasyPost label-cost reconciliation.** Outbound order labels (Layer 5) AND return labels billed to seller (Layer 10 ship-back path with `return_shipping_payer = seller`) write `label_cost_debit` entries immediately on label purchase. This closes Plan 2's explicit deferral.
- **Refund handling — dual path.**
  - **Within hold window** (refund before original credit is available): write `order_refunded` debit, no Stripe transfer reversal. The original credit entry's funds never moved to the seller's Connect balance, so there's nothing to reverse.
  - **After hold window** (rare — refund > 14 days after delivery, on an order whose credit was bundled into a settled payout): write `order_refunded` debit AND call `StripeService::reverseTransfer` to claw back from the seller's Stripe balance. Both ledger entries remain — the debit records the seller's owed amount; the reversal records the Stripe-side mechanism. **Note:** today's buyer-return flow does NOT call `reverseTransfer`; Plan 3 introduces the conditional. The current dispute/admin force-refund flows already call `reverseTransfer` and continue to do so.
- **Seller dashboard widget.** Two-line summary on the seller home: "Available: $X.XX (paying out [date])" and "Pending: $Y.YY (releasing as orders deliver + 14 days)".
- **Seller statement page** at `/seller/statements`. Default view: current cycle. Filterable by date range. Each row = one ledger entry with `description`, source link (order, return, label, payout), `amount`, `available_at`, `payout_id`. CSV export.
- **Seller payouts history page** at `/seller/payouts`. List of past Payout records: period start/end, gross, deductions, net, state, Stripe transfer link.
- **Stripe Connect account health surface.** Inline banner on seller dashboard when the connected account is `restricted`, `pending`, or `disabled`. Blocks the biweekly cron from attempting a transfer when account isn't `enabled`; surfaces the reason via Stripe's API (`requirements.currently_due`).
- **Admin: outstanding balances page** at `/admin/financials/balances`. Aggregate view of total pending + available across all stores; per-store drill-down.
- **Admin: failed payouts queue** at `/admin/financials/failed-payouts`. List of `Payout` rows in `failed` state; retry/void actions; admin can write a `resolution_note`.
- **Admin: manual adjustment endpoint.** `POST /v1/admin/stores/{store}/ledger-adjustments { type: 'credit' | 'debit', amount_cents, reason }`. Writes a ledger entry AND a `spatie/activitylog` row (`ledger.admin_adjustment`). Mirrors Layer 8 / Layer 10 admin-action patterns.
- **Notifications.** New `NotificationCategory::Payouts` case. Three notifications: `PayoutScheduledNotification` (24h before transfer), `PayoutSucceededNotification` (after Stripe confirms), `PayoutFailedNotification` (to seller + admins on failure).

### Out of scope (deferred to future layers)

- **Instant payout.** Stripe's paid feature; nice-to-have but a separate cost discussion.
- **Custom 1099-K generation.** Stripe Connect Express auto-generates 1099-Ks for connected accounts above the federal/state thresholds; lean on that. UI surfacing only: a help-text link on the statements page pointing sellers to Stripe's tax-form portal.
- **Stripe debit authorization for negative balances.** Today: if a seller goes negative (refunds > earnings post-payout), the rollover continues until they re-earn enough or admin manually adjusts. No auto-debit from their bank. Revisit when volume justifies the operational complexity.
- **Multi-currency.** USD only, matches Layer 4.
- **Reserve / holdback percentages** beyond the flat 14-day hold. Some marketplaces hold a sliding % as risk reserve; out of scope for v1.
- **Card payouts.** ACH (Stripe Connect default) only.
- **Per-store custom payout cadence.** Marketplace-wide cadence in v1 — every store gets paid on the same day. Per-store opt-in to a different cadence is a future feature if sellers request it.
- **Disputed-period payout pause.** Layer 8 disputes already pause individual order funds in flight; this layer doesn't re-engineer that. If a dispute opens on an order whose earnings are still in the hold window, the existing dispute flow takes precedence and that ledger entry won't release until the dispute resolves.
- **Tax withholding** (non-US sellers). USD-only marketplace; deferred.

## Data model

### `seller_ledger` (append-only)

| column | type | notes |
|---|---|---|
| `id` | uuid | |
| `store_id` | uuid FK | indexed |
| `entry_type` | string (enum) | see below |
| `direction` | string enum: `credit` \| `debit` | redundant with sign on `amount_cents` but cheap to denormalize for queries |
| `amount_cents` | unsigned int | always positive; `direction` determines sign in sums |
| `source_type` | nullable string | polymorphic class name (Order, OrderReturn, PostageLabel, Payout, ManualAdjustment) |
| `source_id` | nullable uuid | polymorphic id |
| `available_at` | timestamp | for credits: `event_time + 14d`; for debits: `event_time` |
| `payout_id` | nullable uuid FK → payouts | indexed; set when bundled into a Payout |
| `description` | string | human-readable for the statement UI |
| `created_at` | timestamp | event time; immutable |

Index: `(store_id, available_at, payout_id)` — primary read pattern is "available balance for store X right now."

Constraint: `amount_cents > 0`, `direction IN ('credit', 'debit')`, no `updated_at` column (append-only by convention; PHPDoc + a migration comment make the intent explicit).

### `entry_type` enum values

| value | direction | source | written by |
|---|---|---|---|
| `order_earned` | credit | Order | OrderTransitioner when state advances to `delivered` |
| `order_refunded` | debit | OrderReturn | ReturnRefundIssuer (Plan 1-3) |
| `label_cost_debit` | debit | PostageLabel | EasyPostProvider after `buyCheapestLabel` succeeds |
| `payout_settled` | debit | Payout | PayoutService after Stripe transfer confirms (offsets the bundled entries that just paid out) |
| `adjustment_credit` | credit | ManualAdjustment | Admin endpoint |
| `adjustment_debit` | debit | ManualAdjustment | Admin endpoint |

Future entry types are additive (e.g., `chargeback_debit` if Layer 8 dispute outcomes ever need their own type instead of `order_refunded`).

### `payouts`

| column | type | notes |
|---|---|---|
| `id` | uuid | |
| `store_id` | uuid FK | indexed |
| `period_start` / `period_end` | timestamps | the 2-week window being settled |
| `scheduled_for` | timestamp | when the cron will pick it up |
| `gross_cents` | unsigned int | sum of credit entries in this payout |
| `debits_cents` | unsigned int | sum of debit entries in this payout (refunds + label costs) |
| `net_cents` | int | signed; equals gross − debits |
| `state` | string enum | `scheduled` \| `in_flight` \| `succeeded` \| `failed` \| `void` |
| `stripe_transfer_id` | nullable string | populated after `StripeService::createTransfer` returns |
| `transferred_at` | nullable timestamp | success timestamp from Stripe webhook |
| `failed_at` | nullable timestamp | failure timestamp |
| `failure_reason` | nullable text | Stripe error message |
| `retries` | unsigned int | attempt count |
| `created_at` / `updated_at` | timestamps | |

Constraint: `net_cents = gross_cents - debits_cents` (enforced in service, not DB).

State transitions:
- `scheduled` → `in_flight` (cron picks it up, calls Stripe)
- `in_flight` → `succeeded` (Stripe transfer webhook arrives)
- `in_flight` → `failed` (Stripe API error or webhook failure)
- `failed` → `in_flight` (retry; max 3 attempts)
- `scheduled` → `void` (admin cancels before in_flight; releases bundled entries)
- `succeeded`, `void`: terminal

## Money flow walkthrough

**Happy path — order completes, no refund:**

1. Buyer pays at checkout → `Order` created, `seller_payout` precomputed (storeSubtotal − 15% fee). *No ledger entry yet.*
2. Seller fulfills, marks order `delivered` → `OrderTransitioner` writes `order_earned` credit; `available_at = delivered_at + 14 days`.
3. EasyPost label purchased → `EasyPostProvider` writes `label_cost_debit` (negative amount on seller's books).
4. 14 days pass. The `order_earned` entry's `available_at` is now in the past.
5. Biweekly cron fires. Pulls all `WHERE store_id = X AND available_at ≤ now AND payout_id IS NULL` entries. Sum = net payout for that store. Creates `Payout` row in `scheduled`, sets `payout_id` on the bundled entries.
6. Cron worker advances `Payout` to `in_flight`, calls `StripeService::createTransfer`. Writes `payout_settled` entry equal to the net amount (so future balance reads net to zero for the paid-out period).
7. Stripe Connect webhook confirms transfer → `Payout` → `succeeded`. Notification fires.

**Refund within hold window:**

1. Order delivered, `order_earned` credit written with `available_at = delivered_at + 14d`.
2. Day 5 post-delivery: buyer files return, seller approves, return tracker fires `delivered`, refund issues.
3. `ReturnRefundIssuer` writes `order_refunded` debit immediately. `available_at = now`. *No Stripe transfer reversal* — the original credit hadn't paid out yet (still in hold).
4. Stripe refund hits the buyer's card (normal Stripe refund, not a transfer reversal).
5. At cron time, the bundle for that store includes both entries — they net to zero (modulo restocking fee). Either no payout is created (net ≤ 0 for that store), or the refund offsets other orders and the cycle proceeds.

**Refund after hold window (rare):**

1. Day 20 post-delivery: late return. The `order_earned` was already paid out on day 15 (assuming a payout cycle aligned that way).
2. `ReturnRefundIssuer` writes `order_refunded` debit. Plan 3 adds the conditional `StripeService::reverseTransfer` call (gated on the original credit entry's `payout_id` being non-null — i.e., funds already shipped to seller) to claw back from the seller's Stripe balance.
3. The ledger now has a debit that reduces the next payout's net amount. If the next cycle is negative for that store: skip the payout, roll over.

*Today's behavior — to be updated in Plan 3:* `ReturnRefundIssuer::issue` only calls `StripeService::refundForOrder` (refunds the buyer's card), never `reverseTransfer`. Late returns therefore currently leave a hole: the buyer is refunded but the seller's payout already covered the original sale, so the platform absorbs the loss. Plan 3 closes this gap by adding the reverseTransfer call when the original credit's `payout_id` is non-null.

**Manual adjustment:**

1. Admin invokes `POST /v1/admin/stores/{store}/ledger-adjustments { type, amount_cents, reason }`.
2. Endpoint writes `adjustment_credit` or `adjustment_debit` entry with `available_at = now`, source pointing at a `ManualAdjustment` record (which holds the reason + admin id).
3. `spatie/activitylog` row written: `log_name = 'admin'`, `description = 'ledger.admin_adjustment'`.

## Invariants & edge cases

- **Append-only.** Once written, a ledger entry is never updated or deleted. Corrections happen by writing offsetting entries.
- **Idempotency.** The cron must be restart-safe. The "bundle entries into a Payout" step uses `SELECT ... FOR UPDATE` + an atomic `Payout::create` + `UPDATE ledger_entries SET payout_id = ?` inside one transaction. Retries find the existing `scheduled` Payout and resume.
- **Negative balance rollover.** If a store's net for a cycle is ≤ 0, no Payout is created. Debit entries remain unpaid (no `payout_id`), and the next cycle reconsiders. Admin sees the negative balance on the dashboard.
- **Connect account health.** Before calling `StripeService::createTransfer`, the cron checks the seller's Connect account status via Stripe's API. If `account.payouts_enabled === false` OR `account.requirements.disabled_reason !== null`: skip this store's payout, mark the `Payout` `void`, return entries to the pool, notify seller via `PayoutFailedNotification` with the Stripe-provided reason.
- **Disputed orders.** If an order has an open Layer 8 dispute, the existing dispute flow has already adjusted the seller's payout downstream. The ledger entry for that order's `order_earned` either won't be written (if disputed at capture time) or gets offset by a separate debit. The dispute mechanism is the source of truth for dispute-related funds; ledger reflects the outcome.
- **EasyPost label refunds.** If a seller voids a purchased label within EasyPost's refund window, EasyPost refunds the label cost. v1: this writes a counter-entry (`adjustment_credit` for `label_refund`) via an admin endpoint — automated EasyPost label-refund webhook handling is deferred.

## Authorization & audit

- **Seller endpoints** (`/seller/statements`, `/seller/payouts`, dashboard widget): owner-only (mirrors Layer 7 seller-dashboard pattern). Bearer token + owner-id check on the store.
- **Admin endpoints** (`/admin/financials/...`, ledger-adjustments): admin role required. Layer 8's admin middleware reused.
- **All admin actions** write a `spatie/activitylog` row. New log entries:
  - `ledger.admin_adjustment` (manual credit/debit)
  - `payout.retried`
  - `payout.voided`
  - `payout.resolution_noted` (admin comment on failed payout)
- **No buyer-facing surfaces** in this layer. Buyers see refunds via the existing Layer 10 return UI; the underlying ledger writes are invisible to them.

## API surfaces

| method | path | purpose |
|---|---|---|
| GET | `/v1/seller/ledger` | paginated ledger entries for the authenticated seller's store |
| GET | `/v1/stores/{store}/balance` | `{ available_cents, pending_cents, next_payout_date }` (matches existing store-scoped seller endpoint pattern, e.g. `/v1/stores/{store}/dashboard/metrics`) |
| GET | `/v1/seller/payouts` | paginated payout history |
| GET | `/v1/seller/payouts/{payout}` | single payout detail with bundled entries |
| GET | `/v1/seller/statements/export.csv` | downloadable CSV of ledger for a date range |
| GET | `/v1/admin/financials/balances` | aggregate balances across all stores |
| GET | `/v1/admin/financials/payouts` | filterable payout queue (state, store) |
| POST | `/v1/admin/payouts/{payout}/retry` | retry a failed payout |
| POST | `/v1/admin/payouts/{payout}/void` | cancel a scheduled or failed payout |
| POST | `/v1/admin/stores/{store}/ledger-adjustments` | manual credit/debit |

## UI surfaces

- **Seller home** — adds a "Balance" widget at the top of `/seller`. Shows available, pending, and next payout date. Click-through to `/seller/statements`.
- **`/seller/statements`** — date-range picker, ledger entries table, CSV download. New nav item under "Seller" section.
- **`/seller/payouts`** — list of past payouts with state badges + Stripe transfer link (opens Stripe dashboard for cross-reference). New nav item.
- **Stripe Connect health banner** — shows on top of all seller pages when account is unhealthy; click-through to Stripe-hosted onboarding URL.
- **`/admin/financials`** — new admin nav section with two tabs: "Balances" (aggregate view) and "Payouts" (queue + failed). Mirrors `/admin/orders` layout.
- **Admin store detail page** (Layer 8) gains a "Ledger" tab — chronological entries + a "New adjustment" button opening a small modal.

## Plan breakdown

Layer 11 splits into **three plans**, each ~3-5 phases:

### Plan 1 — Ledger Foundation

`SellerLedger` model + migration + factory; `entry_type` + `direction` enums; append-only repository pattern (no Eloquent `update` / `delete` allowed at the model level — override with `throw`). Hook into existing event sources:
- `OrderTransitioner` writes `order_earned` when state advances to `delivered`
- `ReturnRefundIssuer` (Plan 1-3 of Layer 10) writes `order_refunded`
- `EasyPostProvider::buyCheapestLabel` writes `label_cost_debit`
- Stub `payout_settled` and `adjustment_*` types (no writers yet — Plans 2/3 fill those in)

API: `GET /v1/seller/balance` returning `{ available_cents, pending_cents, next_payout_date }`. Dashboard widget consuming it. No Payout model yet — `next_payout_date` is computed from the cron schedule constant. No mutations yet — just observability of the ledger.

**Acceptance:** seller home shows accurate available/pending split; placing a new order → delivery cascade results in a `pending → available` transition exactly 14 days post-delivery (visible by changing system time in tests).

### Plan 2 — Payouts & Cron

`Payout` model + migration + factory; state machine; `PayoutService::scheduleForCycle()` and `::executeScheduled()`. Biweekly Laravel scheduled job (`app/Console/Commands/...` + `routes/console.php`); cadence held in `config/payouts.php`.

`StripeService::createTransfer` integration; webhook handling (Stripe Connect transfer success/fail events); retry job with exponential backoff (max 3 attempts); `Payout` state transitions.

Notifications: `PayoutScheduledNotification`, `PayoutSucceededNotification`, `PayoutFailedNotification` (recipient: seller + admin via `AdminRecipients`).

API: `GET /v1/seller/payouts`, `GET /v1/seller/payouts/{payout}`. Seller `/seller/payouts` page.

**Acceptance:** test cycle (advance time by 14d, fire cron) produces a `Payout` per store with positive available balance; Stripe test transfer succeeds; ledger gains `payout_settled` entry; seller payouts page shows the new record.

### Plan 3 — Statements + Admin

Seller statements page (`/seller/statements`) with date-range picker, ledger entry table, CSV export. Connect account health surface (banner component + cron pre-check). **Late-refund reverseTransfer integration:** extend `ReturnRefundIssuer::issue` to call `StripeService::reverseTransfer` when the original `order_earned` credit's `payout_id IS NOT NULL` (funds already shipped to seller). Audit Layer 8 dispute and Layer 10 admin force-refund paths — they already call `reverseTransfer` and should remain unchanged. Add regression tests covering both within-hold (debit only) and after-hold (debit + reversal) cases.

Admin pages: `/admin/financials/balances`, `/admin/financials/payouts` (with retry / void). `ManualAdjustment` model + endpoint + admin store-detail "Ledger" tab.

Activity log entries for all admin actions.

**Acceptance:** admin can view aggregate marketplace balance, drill into a store, post a manual adjustment that immediately reflects in the seller's available balance; failed payouts can be retried via the queue; CSV export for any date range matches the on-screen statement table exactly.

## Open items (flag during planning)

- **Cron anchor for biweekly.** Plan 2 needs a concrete answer for "every other Sunday starting when?" — choosing the anchor week affects which orders land in which cycle. Probably anchor on the marketplace's launch week.
- **Existing per-order transfers.** The current `TransferFundsToStore` job at capture time needs to be removed (or repurposed) as part of Plan 2. Plan 2 should audit all current `StripeService::createTransfer` call sites and confirm only the new cron-driven path remains.
- **Connect account state polling vs webhook.** Stripe sends `account.updated` webhooks when KYC state changes. Plan 2 should add a handler that caches `payouts_enabled` + `disabled_reason` on the Store model so the dashboard banner doesn't require a Stripe API call on every page load.
- **Ledger entry on order capture vs delivery.** This spec says `order_earned` writes at `delivered`. Some marketplaces write at capture (paid) and use a separate "available" flag. Delivery-anchor is simpler and matches the 14-day-hold-from-delivery decision; documenting here so it isn't second-guessed during Plan 1 implementation.
