# Layer 11 Plan 2: Payouts & Cron

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Turn the Plan 1 ledger into an actual money-moving machine. Plan 2 introduces a `Payout` aggregate (one row per store per biweekly cycle), a `PayoutService` that bundles available ledger entries into a `Payout`, a scheduled artisan command (`payouts:run-cycle`) that fires on every cycle boundary, a Stripe transfer call against the seller's Connect account, webhook handlers for `transfer.paid` / `transfer.failed` / `account.updated`, a retry command for transient failures, three new payout notifications, two new read-only API endpoints, and a seller-facing `/seller/payouts` history page. The Stripe two-step (synchronous `Transfer::create` → asynchronous `transfer.paid` webhook) is modelled explicitly through the `scheduled → in_flight → succeeded | failed | void` state machine. Acceptance: with system time advanced past a cycle boundary, `payouts:run-cycle` creates one `Payout` per store with positive available balance, calls Stripe (faked in tests via `StripeService` mock), marks the row `in_flight`, and a faked `transfer.paid` webhook advances it to `succeeded` and writes the offsetting `payout_settled` debit on the ledger. A second failed transfer advances to `failed`, increments `retries`, and the hourly `payouts:retry-failed` command picks it up; after 3 attempts the row stays `failed`, `PayoutFailedNotification` fires to seller + admins. Sellers see their full history at `/seller/payouts` with state badges and a deep-link to the corresponding Stripe transfer.

**Architecture:** (1) **Schema** — one migration creates `payouts` (UUID PK, `store_id` FK, `period_start`/`period_end`/`scheduled_for` timestamps, three integer cents columns, state enum, nullable `stripe_transfer_id`, nullable `transferred_at` / `failed_at` / `failure_reason`, unsigned `retries`); a second migration backfills the FK constraint on `seller_ledger.payout_id → payouts.id` that Plan 1 left unconstrained. New `PayoutState` enum at `App\Support\Enums\PayoutState`. Plan 2 also adds two columns to `stores` — `payouts_enabled` boolean + `disabled_reason` nullable text — cached from Stripe's `account.updated` webhook so the cron's pre-check is a local read. (2) **Writer extension** — one new method on the existing `LedgerWriter`: `recordPayoutSettled(Payout)` writes a `payout_settled` debit equal to `$payout->net_cents` with `source = $payout`, `available_at = now()`. Plan 1 already shipped the `LedgerEntryType::PayoutSettled` enum stub. (3) **`PayoutService`** at `App\Modules\Ledger\Services\PayoutService` — two methods. `scheduleForCycle(CarbonImmutable $cycleDate)` iterates all stores, locks each store's available ledger window with `SELECT ... FOR UPDATE`, bundles entries into a freshly-created `Payout` row, returns the collection. Idempotent on repeat calls because it checks for an existing `Payout` whose `period_end = $cycleDate` before bundling. `executeScheduled(Payout $payout)` advances `scheduled → in_flight` inside a transaction, then calls `StripeService::createTransfer` outside the transaction (Stripe is a side-effect; never hold a DB transaction across a network call). The Stripe response's `id` lands in `stripe_transfer_id`; failure flips the row to `failed` with `failure_reason` populated and `retries` incremented. **Note:** synchronous Stripe success only advances to `in_flight`; the `transfer.paid` webhook is what advances to `succeeded`. (4) **Cron** — `payouts:run-cycle` command at `App\Modules\Ledger\Console\RunPayoutCycleCommand` registered in `bootstrap/app.php`'s `withCommands` + scheduled daily in `withSchedule`. The command is a no-op on non-cycle-boundary days (compares `now()` against `PayoutSchedule::isCycleBoundary($now)`). A second command `payouts:retry-failed` runs hourly and re-fires `executeScheduled` for any `Payout` with `state = failed AND retries < 3 AND failed_at < now - retries * 1 hour` (1h, 2h, 3h backoff). (5) **Webhook handlers** — extend `CheckoutController::webhook` with three new branches: `transfer.paid` (looks up `Payout` by `stripe_transfer_id`, advances to `succeeded`, writes `payout_settled` ledger entry, fires `PayoutSucceededNotification`), `transfer.failed` (advances to `failed`, fires retry), `account.updated` (looks up `Store` by `stripe_connect_id`, mutates `payouts_enabled` + `disabled_reason`). (6) **Stripe extension** — one new method on `StripeService::createMarketplacePayout(Store $store, int $amountCents, string $idempotencyKey): Transfer` that wraps `Transfer::create` with the right shape (no `transfer_group`, an explicit `destination = store.stripe_connect_id`, and the payout UUID as `idempotency_key`). The existing `createTransfer` signature stays — it's still used by Layer 4 capture-time flow until Plan 3 retires it (out of scope for Plan 2). (7) **Notifications** — three new classes following Plan 1 pattern: `PayoutScheduledNotification`, `PayoutSucceededNotification`, `PayoutFailedNotification`. All in `NotificationCategory::Payouts` (already exists). (8) **Endpoints** — `GET /v1/stores/{store}/payouts` (paginated list, `store.owner` middleware, mirrors Plan 1's `/balance` precedent) and `GET /v1/stores/{store}/payouts/{payout}` (single payout with eager-loaded bundled ledger entries). OpenAPI updated; types regenerated; new `payouts` api-client module. (9) **Frontend** — `useStorePayouts` + `useStorePayout` TanStack hooks; `/seller/payouts` list page with state-badge column + Stripe transfer link; new nav entry between "Orders" and "Returns" in the seller sidebar.

**Tech Stack:** Laravel 12, PHPUnit class-based feature tests under `api/tests/Feature/Payouts/` (mirrors `tests/Feature/Returns/` and `tests/Feature/Ledger/` precedents), Postgres 17 with `SELECT ... FOR UPDATE` row locks, Stripe PHP SDK (existing `StripeService` extended), `ramsey/uuid`-backed `HasUuid` trait, `Carbon::setTestNow` for time-shifted tests, OpenAPI → `openapi-typescript`, Next.js 15 App Router, TanStack Query v5, Tailwind, Vitest + React Testing Library.

**Spec:** `docs/superpowers/specs/2026-05-11-layer-11-payouts-design.md`

**Prerequisites:**
- API head: `f36d8b3` (Plan 1 fully shipped: `201b8b2 feat(payouts): seller ledger foundation + balance read`, `782658c fix(payouts): uuid morph columns on seller_ledger`, `f36d8b3 fix(payouts): use selectRaw + alias instead of value(DB::raw())`). **761 tests passing.** No `payouts` table or model exists; `seller_ledger.payout_id` column exists but is unconstrained.
- Web head: `1e16297` (Plan 1 widget shipped: `1e16297 feat(payouts): seller balance widget on dashboard home`). **249 tests passing, 1 skipped.**
- Plan 1 components Plan 2 builds on:
  - `App\Modules\Ledger\Services\LedgerWriter` at `api/app/Modules/Ledger/Services/LedgerWriter.php` — Plan 2 adds one method (`recordPayoutSettled`).
  - `App\Modules\Ledger\Services\BalanceService` at `api/app/Modules/Ledger/Services/BalanceService.php` — Plan 2 reuses the `selectRaw('… as total')->value('total')` Postgres-safe aggregate pattern.
  - `App\Modules\Ledger\Support\PayoutSchedule` at `api/app/Modules/Ledger/Support/PayoutSchedule.php` — Plan 2's cron uses `PayoutSchedule::nextCycleDate` + adds a sibling `isCycleBoundary(CarbonImmutable): bool` helper.
  - `config/payouts.php` at `api/config/payouts.php` — `cycle_anchor` + `cycle_days` already shipped; Plan 2 reuses both. Anchor is currently `2026-05-17 00:00:00 UTC`.
  - `App\Models\SellerLedger` at `api/app/Models/SellerLedger.php` — append-only model with `update()`/`delete()` overrides that throw.
  - `App\Support\Enums\LedgerEntryType::PayoutSettled` enum case — Plan 1 shipped the stub; Plan 2 finally writes entries with this type.
  - `App\Models\Store::stripe_connect_id` — already present (Layer 4); Plan 2 adds `payouts_enabled` + `disabled_reason` columns. Verified via `grep -n stripe_connect_id api/app/Models/Store.php`.
  - `App\Modules\Checkout\Services\StripeService` at `api/app/Modules/Checkout/Services/StripeService.php` — Plan 2 extends with `createMarketplacePayout(Store, int, string)`. The existing `createTransfer(int $amount, string $destination, string $transferGroup)` stays untouched.
  - `App\Modules\Checkout\Controllers\CheckoutController::webhook` at `api/app/Modules/Checkout/Controllers/CheckoutController.php` — the existing single webhook endpoint `POST /stripe/webhook` (registered in `app/Modules/Checkout/routes.php`). Plan 2 extends its event-dispatch flow; no new route.
  - `App\Modules\Notifications\Services\AdminRecipients::all(): Collection<User>` — reused by `PayoutFailedNotification`.
  - `App\Support\Enums\NotificationCategory::Payouts` — already exists.
- Periodic-command precedent: `App\Modules\Orders\Console\ReconcileFailedMoneyMovements` (signature `orders:reconcile-money`) is registered via `withCommands([ReconcileFailedMoneyMovements::class])` and scheduled via `$schedule->command('orders:reconcile-money')->hourly()` in `bootstrap/app.php`. **Plan 2 follows the same pattern.**

**Successor plan:** `2026-XX-XX-layer-11-statements-and-admin.md` (Plan 3) — `/seller/statements` page with date-range picker + CSV export; Connect account health banner (consumes the `payouts_enabled` field that Plan 2 caches); late-refund `reverseTransfer` integration extension to `ReturnRefundIssuer::issue` gated on the original `order_earned` credit's `payout_id IS NOT NULL`; admin pages `/admin/financials/balances` + `/admin/financials/payouts` with retry / void actions; `ManualAdjustment` model + admin endpoint + admin store-detail "Ledger" tab; activity log rows (`payout.retried`, `payout.voided`, `payout.resolution_noted`, `ledger.admin_adjustment`); migration to add `voided_by_admin_id` + `voided_at` to `payouts` for the admin-void action.

---

## Phase A — Schema (`payouts` table + state enum + FK backfill + Store Connect health columns)

### Task 1: `PayoutState` enum + `payouts` migration + model + factory

**Files:**
- Create: `api/app/Support/Enums/PayoutState.php`
- Create: `api/database/migrations/2026_05_11_200001_create_payouts_table.php`
- Create: `api/database/migrations/2026_05_11_200002_add_payout_id_fk_to_seller_ledger.php`
- Create: `api/app/Models/Payout.php`
- Create: `api/database/factories/PayoutFactory.php`
- Update: `api/app/Models/SellerLedger.php` (add `payout(): BelongsTo` relation)
- Update: `api/app/Models/Store.php` (add `payouts(): HasMany` relation)
- Test: `api/tests/Feature/Payouts/PayoutSchemaTest.php`

`PayoutState`:

```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum PayoutState: string
{
    case Scheduled = 'scheduled';
    case InFlight = 'in_flight';
    case Succeeded = 'succeeded';
    case Failed = 'failed';
    case Void = 'void';

    public function isTerminal(): bool
    {
        return $this === self::Succeeded || $this === self::Void;
    }
}
```

`payouts` migration:

```php
public function up(): void
{
    Schema::create('payouts', function (Blueprint $t) {
        $t->uuid('id')->primary();
        $t->foreignUuid('store_id')->constrained('stores');
        $t->timestamp('period_start');
        $t->timestamp('period_end');
        $t->timestamp('scheduled_for');
        $t->unsignedInteger('gross_cents')->default(0);
        $t->unsignedInteger('debits_cents')->default(0);
        $t->integer('net_cents')->default(0);              // signed — could theoretically be negative
        $t->string('state', 16);                            // PayoutState
        $t->string('stripe_transfer_id')->nullable();
        $t->timestamp('transferred_at')->nullable();
        $t->timestamp('failed_at')->nullable();
        $t->text('failure_reason')->nullable();
        $t->unsignedInteger('retries')->default(0);
        $t->timestamps();

        $t->index(['store_id', 'state']);
        $t->index('scheduled_for');
        $t->index('stripe_transfer_id');                    // webhook lookup
    });

    // Postgres CHECK constraints — defence-in-depth.
    DB::statement(
        "ALTER TABLE payouts ADD CONSTRAINT payouts_state_valid "
        ."CHECK (state IN ('scheduled', 'in_flight', 'succeeded', 'failed', 'void'))"
    );
    DB::statement(
        "ALTER TABLE payouts ADD CONSTRAINT payouts_net_math "
        ."CHECK (net_cents = gross_cents - debits_cents)"
    );
}

public function down(): void
{
    Schema::dropIfExists('payouts');
}
```

> **Plan note (`net_cents` as signed `integer`, not `unsignedInteger`):** A payout net should always be ≥ 0 — if a store's net is ≤ 0 the cron does NOT create a `Payout` at all (negative balance rollover; Task 6 details). But the column is `integer` (signed) rather than `unsignedInteger` because the CHECK constraint `net_cents = gross_cents - debits_cents` is the actual invariant; if a future bug ever wrote `debits > gross` we'd rather see the bad row than have Postgres swallow it as a wraparound. `gross_cents` and `debits_cents` stay unsigned because they're component sums always positive by construction.

> **Plan note (CHECK constraint `net_cents = gross_cents - debits_cents`):** Same defence-in-depth pattern Plan 1 used for `seller_ledger.amount_cents > 0`. Catches any future writer that forgets to recompute `net_cents` after editing a component column. Cheap.

> **Plan note (timestamp resolution):** Postgres `TIMESTAMP` defaults to microsecond precision. Laravel's `->timestamp()` blueprint method maps to it; `Carbon` round-trips correctly. The Plan 1 `seller_ledger.available_at` precedent already uses `->timestamp()` — Plan 2 matches.

FK-backfill migration `2026_05_11_200002_add_payout_id_fk_to_seller_ledger.php`:

```php
public function up(): void
{
    Schema::table('seller_ledger', function (Blueprint $t) {
        $t->foreign('payout_id')->references('id')->on('payouts')->nullOnDelete();
    });
}

public function down(): void
{
    Schema::table('seller_ledger', function (Blueprint $t) {
        $t->dropForeign(['payout_id']);
    });
}
```

> **Plan note (FK backfill in a separate migration file):** Plan 1's `2026_05_11_100001_create_seller_ledger_table.php` deliberately left `payout_id` unconstrained because `payouts` didn't exist yet. Plan 2 ships a second migration whose sole job is the FK addition. **Don't fold this into the `create_payouts_table` migration** — keeping it separate makes the relationship obvious in `git log` and gives the `down()` path a clean one-step rollback if the FK ever causes trouble in production.

> **Plan note (`nullOnDelete()` semantics):** Plan 3 will likely never delete `Payout` rows (admin void is a state transition, not a delete). But if a destructive migration or test cleanup ever drops a payout, the seller_ledger entries should NOT be cascade-deleted — they're append-only. `nullOnDelete()` is the safe fallback; the ledger entries detach and surface in the next cycle as "unpaid" again.

`Payout` model:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use App\Support\Enums\PayoutState;
use App\Support\Traits\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

/**
 * @property string $id
 * @property string $store_id
 * @property \Carbon\Carbon $period_start
 * @property \Carbon\Carbon $period_end
 * @property \Carbon\Carbon $scheduled_for
 * @property int $gross_cents
 * @property int $debits_cents
 * @property int $net_cents
 * @property PayoutState $state
 * @property string|null $stripe_transfer_id
 * @property \Carbon\Carbon|null $transferred_at
 * @property \Carbon\Carbon|null $failed_at
 * @property string|null $failure_reason
 * @property int $retries
 */
class Payout extends Model
{
    use HasFactory;
    use HasUuid;

    protected $fillable = [
        'store_id', 'period_start', 'period_end', 'scheduled_for',
        'gross_cents', 'debits_cents', 'net_cents',
        'state', 'stripe_transfer_id', 'transferred_at',
        'failed_at', 'failure_reason', 'retries',
    ];

    protected function casts(): array
    {
        return [
            'period_start' => 'datetime',
            'period_end' => 'datetime',
            'scheduled_for' => 'datetime',
            'transferred_at' => 'datetime',
            'failed_at' => 'datetime',
            'state' => PayoutState::class,
            'gross_cents' => 'integer',
            'debits_cents' => 'integer',
            'net_cents' => 'integer',
            'retries' => 'integer',
        ];
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    public function ledgerEntries(): HasMany
    {
        return $this->hasMany(SellerLedger::class, 'payout_id');
    }
}
```

`PayoutFactory`:

```php
public function definition(): array
{
    $now = now();

    return [
        'store_id' => Store::factory(),
        'period_start' => $now->copy()->subDays(14),
        'period_end' => $now,
        'scheduled_for' => $now,
        'gross_cents' => 10000,
        'debits_cents' => 0,
        'net_cents' => 10000,
        'state' => PayoutState::Scheduled,
        'stripe_transfer_id' => null,
        'transferred_at' => null,
        'failed_at' => null,
        'failure_reason' => null,
        'retries' => 0,
    ];
}

public function inFlight(): self { return $this->state(['state' => PayoutState::InFlight, 'stripe_transfer_id' => 'tr_test_'.fake()->bothify('??##??##')]); }
public function succeeded(): self { return $this->state(['state' => PayoutState::Succeeded, 'stripe_transfer_id' => 'tr_test_'.fake()->bothify('??##??##'), 'transferred_at' => now()]); }
public function failed(int $retries = 1): self { return $this->state(['state' => PayoutState::Failed, 'failed_at' => now(), 'failure_reason' => 'Test failure', 'retries' => $retries]); }
public function void(): self { return $this->state(['state' => PayoutState::Void]); }
```

- [ ] **Step 1: Write the failing test** — `PayoutSchemaTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Payouts;

use App\Models\Payout;
use App\Models\SellerLedger;
use App\Models\Store;
use App\Support\Enums\PayoutState;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

class PayoutSchemaTest extends TestCase
{
    use RefreshDatabase;

    public function test_payouts_table_has_expected_columns(): void
    {
        $this->assertTrue(Schema::hasTable('payouts'));
        foreach ([
            'id', 'store_id', 'period_start', 'period_end', 'scheduled_for',
            'gross_cents', 'debits_cents', 'net_cents', 'state',
            'stripe_transfer_id', 'transferred_at', 'failed_at',
            'failure_reason', 'retries', 'created_at', 'updated_at',
        ] as $col) {
            $this->assertTrue(Schema::hasColumn('payouts', $col), "payouts.$col missing");
        }
    }

    public function test_payout_state_enum_cases(): void
    {
        $this->assertEqualsCanonicalizing(
            ['scheduled', 'in_flight', 'succeeded', 'failed', 'void'],
            array_map(fn ($c) => $c->value, PayoutState::cases()),
        );
    }

    public function test_state_check_constraint_rejects_invalid_value(): void
    {
        $store = Store::factory()->create();
        $this->expectException(QueryException::class);
        \DB::table('payouts')->insert([
            'id' => (string) \Illuminate\Support\Str::uuid(),
            'store_id' => $store->id,
            'period_start' => now(),
            'period_end' => now(),
            'scheduled_for' => now(),
            'gross_cents' => 0, 'debits_cents' => 0, 'net_cents' => 0,
            'state' => 'sideways',
            'created_at' => now(), 'updated_at' => now(),
        ]);
    }

    public function test_net_math_check_constraint_rejects_inconsistent_net(): void
    {
        $store = Store::factory()->create();
        $this->expectException(QueryException::class);
        Payout::factory()->create([
            'gross_cents' => 1000,
            'debits_cents' => 200,
            'net_cents' => 999,  // not 1000 - 200 = 800
        ]);
    }

    public function test_seller_ledger_payout_id_fk_is_enforced(): void
    {
        $store = Store::factory()->create();
        $this->expectException(QueryException::class);
        SellerLedger::factory()->create([
            'store_id' => $store->id,
            'payout_id' => '00000000-0000-0000-0000-000000000000',  // no such Payout
        ]);
    }

    public function test_seller_ledger_payout_id_can_be_null(): void
    {
        $entry = SellerLedger::factory()->create(['payout_id' => null]);
        $this->assertNull($entry->payout_id);
    }

    public function test_payout_belongs_to_store(): void
    {
        $payout = Payout::factory()->create();
        $this->assertInstanceOf(Store::class, $payout->store);
    }

    public function test_payout_has_many_ledger_entries(): void
    {
        $payout = Payout::factory()->create();
        SellerLedger::factory()->count(3)->create(['payout_id' => $payout->id]);
        $this->assertCount(3, $payout->ledgerEntries);
    }

    public function test_payout_state_is_terminal_helper(): void
    {
        $this->assertTrue(PayoutState::Succeeded->isTerminal());
        $this->assertTrue(PayoutState::Void->isTerminal());
        $this->assertFalse(PayoutState::Scheduled->isTerminal());
        $this->assertFalse(PayoutState::InFlight->isTerminal());
        $this->assertFalse(PayoutState::Failed->isTerminal());
    }

    public function test_factory_states(): void
    {
        $this->assertSame(PayoutState::Scheduled, Payout::factory()->create()->state);
        $this->assertSame(PayoutState::InFlight, Payout::factory()->inFlight()->create()->state);
        $this->assertSame(PayoutState::Succeeded, Payout::factory()->succeeded()->create()->state);
        $this->assertSame(PayoutState::Failed, Payout::factory()->failed()->create()->state);
        $this->assertSame(PayoutState::Void, Payout::factory()->void()->create()->state);
    }
}
```

- [ ] **Step 2: Run, confirm failure** — table missing; enum missing; model missing.

- [ ] **Step 3: Implement** the enum, both migrations, the model, the factory, and the two relation additions (`Store::payouts`, `SellerLedger::payout`). Run `docker compose exec -T laravel.test php artisan migrate`.

- [ ] **Step 4: Run; iterate to 10/10 PASS.**

### Task 2: Add `payouts_enabled` + `disabled_reason` to `stores`

**Files:**
- Create: `api/database/migrations/2026_05_11_200003_add_connect_health_to_stores_table.php`
- Update: `api/app/Models/Store.php` (extend `$fillable`, `$casts`, PHPDoc)
- Update: `api/database/factories/StoreFactory.php` (default `payouts_enabled = true` for test convenience)
- Test: extend `api/tests/Feature/Payouts/PayoutSchemaTest.php` *(or create `api/tests/Feature/Payouts/StoreConnectHealthTest.php` if the schema test grows beyond ~15 cases)*

```php
public function up(): void
{
    Schema::table('stores', function (Blueprint $t) {
        $t->boolean('payouts_enabled')->default(true)->after('stripe_connect_id');
        $t->text('disabled_reason')->nullable()->after('payouts_enabled');
    });
}

public function down(): void
{
    Schema::table('stores', function (Blueprint $t) {
        $t->dropColumn(['payouts_enabled', 'disabled_reason']);
    });
}
```

> **Plan note (default `true` vs `false` for `payouts_enabled`) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Two reasonable defaults:
> - **(a) Default `true`** — Plan 2's choice. Matches today's behaviour: existing stores can already receive transfers, and the column is purely a *cache* of Stripe's view. The truth-source remains Stripe; the column is an optimisation. A future `account.updated` webhook will flip it to `false` if Stripe says the account is restricted.
> - **(b) Default `false`, populate via a one-shot backfill** — defensive but requires a data-migration touching the live Stripe API for every existing store at deploy time. Adds risk for no real safety benefit (Plan 2's pre-check already short-circuits when `stripe_connect_id IS NULL`, which is the actual "no transfers possible" case).
> **Going with (a).** The migration sets `DEFAULT true`; new stores inherit `true`; existing stores get `true`; only the webhook handler ever sets `false`. If a seller's Stripe account is actually disabled, the cron will see the `account.updated` event before any transfer attempt — and even if it doesn't, Stripe's own `Transfer::create` call will fail and the `Payout` lands in `failed` state with the Stripe error in `failure_reason`. Defence-in-depth without artificial friction.

- [ ] **Step 1: Write the failing test:**

```php
public function test_stores_table_has_connect_health_columns(): void
{
    $this->assertTrue(Schema::hasColumn('stores', 'payouts_enabled'));
    $this->assertTrue(Schema::hasColumn('stores', 'disabled_reason'));
}

public function test_payouts_enabled_defaults_to_true(): void
{
    $store = Store::factory()->create();
    $this->assertTrue((bool) $store->payouts_enabled);
}

public function test_disabled_reason_is_nullable(): void
{
    $store = Store::factory()->create();
    $this->assertNull($store->disabled_reason);
}
```

- [ ] **Step 2: Run, confirm failure.**

- [ ] **Step 3: Implement** the migration + Store fillable/casts/PHPDoc + Factory update.

- [ ] **Step 4: Run; iterate to 3/3 PASS.**

---

## Phase B — `LedgerWriter::recordPayoutSettled` + `PayoutSchedule::isCycleBoundary` + `PayoutService::scheduleForCycle`

### Task 3: Extend `LedgerWriter` with the 4th method

**Files:**
- Update: `api/app/Modules/Ledger/Services/LedgerWriter.php`
- Test: `api/tests/Feature/Payouts/LedgerWriterPayoutSettledTest.php`

The new method writes the offsetting debit entry that nets the bundled credits to zero in future balance reads:

```php
/**
 * Debit the seller's ledger for a payout that has settled in Stripe.
 * Written from PayoutService::executeScheduled (synchronous Stripe success)
 * OR from the transfer.paid webhook handler — whichever advances the
 * Payout to Succeeded first. Idempotency lives at the Payout state
 * machine level: succeeded → succeeded is a no-op upstream.
 */
public function recordPayoutSettled(Payout $payout): SellerLedger
{
    return $this->persist([
        'store_id' => $payout->store_id,
        'entry_type' => LedgerEntryType::PayoutSettled,
        'direction' => LedgerDirection::Debit,
        'amount_cents' => (int) $payout->net_cents,
        'source_type' => Payout::class,
        'source_id' => $payout->id,
        'available_at' => now(),
        'payout_id' => $payout->id,
        'description' => "Payout #{$this->shortId($payout->id)} settled",
    ]);
}
```

> **Plan note (the `payout_settled` row's `payout_id` self-reference):** The `payout_settled` debit itself carries `payout_id = $payout->id` — the same column that the bundled credit/debit entries get stamped with. This is deliberate: every ledger entry "belonging to" a payout (the bundled credits, the bundled debits, AND the offsetting settle debit) reference the same `payout_id`. Future statement queries that join `payouts → seller_ledger` get all four entry types in one go. The arithmetic still nets correctly: bundled credits (gross) − bundled debits (debits portion of the bundle) − the settle debit (net) = 0 for that payout's window once everything settles.

> **Plan note (where the call is fired from — synchronous Stripe success vs webhook):** Two firing paths in Plan 2:
> 1. **Synchronous** — `PayoutService::executeScheduled` calls `StripeService::createMarketplacePayout` and on success advances `scheduled → in_flight`. The settle entry is **NOT** written here; the Stripe transfer is created but funds haven't necessarily settled in the destination Connect account yet.
> 2. **Webhook** — `CheckoutController::webhook` on `transfer.paid` advances `in_flight → succeeded` AND writes the `payout_settled` ledger entry.
>
> So `recordPayoutSettled` is called from the webhook handler only in Plan 2. The synchronous path stays at `in_flight`. Document this clearly in the LedgerWriter docblock and in the webhook handler comment. If `transfer.paid` never arrives (Stripe edge case), an admin escalation path (Plan 3) handles it manually.

- [ ] **Step 1: Write the failing test** — `LedgerWriterPayoutSettledTest.php`:

```php
public function test_record_payout_settled_writes_debit_with_payout_self_reference(): void
public function test_record_payout_settled_amount_equals_payout_net_cents(): void
public function test_record_payout_settled_available_at_is_now(): void
public function test_record_payout_settled_source_is_the_payout(): void
public function test_record_payout_settled_entry_type_is_payout_settled(): void
public function test_record_payout_settled_writes_payout_id_on_the_entry(): void
```

- [ ] **Step 2: Run, confirm failure** — method missing.

- [ ] **Step 3: Implement.** Plus add `use App\Models\Payout;` to `LedgerWriter`.

- [ ] **Step 4: Run; iterate to 6/6 PASS.**

### Task 4: Add `PayoutSchedule::isCycleBoundary` + `PayoutSchedule::previousCycleStart`

**Files:**
- Update: `api/app/Modules/Ledger/Support/PayoutSchedule.php`
- Update: `api/tests/Feature/Ledger/PayoutScheduleTest.php` (extend with new cases)

```php
/** True when `$now` falls exactly on a cycle boundary (UTC day match). */
public static function isCycleBoundary(CarbonImmutable $now): bool
{
    $anchor = CarbonImmutable::parse(config('payouts.cycle_anchor'), 'UTC')->startOfDay();
    $cycleDays = (int) config('payouts.cycle_days', 14);

    if ($cycleDays <= 0) {
        throw new \InvalidArgumentException('payouts.cycle_days must be positive.');
    }

    $nowUtc = $now->setTimezone('UTC')->startOfDay();

    if ($nowUtc->lessThan($anchor)) {
        return false;
    }

    return ($anchor->diffInDays($nowUtc) % $cycleDays) === 0;
}

/** Returns the start of the cycle that ends at `$cycleEnd` (= `$cycleEnd - cycle_days`). */
public static function previousCycleStart(CarbonImmutable $cycleEnd): CarbonImmutable
{
    $cycleDays = (int) config('payouts.cycle_days', 14);

    return $cycleEnd->subDays($cycleDays);
}
```

- [ ] **Step 1: Write the failing test** — extend `PayoutScheduleTest.php`:

```php
public function test_is_cycle_boundary_at_anchor(): void
public function test_is_cycle_boundary_at_anchor_plus_one_cycle(): void
public function test_is_not_cycle_boundary_one_day_after_anchor(): void
public function test_is_not_cycle_boundary_before_anchor(): void
public function test_is_cycle_boundary_ignores_time_within_day(): void
// (anchor + 14d at 23:59 UTC should still match)
public function test_previous_cycle_start_returns_cycle_end_minus_cycle_days(): void
```

- [ ] **Step 2: Run, confirm failure.**

- [ ] **Step 3: Implement.** No code changes outside `PayoutSchedule`.

- [ ] **Step 4: Run; iterate to PASS.**

### Task 5: `PayoutService::scheduleForCycle`

**Files:**
- Create: `api/app/Modules/Ledger/Services/PayoutService.php`
- Test: `api/tests/Feature/Payouts/PayoutServiceScheduleTest.php`

The method walks every store, locks its available ledger window, bundles unpaid entries into a freshly-created `Payout`, returns the collection. Idempotency: if a `Payout` already exists for the store with `period_end = $cycleDate`, return it as-is (do not double-bundle).

```php
<?php

declare(strict_types=1);

namespace App\Modules\Ledger\Services;

use App\Models\Payout;
use App\Models\SellerLedger;
use App\Models\Store;
use App\Modules\Ledger\Support\PayoutSchedule;
use App\Support\Enums\LedgerDirection;
use App\Support\Enums\PayoutState;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;

class PayoutService
{
    /**
     * Iterates every store, locks its available unpaid ledger entries, and
     * bundles them into a Payout in `scheduled` state. Stores with net ≤ 0
     * are skipped (debit entries roll into the next cycle).
     *
     * Idempotent: safe to call multiple times for the same `$cycleDate` —
     * stores that already have a Payout for that period are skipped.
     *
     * @return Collection<int, Payout>
     */
    public function scheduleForCycle(CarbonImmutable $cycleDate): Collection
    {
        $periodEnd = $cycleDate->setTimezone('UTC')->startOfDay();
        $periodStart = PayoutSchedule::previousCycleStart($periodEnd);

        $payouts = new Collection;

        Store::query()
            ->whereNotNull('stripe_connect_id')
            ->where('payouts_enabled', true)
            ->orderBy('id')
            ->chunkById(50, function ($stores) use ($payouts, $periodStart, $periodEnd, $cycleDate): void {
                foreach ($stores as $store) {
                    $payout = $this->scheduleForStore($store, $periodStart, $periodEnd, $cycleDate);
                    if ($payout !== null) {
                        $payouts->push($payout);
                    }
                }
            });

        return $payouts;
    }

    private function scheduleForStore(
        Store $store,
        CarbonImmutable $periodStart,
        CarbonImmutable $periodEnd,
        CarbonImmutable $cycleDate,
    ): ?Payout {
        return DB::transaction(function () use ($store, $periodStart, $periodEnd, $cycleDate): ?Payout {
            // Idempotency: if a Payout already exists for this store + period_end, return it.
            $existing = Payout::query()
                ->where('store_id', $store->id)
                ->where('period_end', $periodEnd)
                ->lockForUpdate()
                ->first();
            if ($existing !== null) {
                return $existing;
            }

            // Lock the eligible entries before reading their sums. SELECT FOR
            // UPDATE prevents a concurrent cron run (or a Plan-3 admin
            // adjustment) from bundling the same rows into a second Payout.
            $entries = SellerLedger::query()
                ->where('store_id', $store->id)
                ->where('available_at', '<=', $periodEnd)
                ->whereNull('payout_id')
                ->lockForUpdate()
                ->get();

            if ($entries->isEmpty()) {
                return null;
            }

            $gross = $entries->where('direction', LedgerDirection::Credit)->sum('amount_cents');
            $debits = $entries->where('direction', LedgerDirection::Debit)->sum('amount_cents');
            $net = $gross - $debits;

            if ($net <= 0) {
                // Negative or zero balance: do NOT create a Payout. Debit
                // entries remain unpaid (no payout_id) and roll into the
                // next cycle. Documented in spec line 168 "negative balance
                // rollover".
                return null;
            }

            $payout = Payout::create([
                'store_id' => $store->id,
                'period_start' => $periodStart,
                'period_end' => $periodEnd,
                'scheduled_for' => $cycleDate,
                'gross_cents' => $gross,
                'debits_cents' => $debits,
                'net_cents' => $net,
                'state' => PayoutState::Scheduled,
            ]);

            // Stamp payout_id on every bundled entry. Raw query to dodge
            // SellerLedger::update() (which throws — the table is append-only
            // for amount/source/direction; the payout_id linkage is the one
            // permitted column mutation, by deliberate design).
            SellerLedger::query()
                ->whereIn('id', $entries->pluck('id'))
                ->getQuery()                   // drop to QueryBuilder so model overrides don't fire
                ->update(['payout_id' => $payout->id]);

            return $payout;
        });
    }
}
```

> **Plan note (`payout_id` mutation on append-only ledger entries) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Plan 1's `SellerLedger::update()` override throws on any update. But Plan 2 needs to stamp `payout_id` on the bundled entries. **Two strategies:**
> - **(a) Use the QueryBuilder directly** (`SellerLedger::query()->getQuery()->update([...])`). The QueryBuilder doesn't fire model events, so the `update()` override is never invoked. Chosen here.
> - **(b) Loosen the model override** to allow `payout_id`-only updates (`if (array_keys($attributes) === ['payout_id']) { return parent::update(...); }`).
>
> **Going with (a).** Keeps the model invariant "no Eloquent updates ever" intact — the only mutation route for `payout_id` is the QueryBuilder, which is what `PayoutService` uses. Tests can detect regressions by asserting that any code calling `$entry->update(['payout_id' => ...])` directly still throws. The intent is documented in PayoutService's comment.

> **Plan note (`chunkById` over `cursor()`):** Stores are bounded (tens to low thousands in v1). `chunkById(50)` keeps memory flat for any plausible volume; `cursor()` would also work but is less obvious to a future reader. Performance is dominated by the per-store `DB::transaction` not the iteration.

> **Plan note (filter `stripe_connect_id IS NOT NULL AND payouts_enabled = true`):** Stores without a Stripe Connect account cannot receive transfers; stores whose Connect account is restricted (cached `payouts_enabled = false` via the `account.updated` webhook in Task 11) shouldn't be attempted. Pre-filtering at the query level means the cron iterates fewer stores and there's no Stripe API call per store. **This is the recommended (b) approach from the spec's open items (line 250).**

> **Plan note (negative-balance rollover):** If `$net <= 0` the store gets no `Payout` and the debit entries remain unattached. Next cycle they get reconsidered. This is exactly the spec's "negative balance rollover" (line 168). Test case `test_negative_net_store_creates_no_payout` covers it.

- [ ] **Step 1: Write the failing test** — `PayoutServiceScheduleTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Payouts;

use App\Models\Payout;
use App\Models\SellerLedger;
use App\Models\Store;
use App\Modules\Ledger\Services\PayoutService;
use App\Support\Enums\LedgerDirection;
use App\Support\Enums\LedgerEntryType;
use App\Support\Enums\PayoutState;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PayoutServiceScheduleTest extends TestCase
{
    use RefreshDatabase;

    public function test_creates_payout_for_store_with_positive_available_balance(): void
    {
        $store = Store::factory()->create(['stripe_connect_id' => 'acct_test_xyz']);
        SellerLedger::factory()->create([
            'store_id' => $store->id,
            'entry_type' => LedgerEntryType::OrderEarned,
            'direction' => LedgerDirection::Credit,
            'amount_cents' => 5000,
            'available_at' => now()->subDay(),
            'payout_id' => null,
        ]);

        $payouts = app(PayoutService::class)->scheduleForCycle(CarbonImmutable::now());

        $this->assertCount(1, $payouts);
        $this->assertSame(5000, $payouts->first()->net_cents);
        $this->assertSame(PayoutState::Scheduled, $payouts->first()->state);
    }

    public function test_skips_store_with_no_unpaid_entries(): void
    public function test_skips_store_without_stripe_connect_id(): void
    public function test_skips_store_with_payouts_disabled(): void
    // (Store::factory()->state(['payouts_enabled' => false]) — no Payout created)
    public function test_negative_net_store_creates_no_payout(): void
    public function test_zero_net_store_creates_no_payout(): void
    public function test_bundles_both_credits_and_debits_into_one_payout(): void
    public function test_stamps_payout_id_on_every_bundled_entry(): void
    public function test_excludes_entries_with_future_available_at(): void
    public function test_excludes_entries_already_paid_out(): void
    // (payout_id is non-null on a prior Payout — must not be re-bundled)
    public function test_calling_twice_for_same_cycle_is_idempotent(): void
    // (second call returns existing Payout; ledger entries are not re-stamped)
    public function test_payout_gross_debits_net_math_is_consistent(): void
    // (gross = sum of credits; debits = sum of debits; net = gross - debits)
    public function test_multiple_stores_each_get_their_own_payout(): void
    public function test_period_start_equals_cycle_end_minus_cycle_days(): void
}
```

- [ ] **Step 2: Run, confirm failure** — service missing.

- [ ] **Step 3: Implement** `PayoutService::scheduleForCycle` + private `scheduleForStore`.

- [ ] **Step 4: Run; iterate to ~13/13 PASS.**

---

## Phase C — `PayoutService::executeScheduled` + `StripeService::createMarketplacePayout` + the cron command

### Task 6: `StripeService::createMarketplacePayout`

**Files:**
- Update: `api/app/Modules/Checkout/Services/StripeService.php`
- Test: `api/tests/Feature/Payouts/StripeServiceCreateMarketplacePayoutTest.php` *(short — mostly mocks the SDK)*

```php
/**
 * Creates a Stripe Connect transfer for a marketplace payout. The
 * idempotency key must be stable per Payout (use the Payout UUID) so
 * retries after a partial failure dedupe at the Stripe side.
 *
 * @throws \Stripe\Exception\ApiErrorException
 */
public function createMarketplacePayout(
    Store $store,
    int $amountCents,
    string $idempotencyKey,
): Transfer {
    return Transfer::create(
        [
            'amount' => $amountCents,
            'currency' => 'usd',
            'destination' => $store->stripe_connect_id,
            'metadata' => [
                'store_id' => $store->id,
                'idempotency_key' => $idempotencyKey,
            ],
        ],
        ['idempotency_key' => $idempotencyKey],
    );
}
```

> **Plan note (new method instead of extending `createTransfer`) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** The existing `createTransfer(int $amount, string $destination, string $transferGroup): Transfer` is still called from the Layer 4 capture-time flow (`TransferFundsToStore` job — see spec line 249). Plan 2 does NOT retire that flow; that's a Plan 3 concern. So Plan 2 ships a parallel method `createMarketplacePayout` whose semantics differ:
> - **No `transfer_group`** — payouts aren't tied to a single payment intent; they aggregate many orders.
> - **Idempotency-key argument is required** — the caller (PayoutService) passes the Payout UUID so retries dedupe.
> - **Type signature takes `Store` and `string $idempotencyKey`** — encodes the marketplace-payout intent at the type level.
>
> The shared underlying `Transfer::create` call signature is similar, but the contract is different enough to be a separate method. When Plan 3 retires the capture-time flow, `createTransfer` can be deleted. Don't refactor `createTransfer` in Plan 2 — the existing tests + Layer-4 flow depend on its current shape.

> **Plan note (idempotency key value):** **Use `"payout-{$payout->id}"`** as the idempotency key — prefixed so it can't collide with any other idempotency key in the system. Stripe stores idempotency keys per-account for 24 hours; that window is comfortably longer than our 3-retry × 1-hour-backoff window (max 4 hours from first attempt). If the same payout UUID hits Stripe twice within 24 hours with the same body, Stripe returns the previously-created Transfer. Outside the 24h window the second call would create a duplicate transfer — but at that point the retry budget is exhausted and admin intervention is required (Plan 3).

- [ ] **Step 1: Write the failing test:**

```php
public function test_create_marketplace_payout_passes_amount_destination_and_idempotency_key(): void
public function test_create_marketplace_payout_stamps_metadata_with_store_id(): void
public function test_create_marketplace_payout_returns_transfer_object(): void
```

Use the Stripe SDK's testing pattern: stub `\Stripe\Transfer::create` via Mockery on the static method or via a higher-level fake — match whatever the existing `StripeServiceTest` (if one exists; search for it) does. If there's no existing test pattern, the simplest approach is to mock the `StripeService` instance itself in higher-level tests (Task 7) and skip a unit test for this thin wrapper.

- [ ] **Step 2-4: Standard TDD.**

### Task 7: `PayoutService::executeScheduled`

**Files:**
- Update: `api/app/Modules/Ledger/Services/PayoutService.php`
- Update: `api/app/Modules/Checkout/Services/StripeService.php` (already done in Task 6)
- Test: `api/tests/Feature/Payouts/PayoutServiceExecuteTest.php`

```php
public function __construct(
    private readonly StripeService $stripe,
) {}

/**
 * Advances a scheduled Payout through the Stripe transfer call. On
 * synchronous success the row sits at `in_flight` awaiting the
 * `transfer.paid` webhook (which fires recordPayoutSettled and advances
 * to `succeeded`). On synchronous failure the row lands in `failed`
 * with retries incremented; the hourly payouts:retry-failed command
 * picks it up.
 */
public function executeScheduled(Payout $payout): Payout
{
    if ($payout->state !== PayoutState::Scheduled && $payout->state !== PayoutState::Failed) {
        // Already in_flight or terminal — caller bug, don't re-fire Stripe.
        return $payout;
    }

    // Pre-check: store health. If the cached payouts_enabled flag is
    // false, void the Payout immediately and return entries to the pool.
    $store = $payout->store()->lockForUpdate()->first();
    if (! $store->payouts_enabled || $store->stripe_connect_id === null) {
        return $this->voidUnhealthy($payout, $store->disabled_reason ?? 'Connect account not ready');
    }

    // Advance to in_flight BEFORE the Stripe call. If Stripe fails the
    // catch block flips us to failed; if Stripe succeeds we stay
    // in_flight awaiting the webhook.
    DB::transaction(function () use ($payout) {
        $payout->update(['state' => PayoutState::InFlight]);
    });

    try {
        $transfer = $this->stripe->createMarketplacePayout(
            $payout->store,
            $payout->net_cents,
            'payout-'.$payout->id,
        );
    } catch (\Throwable $e) {
        DB::transaction(function () use ($payout, $e) {
            $payout->update([
                'state' => PayoutState::Failed,
                'failed_at' => now(),
                'failure_reason' => $e->getMessage(),
                'retries' => $payout->retries + 1,
            ]);
        });

        // Surface as PayoutFailedNotification only after exhausting retries.
        if ($payout->fresh()->retries >= 3) {
            $payout->store->owner->notify(new PayoutFailedNotification($payout->fresh()));
            foreach (app(AdminRecipients::class)->all() as $admin) {
                $admin->notify(new PayoutFailedNotification($payout->fresh()));
            }
        }

        return $payout->fresh();
    }

    DB::transaction(function () use ($payout, $transfer) {
        $payout->update(['stripe_transfer_id' => $transfer->id]);
    });

    return $payout->fresh();
}

private function voidUnhealthy(Payout $payout, string $reason): Payout
{
    return DB::transaction(function () use ($payout, $reason) {
        $payout->update([
            'state' => PayoutState::Void,
            'failure_reason' => "Connect account not payable: {$reason}",
        ]);

        // Release bundled entries back to the pool — they'll be picked up
        // again next cycle (when hopefully the seller has fixed their
        // Connect onboarding).
        SellerLedger::query()
            ->where('payout_id', $payout->id)
            ->where('entry_type', '!=', LedgerEntryType::PayoutSettled)
            ->getQuery()
            ->update(['payout_id' => null]);

        $payout->store->owner->notify(new PayoutFailedNotification($payout->fresh()));

        return $payout->fresh();
    });
}
```

> **Plan note (DB transaction does NOT wrap the Stripe call):** Critical. Stripe `Transfer::create` is a network call to api.stripe.com — holding a Postgres transaction open across that call would tie up a connection for up to several seconds per store and risk transaction timeouts. The pattern is:
> 1. Open transaction → advance state to `in_flight` → close transaction.
> 2. Call Stripe (no DB transaction).
> 3. Open transaction → record result (`stripe_transfer_id` on success, or `failed` state on exception) → close transaction.
>
> The `in_flight` state is the "in-progress" marker. If the cron is killed between steps 2 and 3 the row stays `in_flight` indefinitely — Plan 3's admin retry button handles that edge case. For Plan 2 we accept the small risk; tests document it.

> **Plan note (two-step success: synchronous → in_flight, webhook → succeeded) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Stripe's transfer API responds synchronously with a `Transfer` object once the request is accepted, but funds aren't fully settled in the destination Connect account until the `transfer.paid` event fires (usually within seconds, occasionally minutes). Plan 2 models this honestly:
> - Synchronous success → `in_flight` only.
> - `transfer.paid` webhook → `succeeded` + `recordPayoutSettled` writes the offsetting ledger debit.
> - `transfer.failed` webhook → `failed` (rare; almost always the synchronous call would have thrown first).
>
> This means there's a brief window where a `Payout` is `in_flight` and the ledger has NOT yet been debited. The available balance read therefore (briefly) shows the gross as still "available" minus payout_id-stamped entries. Plan 1's `BalanceService::forStore` already filters on `payout_id IS NULL` so this is correctly zero. Document the transient state.

> **Plan note (retry-exhaustion notification fires from executeScheduled, not from `payouts:retry-failed`):** When `executeScheduled` increments retries to 3+, the notification fires immediately. The `payouts:retry-failed` command doesn't need its own notification logic — it just re-calls `executeScheduled` and the same notification path handles exhaustion.

- [ ] **Step 1: Write the failing test** — `PayoutServiceExecuteTest.php`:

```php
public function test_execute_advances_scheduled_to_in_flight_on_stripe_success(): void
public function test_execute_records_stripe_transfer_id_on_success(): void
public function test_execute_passes_payout_id_as_idempotency_key(): void
public function test_execute_does_not_write_payout_settled_entry_on_synchronous_success(): void
// (key — settle entry only fires from the transfer.paid webhook)
public function test_execute_advances_to_failed_on_stripe_exception(): void
public function test_execute_increments_retries_on_failure(): void
public function test_execute_records_failure_reason_from_stripe_message(): void
public function test_execute_does_not_notify_on_first_failure(): void
// (only notifies on retries >= 3)
public function test_execute_notifies_seller_on_retry_exhaustion(): void
public function test_execute_notifies_admins_on_retry_exhaustion(): void
public function test_execute_voids_payout_when_store_payouts_disabled(): void
public function test_execute_returns_entries_to_pool_on_void(): void
public function test_execute_voids_when_stripe_connect_id_is_null(): void
public function test_execute_is_noop_on_already_succeeded_payout(): void
public function test_execute_is_noop_on_void_payout(): void
public function test_execute_can_re_fire_a_failed_payout(): void
// (precondition: retries < 3; failed → in_flight on success)
```

- [ ] **Step 2-4: Standard TDD.** Mock `StripeService` via `$this->app->instance(StripeService::class, $mock)`. Use `Mockery::mock(StripeService::class)` with `->shouldReceive('createMarketplacePayout')`.

### Task 8: `payouts:run-cycle` artisan command

**Files:**
- Create: `api/app/Modules/Ledger/Console/RunPayoutCycleCommand.php`
- Update: `api/bootstrap/app.php` (register in `withCommands` + `withSchedule`)
- Test: `api/tests/Feature/Payouts/RunPayoutCycleCommandTest.php`

```php
<?php

declare(strict_types=1);

namespace App\Modules\Ledger\Console;

use App\Modules\Ledger\Services\PayoutService;
use App\Modules\Ledger\Support\PayoutSchedule;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;

class RunPayoutCycleCommand extends Command
{
    protected $signature = 'payouts:run-cycle {--force : Run even when today is not a cycle boundary}';

    protected $description = 'Bundle available ledger entries into Payouts and dispatch Stripe transfers for the current cycle.';

    public function handle(PayoutService $service): int
    {
        $now = CarbonImmutable::now('UTC');

        if (! $this->option('force') && ! PayoutSchedule::isCycleBoundary($now)) {
            $this->info('Not a cycle boundary; nothing to do.');
            return self::SUCCESS;
        }

        $this->info("Scheduling payouts for cycle ending {$now->format('Y-m-d')}…");
        $payouts = $service->scheduleForCycle($now);
        $this->info("Created {$payouts->count()} Payout(s).");

        foreach ($payouts as $payout) {
            $this->line("  → executing payout {$payout->id} (net: {$payout->net_cents}¢, store {$payout->store_id})");
            $service->executeScheduled($payout);
            // Notification fired synchronously inside executeScheduled.
        }

        $this->info('Cycle complete.');
        return self::SUCCESS;
    }
}
```

Register in `bootstrap/app.php`:

```php
->withCommands([
    ReconcileFailedMoneyMovements::class,
    \App\Modules\Ledger\Console\RunPayoutCycleCommand::class,
    \App\Modules\Ledger\Console\RetryFailedPayoutsCommand::class,   // Task 12
])
->withSchedule(function (Schedule $schedule): void {
    $schedule->command('orders:reconcile-money')->hourly();
    $schedule->command('payouts:run-cycle')->dailyAt('09:00');     // 9am UTC; no-op on non-cycle days
    $schedule->command('payouts:retry-failed')->hourly();          // Task 12
})
```

> **Plan note (registration in `bootstrap/app.php` vs `routes/console.php`) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Laravel 11+ supports both:
> - **`routes/console.php`** — Plan 1's existing `Schedule::command('messages:gc-orphan-attachments')->dailyAt('03:30')` lives here.
> - **`bootstrap/app.php`'s `withSchedule`** — the existing `orders:reconcile-money` is registered here.
>
> Both patterns coexist in the codebase. **Plan 2 commits to `bootstrap/app.php`** for the payout commands because (1) the most recent precedent (`orders:reconcile-money`) uses it, (2) the `withCommands` registration MUST go in `bootstrap/app.php` anyway, so collocating `withSchedule` keeps both wirings in one file. Plan 2 does NOT migrate the existing `routes/console.php` schedule entries; that's incidental cleanup outside scope.

> **Plan note (daily-at-9am-UTC + boundary check, not weekly):** The scheduler runs `payouts:run-cycle` every day at 09:00 UTC. The command itself short-circuits on non-cycle-boundary days via `PayoutSchedule::isCycleBoundary($now)`. This pattern (daily wake + early-exit) is simpler than registering a cron that fires only on specific weekdays — and it's robust to anchor changes (changing `config/payouts.php` cycle_anchor doesn't require updating the schedule). The `--force` flag lets ops trigger an off-cycle run for testing.

> **Plan note (no queue, all synchronous):** `RunPayoutCycleCommand` calls `executeScheduled` inline for every Payout. With ~tens of stores in v1 this is fine — the bottleneck is Stripe API latency (~200-500ms per transfer) so a 100-store cycle takes ~50 seconds, well within a single command invocation. If the marketplace grows to thousands of stores Plan 3+ can queue the per-payout work, but Plan 2's synchronous loop matches the codebase's existing periodic-job pattern (`ReconcileFailedMoneyMovements` is also synchronous) and avoids the testing complexity of queued jobs.

- [ ] **Step 1: Write the failing test** — `RunPayoutCycleCommandTest.php`:

```php
public function test_command_is_noop_on_non_cycle_boundary(): void
public function test_command_runs_on_cycle_boundary(): void
public function test_command_creates_payouts_for_eligible_stores(): void
public function test_command_calls_execute_scheduled_for_each_created_payout(): void
public function test_command_force_flag_overrides_boundary_check(): void
public function test_command_succeeds_when_no_eligible_stores(): void
public function test_command_handles_individual_store_failure_without_aborting_cycle(): void
// (one store throws on executeScheduled; subsequent stores still process)
```

Use `$this->artisan('payouts:run-cycle')->assertSuccessful()` from Laravel's testing helpers. Mock `StripeService` so tests don't hit the network.

- [ ] **Step 2-4: Standard TDD.**

---

## Phase D — Stripe webhooks + retry command

### Task 9: Extend `CheckoutController::webhook` with three new events

**Files:**
- Update: `api/app/Modules/Checkout/Controllers/CheckoutController.php`
- Create: `api/app/Modules/Ledger/Services/PayoutWebhookHandler.php` *(separates webhook logic from controller; keeps controller thin)*
- Test: `api/tests/Feature/Payouts/StripePayoutWebhookTest.php`

> **Plan note (location of the webhook endpoint) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Audit confirms the codebase has **exactly one** Stripe webhook route: `POST /v1/stripe/webhook` declared in `api/app/Modules/Checkout/routes.php` line 13, handled by `CheckoutController::webhook`. It already dispatches by `$event->type` for `payment_intent.succeeded` and the three dispute event types. **Plan 2 extends this existing route** with three new event-type branches — it does NOT create a separate `/v1/webhooks/stripe-connect` route. Rationale: Stripe Connect events (`transfer.paid`, `transfer.failed`, `account.updated`) all arrive at whatever webhook URL is configured in the Stripe dashboard for the platform account. Having one endpoint that dispatches by event type matches how Stripe expects platforms to operate, and matches the existing `dispute.created/updated/closed` precedent in the same file.

Updated webhook method (additions after the existing dispute block):

```php
public function webhook(Request $request): JsonResponse
{
    $event = $this->stripeService->verifyWebhookSignature(
        $request->getContent(),
        $request->header('Stripe-Signature', ''),
    );

    if ($event->type === 'payment_intent.succeeded') {
        // ... existing ...
    }

    if (in_array($event->type, ['charge.dispute.created', 'charge.dispute.updated', 'charge.dispute.closed'], true)) {
        // ... existing ...
    }

    // NEW: Layer 11 payout events
    if (in_array($event->type, ['transfer.paid', 'transfer.failed'], true)) {
        app(PayoutWebhookHandler::class)->handleTransferEvent(
            $event->type,
            $event->data->object->toArray(),
        );
    }

    if ($event->type === 'account.updated') {
        app(PayoutWebhookHandler::class)->handleAccountUpdated(
            $event->data->object->toArray(),
        );
    }

    return response()->json(['status' => 'ok']);
}
```

`PayoutWebhookHandler`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Ledger\Services;

use App\Models\Payout;
use App\Models\Store;
use App\Modules\Notifications\Notifications\PayoutSucceededNotification;
use App\Modules\Notifications\Notifications\PayoutFailedNotification;
use App\Modules\Notifications\Services\AdminRecipients;
use App\Support\Enums\PayoutState;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class PayoutWebhookHandler
{
    public function __construct(
        private readonly LedgerWriter $ledger,
    ) {}

    /**
     * @param  array<string, mixed>  $transferData  Stripe Transfer object as array.
     */
    public function handleTransferEvent(string $eventType, array $transferData): void
    {
        $transferId = $transferData['id'] ?? null;
        if ($transferId === null) {
            Log::warning('transfer.* webhook with no transfer id', ['data' => $transferData]);
            return;
        }

        $payout = Payout::query()->where('stripe_transfer_id', $transferId)->first();
        if ($payout === null) {
            // No matching Payout — could be a legacy capture-time transfer
            // (Layer 4 createTransfer flow). Ignore silently.
            return;
        }

        if ($eventType === 'transfer.paid') {
            $this->advanceToSucceeded($payout);
        } elseif ($eventType === 'transfer.failed') {
            $this->advanceToFailed($payout, $transferData['failure_message'] ?? 'Stripe transfer.failed');
        }
    }

    private function advanceToSucceeded(Payout $payout): void
    {
        if ($payout->state === PayoutState::Succeeded) {
            return;  // already settled — idempotent retry
        }

        DB::transaction(function () use ($payout) {
            $payout->update([
                'state' => PayoutState::Succeeded,
                'transferred_at' => now(),
            ]);

            // Write the offsetting payout_settled debit so the bundled
            // credits net to zero in future balance reads.
            $this->ledger->recordPayoutSettled($payout->fresh());
        });

        $payout->store->owner->notify(new PayoutSucceededNotification($payout->fresh()));
    }

    private function advanceToFailed(Payout $payout, string $reason): void
    {
        DB::transaction(function () use ($payout, $reason) {
            $payout->update([
                'state' => PayoutState::Failed,
                'failed_at' => now(),
                'failure_reason' => $reason,
                'retries' => $payout->retries + 1,
            ]);
        });

        if ($payout->fresh()->retries >= 3) {
            $payout->store->owner->notify(new PayoutFailedNotification($payout->fresh()));
            foreach (app(AdminRecipients::class)->all() as $admin) {
                $admin->notify(new PayoutFailedNotification($payout->fresh()));
            }
        }
    }

    /** @param array<string, mixed> $accountData Stripe Account object as array. */
    public function handleAccountUpdated(array $accountData): void
    {
        $accountId = $accountData['id'] ?? null;
        if ($accountId === null) {
            return;
        }

        $store = Store::query()->where('stripe_connect_id', $accountId)->first();
        if ($store === null) {
            return;  // not one of our connected accounts
        }

        $payoutsEnabled = (bool) ($accountData['payouts_enabled'] ?? false);
        $disabledReason = $accountData['requirements']['disabled_reason'] ?? null;

        $store->update([
            'payouts_enabled' => $payoutsEnabled,
            'disabled_reason' => $disabledReason,
        ]);
    }
}
```

> **Plan note (transfer.reversed event is intentionally NOT handled):** Stripe Connect emits `transfer.reversed` when a transfer is administratively reversed. Spec line 18 puts that path out of scope for Plan 2 — Plan 3's admin-void action is the marketplace-initiated reversal flow; a Stripe-initiated reversal (rare; usually only in fraud-investigation contexts) is an edge case for Plan 3+. If `transfer.reversed` arrives in Plan 2 production, the catch-all logs it via the silent "no matching Payout / unknown event type" path (no explicit branch fires).

> **Plan note (settle ledger entry is written from the webhook handler, NOT from `executeScheduled`):** Repeat from Task 7 because it's the single most important architectural call in Plan 2: `recordPayoutSettled` is **only** called from `PayoutWebhookHandler::advanceToSucceeded`. The synchronous `executeScheduled` advances to `in_flight` and stops. This guarantees the ledger debit doesn't write until Stripe actually settles the funds.

- [ ] **Step 1: Write the failing test** — `StripePayoutWebhookTest.php`:

```php
public function test_transfer_paid_advances_in_flight_payout_to_succeeded(): void
public function test_transfer_paid_writes_payout_settled_ledger_entry(): void
public function test_transfer_paid_stamps_transferred_at(): void
public function test_transfer_paid_is_idempotent_on_already_succeeded_payout(): void
public function test_transfer_paid_fires_seller_notification(): void
public function test_transfer_paid_ignored_when_no_matching_payout(): void
public function test_transfer_failed_advances_to_failed_state(): void
public function test_transfer_failed_increments_retries(): void
public function test_transfer_failed_records_failure_reason(): void
public function test_account_updated_caches_payouts_enabled_on_store(): void
public function test_account_updated_caches_disabled_reason_on_store(): void
public function test_account_updated_ignored_when_no_matching_store(): void
```

Use `$this->postJson('/v1/stripe/webhook', $payload, ['Stripe-Signature' => 'fake'])` and stub `StripeService::verifyWebhookSignature` to return a constructed `\Stripe\Event` (mock via `Mockery::mock(StripeService::class)`).

- [ ] **Step 2-4: Standard TDD.**

### Task 10: `payouts:retry-failed` artisan command

**Files:**
- Create: `api/app/Modules/Ledger/Console/RetryFailedPayoutsCommand.php`
- (already registered in Task 8's `bootstrap/app.php` edits)
- Test: `api/tests/Feature/Payouts/RetryFailedPayoutsCommandTest.php`

```php
<?php

declare(strict_types=1);

namespace App\Modules\Ledger\Console;

use App\Models\Payout;
use App\Modules\Ledger\Services\PayoutService;
use App\Support\Enums\PayoutState;
use Illuminate\Console\Command;

class RetryFailedPayoutsCommand extends Command
{
    protected $signature = 'payouts:retry-failed';

    protected $description = 'Retry Payouts in failed state with exponential backoff (max 3 attempts).';

    public function handle(PayoutService $service): int
    {
        $now = now();

        // Pick up Payouts in failed state with retries < 3, where the
        // backoff window has elapsed: failed_at + retries hours <= now.
        $candidates = Payout::query()
            ->where('state', PayoutState::Failed)
            ->where('retries', '<', 3)
            ->whereRaw("failed_at + (retries || ' hours')::interval <= ?", [$now])
            ->get();

        $this->info("Retrying {$candidates->count()} failed payout(s)…");

        foreach ($candidates as $payout) {
            $this->line("  → {$payout->id} (attempt {$payout->retries})");
            $service->executeScheduled($payout);
        }

        return self::SUCCESS;
    }
}
```

> **Plan note (exponential backoff via SQL interval) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Two backoff strategies:
> - **(a) Linear hourly** — retry 1h after failure, 2h after first retry, 3h after second. Total: ~6 hours from initial failure to retry exhaustion.
> - **(b) Exponential** — 1h, 2h, 4h. Total: ~7 hours.
>
> Spec says "exponential backoff" but in practice the difference between linear and exponential at only 3 attempts is negligible (6h vs 7h). **Going with (a) linear via `failed_at + retries * 1 hour`** because the Postgres SQL is dramatically simpler. The Plan-3 admin-retry button (out of scope) can re-fire manually if the seller is in a hurry.

> **Plan note (raw SQL `failed_at + (retries || ' hours')::interval`) — Postgres-specific:** This uses Postgres's interval arithmetic to compute "ready to retry at" inline. Sqlite (used by some Laravel test suites) doesn't speak this dialect, but the API repo runs Postgres in CI and locally (verified — `config/database.php` default driver is `pgsql`). If a future contributor moves to sqlite for tests this query would need a `DB::raw` fallback or computed-in-PHP equivalent. Plan 1's `selectRaw` pattern set the precedent that Postgres-specific SQL in the codebase is acceptable.

- [ ] **Step 1: Write the failing test:**

```php
public function test_retries_only_failed_payouts(): void
public function test_skips_payouts_with_retries_at_max(): void
// (retries = 3 — no further retry)
public function test_respects_backoff_window(): void
// (failed_at = now, retries = 1 → not yet eligible)
public function test_includes_payouts_past_backoff_window(): void
// (failed_at = now - 2h, retries = 1 → eligible)
public function test_calls_execute_scheduled_for_each_eligible_payout(): void
public function test_succeeds_when_no_eligible_payouts(): void
```

- [ ] **Step 2-4: Standard TDD.**

---

## Phase E — Notifications (3 classes)

### Task 11: `PayoutScheduledNotification`, `PayoutSucceededNotification`, `PayoutFailedNotification`

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/PayoutScheduledNotification.php`
- Create: `api/app/Modules/Notifications/Notifications/PayoutSucceededNotification.php`
- Create: `api/app/Modules/Notifications/Notifications/PayoutFailedNotification.php`
- Update: `api/app/Modules/Notifications/Services/NotificationCategoryMap.php` (map the three new notification classes to `NotificationCategory::Payouts`)
- Test: `api/tests/Feature/Payouts/PayoutNotificationsTest.php`

Each follows the Layer 10 `ReturnRefundedNotification` skeleton — extend `Illuminate\Notifications\Notification`, implement `via()`, `toMail()`, `toArray()` (for database channel), `toDatabase()` if database is the persistence layer. Mirror the existing notifications in `app/Modules/Notifications/Notifications/`.

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Payout;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class PayoutSucceededNotification extends Notification
{
    use Queueable;

    public function __construct(public readonly Payout $payout) {}

    public function via(object $notifiable): array
    {
        return ['mail', 'database'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject('Your payout has settled')
            ->greeting("Hi {$notifiable->name},")
            ->line("Your {$this->payout->period_start->format('M j')} – {$this->payout->period_end->format('M j')} payout of \${$this->formatDollars($this->payout->net_cents)} has settled in your Stripe account.")
            ->action('View payouts', config('app.frontend_url').'/seller/payouts');
    }

    public function toDatabase(object $notifiable): array
    {
        return [
            'payout_id' => $this->payout->id,
            'net_cents' => $this->payout->net_cents,
            'period_start' => $this->payout->period_start->toIso8601String(),
            'period_end' => $this->payout->period_end->toIso8601String(),
        ];
    }

    private function formatDollars(int $cents): string
    {
        return number_format($cents / 100, 2);
    }
}
```

`PayoutScheduledNotification` and `PayoutFailedNotification` follow the same shape. For `PayoutFailedNotification`, include `$payout->failure_reason` in both the mail body and the database payload so the admin notification panel can show a useful summary.

> **Plan note (`PayoutScheduledNotification` timing — fire-at-schedule vs 24h-lookahead) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Spec hints at "24h before transfer" (line 52). Two implementations:
> - **(a) Fire from `scheduleForCycle`** — same moment the `Payout` row is created, sent right before `executeScheduled` runs. Seller gets the email/in-app a few seconds before the transfer is initiated.
> - **(b) Separate lookahead command** — a second daily-at-09:00 command that runs the day BEFORE the cycle boundary, computes which stores would have positive available balance, and notifies them.
>
> **Going with (a) for v1.** The lookahead is 24h of nicer UX but adds a meaningful surface area: a second command, a "would-be-bundled" balance computation that doesn't actually persist anything, and a synchronization risk (what if the lookahead notification fires but the actual cycle then skips the store because a late refund pushed net ≤ 0?). Document the trade-off in the spec follow-up; if sellers ask for advance notice, a lookahead command can be added later without changing any existing code.

> **Plan note (recipient pattern):** `PayoutFailedNotification` goes to both the store owner AND every admin (via `AdminRecipients::all()`). This matches the spec's "recipient: seller + admin via AdminRecipients" (line 230) and the existing Layer 10 escalation pattern (`ReturnEscalationOpenedNotification`). Scheduled and Succeeded notifications go to the seller only.

> **Plan note (no buyer-facing notifications):** Buyers never see payout notifications. Buyer-side refund mechanics are unchanged from Layer 10; the ledger is purely seller-internal.

- [ ] **Step 1: Write the failing test** — `PayoutNotificationsTest.php`:

```php
public function test_payout_scheduled_notification_routes_to_seller_only(): void
public function test_payout_scheduled_notification_includes_net_cents_in_payload(): void
public function test_payout_succeeded_notification_routes_to_seller(): void
public function test_payout_succeeded_notification_renders_dollars_correctly(): void
public function test_payout_failed_notification_routes_to_seller_and_admins(): void
public function test_payout_failed_notification_includes_failure_reason(): void
public function test_all_three_notifications_are_in_payouts_category(): void
// (assert via NotificationCategoryMap)
```

Use `Notification::fake()` from Laravel testing utilities; verify recipients via `Notification::assertSentTo`.

- [ ] **Step 2: Run, confirm failure.**

- [ ] **Step 3: Implement** all three notification classes + the `NotificationCategoryMap` update.

- [ ] **Step 4: Run; iterate to 7/7 PASS.**

---

## Phase F — Endpoints + OpenAPI + types + api-client

### Task 12: `GET /v1/stores/{store}/payouts` + `GET /v1/stores/{store}/payouts/{payout}`

**Files:**
- Create: `api/app/Modules/Ledger/Controllers/StorePayoutsController.php`
- Update: `api/app/Modules/Ledger/routes.php`
- Create: `api/app/Modules/Ledger/Resources/PayoutResource.php` *(API resource transformer)*
- Test: `api/tests/Feature/Payouts/StorePayoutsEndpointsTest.php`

```php
<?php

declare(strict_types=1);

namespace App\Modules\Ledger\Controllers;

use App\Models\Payout;
use App\Models\Store;
use App\Modules\Ledger\Resources\PayoutResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StorePayoutsController
{
    public function index(Request $request, Store $store): JsonResponse
    {
        $payouts = $store->payouts()
            ->orderByDesc('scheduled_for')
            ->paginate(
                perPage: (int) min(50, max(1, $request->integer('per_page', 25))),
            );

        return response()->json([
            'data' => PayoutResource::collection($payouts->items()),
            'meta' => [
                'current_page' => $payouts->currentPage(),
                'last_page' => $payouts->lastPage(),
                'total' => $payouts->total(),
            ],
        ]);
    }

    public function show(Store $store, Payout $payout): JsonResponse
    {
        abort_unless($payout->store_id === $store->id, 404);

        $payout->load('ledgerEntries');

        return response()->json([
            'data' => (new PayoutResource($payout))->withLedgerEntries(),
        ]);
    }
}
```

Routes (extending the existing Ledger module `routes.php`):

```php
Route::middleware(['auth:sanctum', 'store.owner'])->group(function () {
    Route::get('/stores/{store}/balance', [StoreBalanceController::class, 'show']);
    Route::get('/stores/{store}/payouts', [StorePayoutsController::class, 'index']);
    Route::get('/stores/{store}/payouts/{payout}', [StorePayoutsController::class, 'show']);
});
```

> **Plan note (`/stores/{store}/payouts` vs `/seller/payouts`) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Spec line 191 says `/v1/seller/payouts`. Plan 1's task 8 already established the divergence: store-scoped endpoints match the existing `dashboard/metrics` + Plan-1 `/balance` precedent and inherit free auth via the `store.owner` middleware. **Plan 2 follows Plan 1** — `/v1/stores/{store}/payouts` and `/v1/stores/{store}/payouts/{payout}`. The OpenAPI doc reflects the actual paths; the spec is a forward-looking design and the implementation has already drifted on this point.

> **Plan note (eager-load ledger entries on the show endpoint):** Single-payout detail includes the bundled ledger entries so the frontend can render a breakdown ("3 orders, 1 refund, 1 label cost"). The list endpoint does NOT include entries — they'd 5-10x the payload for the common case (50 payouts × 20 entries each).

- [ ] **Step 1: Write the failing test:**

```php
public function test_index_returns_paginated_payouts_for_store_owner(): void
public function test_index_orders_by_scheduled_for_desc(): void
public function test_index_returns_403_for_non_owner(): void
public function test_index_requires_authentication(): void
public function test_index_only_returns_own_stores_payouts(): void
public function test_show_returns_single_payout_with_ledger_entries(): void
public function test_show_returns_404_when_payout_belongs_to_different_store(): void
public function test_show_returns_403_for_non_owner(): void
public function test_response_includes_state_period_start_period_end_net_cents(): void
public function test_response_includes_stripe_transfer_id_when_in_flight(): void
```

- [ ] **Step 2-4: Standard TDD.**

### Task 13: OpenAPI + types + api-client

**Files:**
- Update: `api/contracts/openapi.yaml`
- Sync: `~/projects/alqove-web/contracts/openapi.yaml` (via `./bin/sync-openapi.sh`)
- Build: `npm run build:types`
- Create: `web/packages/api-client/src/endpoints/payouts.ts`
- Update: `web/packages/api-client/src/index.ts` (export the new endpoints module)
- Update: `web/packages/api-client/src/client.ts` (register payouts on the client)

OpenAPI fragments:

```yaml
/v1/stores/{store}/payouts:
  get:
    tags: [Seller]
    operationId: listStorePayouts
    summary: Paginated list of payouts for a store (seller-owner only).
    parameters:
      - { $ref: '#/components/parameters/StoreIdPathParam' }
      - in: query
        name: per_page
        schema: { type: integer, minimum: 1, maximum: 50, default: 25 }
    responses:
      '200':
        description: Paginated payouts
        content:
          application/json:
            schema:
              type: object
              properties:
                data:
                  type: array
                  items: { $ref: '#/components/schemas/Payout' }
                meta:  { $ref: '#/components/schemas/PaginationMeta' }
      '401': { $ref: '#/components/responses/Unauthenticated' }
      '403': { $ref: '#/components/responses/Forbidden' }

/v1/stores/{store}/payouts/{payout}:
  get:
    tags: [Seller]
    operationId: getStorePayout
    parameters:
      - { $ref: '#/components/parameters/StoreIdPathParam' }
      - in: path
        name: payout
        required: true
        schema: { type: string, format: uuid }
    responses:
      '200':
        description: Payout detail with bundled ledger entries
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/PayoutWithEntries' }
      '404': { $ref: '#/components/responses/NotFound' }

# components.schemas additions:
Payout:
  type: object
  required: [id, store_id, period_start, period_end, scheduled_for, gross_cents, debits_cents, net_cents, state, retries]
  properties:
    id:                 { type: string, format: uuid }
    store_id:           { type: string, format: uuid }
    period_start:       { type: string, format: date-time }
    period_end:         { type: string, format: date-time }
    scheduled_for:      { type: string, format: date-time }
    gross_cents:        { type: integer }
    debits_cents:       { type: integer }
    net_cents:          { type: integer }
    state:              { type: string, enum: [scheduled, in_flight, succeeded, failed, void] }
    stripe_transfer_id: { type: string, nullable: true }
    transferred_at:     { type: string, format: date-time, nullable: true }
    failed_at:          { type: string, format: date-time, nullable: true }
    failure_reason:     { type: string, nullable: true }
    retries:            { type: integer }

PayoutWithEntries:
  allOf:
    - { $ref: '#/components/schemas/Payout' }
    - type: object
      properties:
        ledger_entries:
          type: array
          items: { $ref: '#/components/schemas/SellerLedgerEntry' }

SellerLedgerEntry:
  type: object
  required: [id, entry_type, direction, amount_cents, available_at, description]
  properties:
    id:           { type: string, format: uuid }
    entry_type:   { type: string }
    direction:    { type: string, enum: [credit, debit] }
    amount_cents: { type: integer }
    source_type:  { type: string, nullable: true }
    source_id:    { type: string, format: uuid, nullable: true }
    available_at: { type: string, format: date-time }
    description:  { type: string, nullable: true }
    created_at:   { type: string, format: date-time }
```

`web/packages/api-client/src/endpoints/payouts.ts`:

```ts
import type { AlqoveClient } from '../client';

export interface Payout {
  id: string;
  store_id: string;
  period_start: string;
  period_end: string;
  scheduled_for: string;
  gross_cents: number;
  debits_cents: number;
  net_cents: number;
  state: 'scheduled' | 'in_flight' | 'succeeded' | 'failed' | 'void';
  stripe_transfer_id: string | null;
  transferred_at: string | null;
  failed_at: string | null;
  failure_reason: string | null;
  retries: number;
}

export interface SellerLedgerEntry {
  id: string;
  entry_type: string;
  direction: 'credit' | 'debit';
  amount_cents: number;
  source_type: string | null;
  source_id: string | null;
  available_at: string;
  description: string | null;
  created_at: string;
}

export interface PayoutWithEntries extends Payout {
  ledger_entries: SellerLedgerEntry[];
}

export interface PayoutListResponse {
  data: Payout[];
  meta: { current_page: number; last_page: number; total: number };
}

export interface PayoutDetailResponse {
  data: PayoutWithEntries;
}

export function createPayoutsEndpoints(client: AlqoveClient) {
  return {
    listForStore(storeId: string, perPage = 25) {
      return client.get<PayoutListResponse>(
        `/v1/stores/${storeId}/payouts?per_page=${perPage}`,
      );
    },
    getForStore(storeId: string, payoutId: string) {
      return client.get<PayoutDetailResponse>(
        `/v1/stores/${storeId}/payouts/${payoutId}`,
      );
    },
  };
}
```

- [ ] **Step 1: Edit OpenAPI YAML; validate via `python3 -c "import yaml; yaml.safe_load(open('api/contracts/openapi.yaml'))"`.**
- [ ] **Step 2: Sync to web** via `./bin/sync-openapi.sh`.
- [ ] **Step 3: `npm run build:types`** regenerates `web/packages/types/src/generated.ts`.
- [ ] **Step 4: Implement** the new endpoints module + wire onto `AlqoveClient`.
- [ ] **Step 5: `npm run typecheck`** — clean.

---

## Phase G — Frontend (`useStorePayouts` hook + `/seller/payouts` page)

### Task 14: TanStack hooks

**Files:**
- Create: `web/src/lib/queries/use-store-payouts.ts`
- Create: `web/src/lib/queries/__tests__/use-store-payouts.test.ts`

```ts
'use client';

import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { Payout, PayoutWithEntries } from '@alqove/api-client';

export const PAYOUT_KEYS = {
  listForStore: (storeId: string, perPage: number) =>
    ['seller', 'payouts', 'list', storeId, perPage] as const,
  detailForStore: (storeId: string, payoutId: string) =>
    ['seller', 'payouts', 'detail', storeId, payoutId] as const,
};

export function useStorePayouts(storeId: string | null | undefined, perPage = 25) {
  return useQuery({
    queryKey: PAYOUT_KEYS.listForStore(storeId ?? 'none', perPage),
    enabled: Boolean(storeId),
    queryFn: async () => {
      const res = await api.payouts.listForStore(storeId!, perPage);
      return res;
    },
  });
}

export function useStorePayout(
  storeId: string | null | undefined,
  payoutId: string | null | undefined,
) {
  return useQuery({
    queryKey: PAYOUT_KEYS.detailForStore(storeId ?? 'none', payoutId ?? 'none'),
    enabled: Boolean(storeId && payoutId),
    queryFn: async () => {
      const res = await api.payouts.getForStore(storeId!, payoutId!);
      return res.data;
    },
  });
}
```

- [ ] **Step 1: Write the failing test** — assert each hook calls the correct endpoint and surfaces the response. Use `renderHook` + a wrapper `QueryClientProvider`. ~2-3 tests.

- [ ] **Step 2-4: Standard TDD.**

### Task 15: `/seller/payouts` page

**Files:**
- Create: `web/src/app/(seller)/seller/payouts/page.tsx`
- Create: `web/src/app/(seller)/seller/payouts/payouts-list-client.tsx`
- Create: `web/src/app/(seller)/seller/payouts/__tests__/payouts-list-client.test.tsx`
- Update: `web/src/components/seller/seller-nav.tsx` *(or wherever the seller sidebar lives — add a "Payouts" entry between "Orders" and "Returns")*

The page mirrors `seller/orders/page.tsx` (Suspense boundary + client component):

```tsx
// page.tsx
import { Suspense } from 'react';
import { PayoutsListClient } from './payouts-list-client';

export default function Page() {
  return (
    <Suspense fallback={null}>
      <PayoutsListClient />
    </Suspense>
  );
}
```

`payouts-list-client.tsx` — table with columns: Period (period_start → period_end), Net, State, Stripe ref. State badge colours: `scheduled` neutral, `in_flight` blue, `succeeded` green, `failed` red, `void` grey. Stripe ref is a deep-link to `https://dashboard.stripe.com/connect/transfers/{stripe_transfer_id}` when present.

```tsx
'use client';

import { useStorePayouts } from '@/lib/queries/use-store-payouts';
import { useMe } from '@/lib/queries/use-me';
import { formatPrice } from '@alqove/shared';
import type { Payout } from '@alqove/api-client';

const STATE_LABELS: Record<Payout['state'], { label: string; tone: string }> = {
  scheduled: { label: 'Scheduled', tone: 'bg-cream/30 text-ink' },
  in_flight: { label: 'In transit', tone: 'bg-sky/20 text-sky-dark' },
  succeeded: { label: 'Paid', tone: 'bg-forest/20 text-forest' },
  failed:    { label: 'Failed', tone: 'bg-coral/30 text-coral-dark' },
  void:      { label: 'Voided', tone: 'bg-forest/10 text-forest/60' },
};

export function PayoutsListClient() {
  const me = useMe();
  const storeId = me.data?.user?.owned_store_id ?? null;
  const { data, isLoading } = useStorePayouts(storeId);

  if (isLoading) return <div className="p-6 text-forest/60">Loading payouts…</div>;
  if (!data || data.data.length === 0) {
    return (
      <div className="p-6">
        <h1 className="text-2xl font-semibold text-ink">Payouts</h1>
        <p className="mt-3 text-sm text-forest/60">No payouts yet. Your first payout will appear after your next cycle settles.</p>
      </div>
    );
  }

  return (
    <div className="p-6">
      <h1 className="text-2xl font-semibold text-ink">Payouts</h1>
      <table className="mt-4 w-full text-sm">
        <thead><tr className="text-left text-forest/70">
          <th className="py-2">Period</th>
          <th>Net</th>
          <th>State</th>
          <th>Stripe</th>
        </tr></thead>
        <tbody>
          {data.data.map((p) => {
            const tone = STATE_LABELS[p.state];
            return (
              <tr key={p.id} className="border-t border-forest/10">
                <td className="py-2">{formatPeriod(p.period_start, p.period_end)}</td>
                <td>{formatPrice(p.net_cents)}</td>
                <td><span className={`rounded px-2 py-0.5 text-xs ${tone.tone}`}>{tone.label}</span></td>
                <td>
                  {p.stripe_transfer_id ? (
                    <a className="text-sky-dark underline"
                       href={`https://dashboard.stripe.com/connect/transfers/${p.stripe_transfer_id}`}
                       target="_blank" rel="noreferrer">
                      {p.stripe_transfer_id.slice(0, 12)}…
                    </a>
                  ) : '—'}
                </td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

function formatPeriod(start: string, end: string): string {
  const s = new Date(start).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' });
  const e = new Date(end).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' });
  return `${s} – ${e}`;
}
```

Tests assert: (1) loading state renders, (2) empty state renders the "no payouts yet" message, (3) populated state renders one row per payout, (4) state badges render with the right tone class, (5) Stripe link absent when `stripe_transfer_id === null`.

> **Plan note (no payout detail page in Plan 2) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** The spec mentions a per-payout detail view; the API endpoint `/v1/stores/{store}/payouts/{payout}` is in scope and tested. But the **detail page UI** (showing bundled ledger entries) overlaps heavily with the Plan 3 `/seller/statements` page (date-range ledger view). Plan 2 ships the list page + the API endpoint, defers the detail UI to Plan 3 where it composes naturally with statements. The detail endpoint is still useful — it's how `/seller/statements` will deep-link from a payout reference. Document the trade-off.

> **Plan note (Stripe dashboard URL format):** `https://dashboard.stripe.com/connect/transfers/{transfer_id}` works for the platform-account view. Sellers don't have access to that URL — it's an internal admin convenience surfaced to sellers as a transparency feature. If a seller clicks the link logged out, Stripe redirects them to a login wall. **This is fine** — it's a power-user surface, not a primary CTA. Document the assumption.

- [ ] **Step 1: Write the failing tests** (~5 cases).
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement** the page + client component + add the nav entry.
- [ ] **Step 4: Run; iterate to 5/5 PASS.**

---

## Phase H — Wrap-up

### Task 16: Full sweep

- [ ] **Step 1: Backend** — `docker compose exec -T laravel.test php artisan test`. Expected: **761 → ~825** (~64 new tests across:  10 schema + 3 store connect health + 6 ledger-writer-payout-settled + 6 PayoutSchedule extension + 13 PayoutService schedule + 16 PayoutService execute + 7 RunPayoutCycleCommand + 12 StripePayoutWebhook + 6 RetryFailedPayoutsCommand + 7 notifications + 10 endpoints + 3 StripeService wrapper).

- [ ] **Step 2: Backend lint** — `cd api && ./vendor/bin/pint app/Modules/Ledger app/Modules/Checkout/Services/StripeService.php app/Modules/Checkout/Controllers/CheckoutController.php app/Modules/Notifications/Notifications/PayoutScheduledNotification.php app/Modules/Notifications/Notifications/PayoutSucceededNotification.php app/Modules/Notifications/Notifications/PayoutFailedNotification.php app/Models/Payout.php app/Models/Store.php app/Support/Enums/PayoutState.php tests/Feature/Payouts bootstrap/app.php`. Expected PASS or auto-fix.

- [ ] **Step 3: Web typecheck** — `npm run typecheck` at root + `npx tsc --noEmit` in `web/`. Expected clean.

- [ ] **Step 4: Web lint** — `npm run lint`. Expected baseline.

- [ ] **Step 5: Web tests** — `npm run test`. Expected: **249 → ~260** (~11 new: 2-3 hook tests + 5 list-page tests + maybe 1-2 incidental).

- [ ] **Step 6: Local build** — `npm run build:web`. Should produce a clean static build; `/seller/payouts/page.tsx` is the new route.

### Task 17: Manual QA scenarios (cron is the riskiest piece)

Backend cron logic is hard to fully exercise from PHPUnit. Run these scenarios against the dev stack before pushing:

- [ ] **Scenario A — happy path full cycle.**
  1. Seed: a Store with `stripe_connect_id` set (use Stripe test mode account), one delivered Order with `seller_payout = 5000`, ledger `OrderEarned` entry whose `available_at` is now in the past.
  2. `php artisan payouts:run-cycle --force` (force flag bypasses boundary check).
  3. Verify: a `Payout` row exists with `state = in_flight`, `stripe_transfer_id` populated.
  4. Send a fake `transfer.paid` webhook (use `stripe trigger transfer.paid` CLI or hit `/v1/stripe/webhook` with a signed payload).
  5. Verify: Payout → `succeeded`, `payout_settled` ledger entry exists with `payout_id` matching, `BalanceService::forStore` now returns 0 available for that store.

- [ ] **Scenario B — Stripe transfer fails.**
  1. Seed: as in A, but use an invalid `stripe_connect_id` (e.g., `acct_does_not_exist`).
  2. `php artisan payouts:run-cycle --force`.
  3. Verify: Payout → `failed`, `failure_reason` includes "No such account", `retries = 1`.
  4. `php artisan payouts:retry-failed` (within the 1h backoff window) — should be a no-op.
  5. Wait > 1h OR manually set `failed_at` to a past timestamp; rerun `payouts:retry-failed`. Verify retries = 2.
  6. Repeat until retries = 3; verify `PayoutFailedNotification` fired to both seller and admin (check the database `notifications` table).

- [ ] **Scenario C — Connect account restricted via webhook.**
  1. Seed: a Store with `payouts_enabled = true`.
  2. Send a fake `account.updated` webhook with `payouts_enabled = false, requirements.disabled_reason = 'requirements.past_due'`.
  3. Verify: `stores.payouts_enabled = false`, `stores.disabled_reason = 'requirements.past_due'`.
  4. `php artisan payouts:run-cycle --force`. The store should be skipped entirely (not even a `void` Payout — pre-filter in `PayoutService::scheduleForCycle`).
  5. Restore `payouts_enabled = true` via webhook with the inverse payload; rerun. Payout now created.

- [ ] **Scenario D — Negative balance rollover.**
  1. Seed: a Store with `OrderEarned` credit of 5000 (past `available_at`) + a `OrderRefunded` debit of 6000 (past `available_at`). Net = -1000.
  2. `php artisan payouts:run-cycle --force`.
  3. Verify: NO Payout row created for this store. Both ledger entries still have `payout_id = NULL`.
  4. Add another `OrderEarned` credit of 2000. Rerun. Now net = 1000 → one Payout for 1000, all three entries get `payout_id` stamped.

- [ ] **Scenario E — Idempotency.**
  1. Seed: as A.
  2. Run `payouts:run-cycle --force` twice in a row.
  3. Verify: second run creates no new Payout. Existing Payout's `state` unchanged (don't double-fire executeScheduled if first run already advanced to in_flight).

### Task 18: Commit + push

- [ ] **Step 1:** In `~/projects/alqove-api`, stage `app config contracts database tests docs bootstrap/app.php` and commit:

```
feat(payouts): biweekly cron, Stripe transfer, retry, webhooks, notifications, endpoints
```

- [ ] **Step 2:** In `~/projects/alqove-web`, stage `packages web contracts` and commit:

```
feat(payouts): /seller/payouts history page + payouts api-client + hooks
```

- [ ] **Step 3:** Push both. Watch GitHub Actions. **Expected gotchas:**
  - The new Postgres CHECK constraints (`payouts_state_valid`, `payouts_net_math`) need verbatim string match — fix in the migration if CI reports a constraint syntax error.
  - Webhook tests must mock `StripeService::verifyWebhookSignature` because the Stripe SDK refuses to parse a fake signature against the real secret. Pattern: `$stripe = Mockery::mock(StripeService::class)->makePartial(); $stripe->shouldReceive('verifyWebhookSignature')->andReturn($fakeEvent); $this->app->instance(StripeService::class, $stripe);`.
  - The `failed_at + (retries || ' hours')::interval` SQL in `RetryFailedPayoutsCommand` is Postgres-specific (called out in Plan 1's CI postmortems). If the test suite ever switches engines, fall back to PHP-side filtering.

---

## Open items deferred to Plan 3 (or later)

- **`/seller/statements` page** with date-range ledger view + CSV export — Plan 3.
- **Connect account health banner** consuming the `payouts_enabled` + `disabled_reason` columns Plan 2 caches — Plan 3.
- **Late-refund reverseTransfer integration.** Spec lines 153-156 describe the conditional `StripeService::reverseTransfer` call when refunding an order whose `order_earned` ledger entry has `payout_id IS NOT NULL` (funds already shipped). Plan 2 ships everything that *makes* `payout_id` non-null on a credit entry; Plan 3 wires the conditional. Until then, late refunds still leave the existing hole.
- **Admin `/admin/financials/payouts` queue** — retry button, void button, resolution notes, activity-log rows (`payout.retried`, `payout.voided`, `payout.resolution_noted`) — Plan 3.
- **Admin `/admin/financials/balances`** aggregate view — Plan 3.
- **`ManualAdjustment` model + admin endpoint** (`POST /v1/admin/stores/{store}/ledger-adjustments`) + admin store-detail "Ledger" tab — Plan 3. The `LedgerEntryType::AdjustmentCredit | AdjustmentDebit` enums Plan 1 shipped finally get writers.
- **`voided_by_admin_id` + `voided_at` columns on `payouts`** — added in Plan 3's migration when the admin void endpoint lands.
- **24h-lookahead `PayoutScheduledNotification`.** Plan 2 fires at `scheduleForCycle` time (same day as cron); a separate "tomorrow's payouts" lookahead command can be added in Plan 3 if sellers ask for advance notice.
- **Per-payout detail page in the frontend** (`/seller/payouts/[id]`). Plan 2 ships the API endpoint; the UI composes naturally with Plan 3's statements page (a deep-link from a ledger entry's `payout_id` opens the statements view filtered to that payout's bundled entries). Don't ship a standalone detail page that duplicates statements.
- **`transfer.reversed` webhook handling.** Out of scope for Plan 2; Plan 3+ handles admin-initiated and Stripe-initiated reversals in the same code path.
- **Legacy `TransferFundsToStore` capture-time job audit.** Spec line 249 calls out that the current per-order capture-time Stripe transfer needs to be retired (or repurposed) — Plan 3 will. Plan 2 leaves it untouched and uses the parallel `StripeService::createMarketplacePayout` method.
- **EasyPost label-refund automated handling.** Out of scope for Plan 2; Plan 3 ships the `ManualAdjustment` admin endpoint as the manual recovery path. The spec defers automated EasyPost-side label-refund webhooks to a later layer.
- **Per-store custom payout cadence.** Spec out-of-scope; one marketplace-wide cadence in v1.
- **Multi-currency.** USD only; deliberate KISS choice. No `currency` column on `payouts`.
- **Disputed-period payout pause.** Spec line 63 documents that Layer 8 dispute mechanics already handle this; Plan 2 doesn't re-engineer it. If a dispute opens on an order whose `order_earned` is still in hold, the dispute flow either writes an offsetting debit (resolution against seller) or the credit releases normally (resolution for seller).
- **Lookahead admin alerts** for stores approaching retry exhaustion. Plan 2 only fires `PayoutFailedNotification` at retries = 3; a "this payout has failed twice, may exhaust soon" admin alert is a Plan 3+ UX improvement.
- **Payout detail in seller notifications.** Plan 2's `PayoutSucceededNotification` mail mentions "$X.XX has settled" but doesn't itemize. Plan 3's statements page is the itemization surface; the notification deep-links to it.
- **In-flight Payout state stuck after cron kill.** If `payouts:run-cycle` is killed between the synchronous Stripe success and the DB write of `stripe_transfer_id`, the Payout sits at `in_flight` with `stripe_transfer_id = NULL`. The `transfer.paid` webhook would arrive but not match. Plan 3's admin queue surfaces these for manual reconciliation; Plan 2's open item is to flag this risk in the manual QA scenarios (Scenario E covers idempotency; this is a different failure mode).
