# Layer 11 Plan 3: Statements + Admin Polish

> **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:** Close Layer 11. Plan 1 stood up the ledger; Plan 2 shipped the cron + the `Payout` state machine. Plan 3 turns the ledger into a financial-reporting surface for sellers, gives admins the financials-ops queue they need to keep the marketplace running, and closes the last money-movement gap: refunds issued *after* a payout has already shipped to a seller's Connect balance now reverse the Stripe transfer in addition to writing the ledger debit. The plan ships: (1) `/seller/statements` — date-range ledger table with CSV export; (2) a Connect-account health banner that consumes the `stores.payouts_enabled` + `disabled_reason` Plan 2 cached; (3) the late-refund `reverseTransfer` integration on `ReturnRefundIssuer::issue`, gated on the original `order_earned` credit's `payout_id IS NOT NULL`; (4) the admin financials surfaces — aggregate balances page, failed-payouts queue with retry/void actions, and an admin store-detail "Ledger" tab with a "New adjustment" button; (5) the `ManualAdjustment` model + `POST /v1/admin/stores/{store}/ledger-adjustments` endpoint backed by the last two `LedgerWriter` methods (`recordAdjustmentCredit`, `recordAdjustmentDebit`), completing the entry-type enum coverage; (6) `LedgerAdjustmentNotification` to the seller whenever an admin posts an adjustment on their store; (7) the seller balance widget gains an "Account on hold" inline state. **Acceptance:** an admin can drill from `/admin/financials/balances` into a store's ledger tab, post a manual adjustment, and watch it land on the seller's `/seller/statements` page within seconds. The seller can filter by date range, export a CSV that matches the on-screen table row-for-row, and see their next payout date update. Failed payouts can be retried from `/admin/financials/payouts` with one click. A late return (refund > 14 days after delivery, on an order whose `order_earned` already paid out) writes both the ledger debit AND a Stripe `reverseTransfer` against the settled `Payout`. A within-hold return continues to write the debit only — Plan 2's behaviour preserved.

**Architecture:** (1) **Schema** — one migration creates `manual_adjustments` (UUID PK, `store_id` FK, `admin_user_id` FK to `users`, `type` enum credit|debit, `amount_cents` unsigned int, `reason` text, timestamps). No other schema changes; the `seller_ledger` enum stubs for `AdjustmentCredit | AdjustmentDebit` already shipped in Plan 1. (2) **Writer extension** — two new methods on `LedgerWriter` (the 5th and 6th, completing the enum): `recordAdjustmentCredit(Store, int $amountCents, ManualAdjustment $adjustment): SellerLedger` and `recordAdjustmentDebit(Store, int $amountCents, ManualAdjustment $adjustment): SellerLedger`. Both stamp `source = $adjustment` and `available_at = now()`; the description embeds the truncated reason. A new orchestrator service `LedgerAdjustmentService::apply(Store, AdjustmentType, int, string, User $admin): ManualAdjustment` creates the `ManualAdjustment` row, writes the ledger entry inside one transaction, logs to `spatie/activitylog` (`ledger.admin_adjustment`), and dispatches `LedgerAdjustmentNotification` to the store owner. (3) **Late-refund reverseTransfer** — `ReturnRefundIssuer::issue` gains a post-Stripe-refund query: `SellerLedger::where('source_type', Order::class)->where('source_id', $order->id)->where('entry_type', 'order_earned')->whereNotNull('payout_id')->first()`. If the row exists and the matching `Payout` has `stripe_transfer_id != null`, call `StripeService::reverseTransfer(transferId: $payout->stripe_transfer_id, amountCents: $refundAmount, idempotencyKey: "late-return-reversal:{$return->id}", metadata: [...])`. The ledger debit (already written by Plan 1's `ReturnTransitioner::markReceived` hook) stays. If no such credit exists, or the credit has `payout_id IS NULL` (still in hold), skip the reversal — Plan 2's behaviour. The conditional is gated entirely on ledger state; no new columns. (4) **Admin endpoints** — `POST /v1/admin/stores/{store}/ledger-adjustments` invokes `LedgerAdjustmentService`. `GET /v1/admin/financials/balances` returns marketplace-wide aggregate + paginated per-store breakdown (uses `BalanceService` per-store + a single `selectRaw` aggregate across all stores). `GET /v1/admin/financials/payouts` lists `Payout` rows with `?state=` filter (default `failed`), paginated. `POST /v1/admin/payouts/{payout}/retry` — admin-initiated retry; delegates to `PayoutService::executeScheduled` after resetting `state = scheduled` and clearing `failed_at / failure_reason` (retries unchanged so backoff bookkeeping survives). `POST /v1/admin/payouts/{payout}/void` — flips state to `void`, releases bundled entries (unsets `payout_id`), records `voided_by_admin_id` + `voided_at` + `resolution_note` in a new admin block on the `payouts` table (added in Phase A's second migration). All admin actions write `spatie/activitylog` rows: `payout.retried`, `payout.voided`, `ledger.admin_adjustment`. (5) **Seller endpoints** — `GET /v1/stores/{store}/ledger` returns paginated entries for a date range. `GET /v1/stores/{store}/statements/export.csv` streams CSV via `response()->streamDownload(...)` with `cursor()` iteration so memory stays flat for large windows. (6) **Notification** — `LedgerAdjustmentNotification` (seller-recipient only) mirrors Layer 10's `ReturnEscalationResolvedNotification` shape. (7) **Frontend** — `/seller/statements` page with a date-range picker, ledger entries table, "Export CSV" button. Connect-health banner component dropped at the top of `/seller` home and `/seller/payouts` only (not every seller page — see plan note). Admin financials section: nest `/admin/financials/balances` + `/admin/financials/payouts` under a new "Financials" parent label in the admin sidebar. Admin store-detail page gains a "Ledger" tab (existing page has no tab system today — Plan 3 introduces a minimal `Tabs` shim). The "New adjustment" modal posts to the admin endpoint and invalidates the ledger query on success. (8) **Activity log** — three new admin log entries, all hooked through `spatie/laravel-activitylog`'s `activity('admin')` helper, matching the existing `AdminOrderActions::reverseTransfer` and `StoreSuspender` patterns.

**Tech Stack:** Laravel 12, PHPUnit class-based feature tests under `api/tests/Feature/Ledger/` + `api/tests/Feature/Payouts/` + `api/tests/Feature/Admin/` (mirrors existing precedents), Postgres 17, `spatie/laravel-activitylog` (already wired — used by `StoreSuspender`, `AdminOrderActions`, `DisputeAdjudicator`), Stripe PHP SDK (existing `StripeService::reverseTransfer`), Carbon for date math, `response()->streamDownload(...)` for CSV streaming, 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` (Plan breakdown § Plan 3, "Money flow walkthrough — Refund after hold window", "Authorization & audit", "API surfaces", "UI surfaces").

**Prerequisites:**
- **API head:** `38f5f3c` (Plan 2 fully shipped + 2 CI test-helper fixes: `38f5f3c fix(payouts): test helper auto-computes net_cents from gross/debits`, `cece02f fix(payouts): test fixtures pass coherent gross/debits/net values`, `726ec0b feat(payouts): biweekly aggregated payouts + cron + webhooks + retry`). **890 tests passing.**
- **Web head:** `8e141e9` (Plan 2 frontend shipped: `8e141e9 feat(payouts): seller payouts page + state badge`, `1e16297 feat(payouts): seller balance widget on dashboard home`). **267 tests passing, 1 skipped.**
- **Plan 1 + Plan 2 components Plan 3 builds on:**
  - `App\Modules\Ledger\Services\LedgerWriter` at `api/app/Modules/Ledger/Services/LedgerWriter.php` — Plan 3 adds two methods (`recordAdjustmentCredit`, `recordAdjustmentDebit`). Plan 1+2 shipped four: `recordOrderEarned`, `recordOrderRefunded`, `recordLabelCost`, `recordPayoutSettled`. Plan 3 closes the enum coverage.
  - `App\Modules\Ledger\Services\BalanceService::forStore(Store): BalanceSnapshot` — Plan 3 reuses verbatim for the per-store rows on `/admin/financials/balances`. The marketplace-wide aggregate is a new single-query method on the same service.
  - `App\Modules\Ledger\Services\PayoutService` at `api/app/Modules/Ledger/Services/PayoutService.php` — Plan 3 calls `executeScheduled` from the admin retry endpoint; **no new methods on this class.**
  - `App\Modules\Notifications\Services\NotificationCategoryMap` — Plan 3 adds one new mapping (`LedgerAdjustmentNotification::class => NotificationCategory::Payouts`).
  - `App\Models\SellerLedger` — append-only model already shipped; Plan 3 only reads from it (aggregate queries) and writes via `LedgerWriter`.
  - `App\Models\Payout` — Plan 3 mutates `state`, `voided_at`, `voided_by_admin_id`, `resolution_note` via direct Eloquent `update()` (NOT append-only). The retry path resets state to `scheduled` and clears `failed_at`/`failure_reason`; the void path flips state and detaches bundled ledger entries by setting their `payout_id = NULL` via the QueryBuilder-bypass pattern Plan 2 used in `scheduleForStore`.
  - `App\Modules\Checkout\Services\StripeService::reverseTransfer(string $transferId, int $amountCents, string $idempotencyKey, array $metadata = []): TransferReversal` — verified actual signature at `api/app/Modules/Checkout/Services/StripeService.php:166-180`. Plan 3 calls it from `ReturnRefundIssuer::issue` with `idempotencyKey: "late-return-reversal:{$return->id}"` so retries dedupe. **Already in use by `DisputeAdjudicator`, `AdminOrderActions::reverseTransfer`, `ReconcileFailedMoneyMovements`, `AdminDisputeController`.** Plan 3 adds the 5th caller — `ReturnRefundIssuer`. The refund-return path currently never calls reverseTransfer; this is the gap Plan 1 noted (Plan 1's audit at Phase C / Task 4 confirmed `ReturnRefundIssuer::issue` only calls `StripeService::refundForOrder`).
  - `App\Modules\Returns\Services\ReturnRefundIssuer::issue(OrderReturn $return, ?int $overrideAmountCents = null, bool $includeOriginalShipping = false): void` at `api/app/Modules/Returns/Services/ReturnRefundIssuer.php` — Plan 3 extends with a post-refund conditional reverseTransfer call. Constructor gains no new dependencies (`StripeService` already injected).
  - `App\Models\Store::payouts_enabled` + `disabled_reason` — Plan 2 cached these via the `account.updated` webhook. Plan 3 surfaces them in the seller UI banner + admin `/admin/financials/balances` per-store row.
  - `App\Modules\Admin\Services\AdminOrderActions` at `api/app/Modules/Admin/Services/AdminOrderActions.php:184-193` — Plan 3 mirrors the `activity('admin')->causedBy($admin)->performedOn(...)->withProperties([...])->log('...')` pattern for the three new admin log entries.
  - Admin route group at `api/app/Modules/Admin/routes.php` — uses `middleware(['auth:sanctum', 'admin'])->prefix('admin')`. Plan 3 adds five routes to this same group.
  - Admin layout at `web/src/app/(admin)/layout.tsx` — flat nav-items list; verified. **Plan 3 adds two new nav entries.** No nested-nav primitive exists today; see plan note in Phase H.
  - Seller layout at `web/src/app/(seller)/layout.tsx` — flat nav-items list including `/seller/payouts`. **Plan 3 adds one entry: `/seller/statements`.**
  - Admin store-detail page at `web/src/app/(admin)/admin/stores/[id]/store-detail-client.tsx` (217 lines) — does NOT use tabs today. Plan 3 introduces a minimal `Tabs` component (or rendered in-page section), see plan note in Phase H.

**Successor plan:** None — Plan 3 closes Layer 11. Layer 12 starts a new layer (likely seller analytics or reviews/ratings; out of scope here).

---

## Phase A — Schema (`manual_adjustments` table + `payouts` admin columns)

### Task 1: `AdjustmentType` enum + `manual_adjustments` migration + model + factory

**Files:**
- Create: `api/app/Support/Enums/AdjustmentType.php`
- Create: `api/database/migrations/2026_05_12_100001_create_manual_adjustments_table.php`
- Create: `api/app/Models/ManualAdjustment.php`
- Create: `api/database/factories/ManualAdjustmentFactory.php`
- Update: `api/app/Models/Store.php` (add `manualAdjustments(): HasMany` relation)
- Update: `api/app/Models/User.php` (add `manualAdjustments(): HasMany` relation — admin's adjustment history)
- Test: `api/tests/Feature/Ledger/ManualAdjustmentSchemaTest.php`

> **Plan note (separate `manual_adjustments` table vs ledger-entry-only) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** The spec explicitly mentions "ManualAdjustment model" (line 51, line 160, line 240). Two design choices:
> - **(a) Separate `manual_adjustments` table** with `admin_user_id`, `type`, `amount_cents`, `reason`, timestamps. The seller_ledger entry's `source_type = ManualAdjustment::class, source_id = $adjustment->id` links them. Adjustments are queryable independently of the ledger — useful for an admin "all adjustments I've posted" view, and for audit ("show me every adjustment with reason like 'goodwill'").
> - **(b) Ledger-entry-only.** Reason in `description`, admin id squirrelled into the activitylog row only.
> **Going with (a).** Spec says the model exists; richer audit surface; matches the `OrderReturn` / `ReturnEscalation` pattern (compound row + ledger entry) Layer 10 established. The `admin_user_id` column is queryable directly without joining to `activity_log`. Plan 3 also benefits in the test layer — assertions can do `$adjustment->refresh()` instead of digging into the activitylog payload.

Enum:

```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum AdjustmentType: string
{
    case Credit = 'credit';
    case Debit = 'debit';
}
```

Migration:

```php
public function up(): void
{
    Schema::create('manual_adjustments', function (Blueprint $t) {
        $t->uuid('id')->primary();
        $t->foreignUuid('store_id')->constrained('stores');
        $t->foreignUuid('admin_user_id')->constrained('users');
        $t->string('type', 8);                  // AdjustmentType
        $t->unsignedInteger('amount_cents');    // always positive; type determines sign
        $t->text('reason');                     // admin-supplied justification, required
        $t->timestamps();

        $t->index(['store_id', 'created_at']);
        $t->index('admin_user_id');
    });

    DB::statement("ALTER TABLE manual_adjustments ADD CONSTRAINT manual_adjustments_amount_positive CHECK (amount_cents > 0)");
    DB::statement("ALTER TABLE manual_adjustments ADD CONSTRAINT manual_adjustments_type_valid CHECK (type IN ('credit', 'debit'))");
}

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

> **Plan note (CHECK constraints):** Mirrors Plan 1's defence-in-depth pattern on `seller_ledger`. Cheap. Same approach used in Plan 2's `payouts_state_valid` + `payouts_net_math` constraints.

> **Plan note (`reason` is `text` not `string`):** Admin reasons can run long ("Refund for shipping delay on orders #abc, #def, #ghi following customer escalation on 2026-05-08"). `text` is unbounded; `string` defaults to 255. The activitylog row also stores the reason in JSONB so a long reason persists there. No length limit at the DB layer; UI enforces a soft cap (~500 chars) in the modal.

Model:

```php
<?php

declare(strict_types=1);

namespace App\Models;

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

/**
 * @property string $id
 * @property string $store_id
 * @property string $admin_user_id
 * @property AdjustmentType $type
 * @property int $amount_cents
 * @property string $reason
 */
class ManualAdjustment extends Model
{
    use HasFactory;
    use HasUuid;

    protected $fillable = [
        'store_id', 'admin_user_id', 'type', 'amount_cents', 'reason',
    ];

    protected function casts(): array
    {
        return [
            'type' => AdjustmentType::class,
            'amount_cents' => 'integer',
        ];
    }

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

    public function admin(): BelongsTo
    {
        return $this->belongsTo(User::class, 'admin_user_id');
    }
}
```

Factory:

```php
public function definition(): array
{
    return [
        'store_id' => Store::factory(),
        'admin_user_id' => User::factory(),
        'type' => AdjustmentType::Credit,
        'amount_cents' => $this->faker->numberBetween(100, 50000),
        'reason' => $this->faker->sentence(8),
    ];
}

public function credit(): self { return $this->state(['type' => AdjustmentType::Credit]); }
public function debit(): self  { return $this->state(['type' => AdjustmentType::Debit]); }
```

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Ledger;

use App\Models\ManualAdjustment;
use App\Models\Store;
use App\Models\User;
use App\Support\Enums\AdjustmentType;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

class ManualAdjustmentSchemaTest extends TestCase
{
    use RefreshDatabase;

    public function test_table_exists_with_expected_columns(): void
    {
        $this->assertTrue(Schema::hasTable('manual_adjustments'));
        foreach ([
            'id', 'store_id', 'admin_user_id', 'type', 'amount_cents',
            'reason', 'created_at', 'updated_at',
        ] as $col) {
            $this->assertTrue(
                Schema::hasColumn('manual_adjustments', $col),
                "manual_adjustments.$col missing",
            );
        }
    }

    public function test_adjustment_type_enum_cases(): void
    {
        $this->assertEqualsCanonicalizing(
            ['credit', 'debit'],
            array_map(fn ($c) => $c->value, AdjustmentType::cases()),
        );
    }

    public function test_amount_cents_must_be_positive(): void
    {
        $this->expectException(QueryException::class);
        ManualAdjustment::factory()->create(['amount_cents' => 0]);
    }

    public function test_type_check_constraint_rejects_invalid_value(): void
    {
        $store = Store::factory()->create();
        $admin = User::factory()->create();
        $this->expectException(QueryException::class);
        \DB::table('manual_adjustments')->insert([
            'id' => (string) \Illuminate\Support\Str::uuid(),
            'store_id' => $store->id,
            'admin_user_id' => $admin->id,
            'type' => 'sideways',
            'amount_cents' => 100,
            'reason' => 'bad',
            'created_at' => now(), 'updated_at' => now(),
        ]);
    }

    public function test_adjustment_belongs_to_store(): void
    {
        $adj = ManualAdjustment::factory()->create();
        $this->assertInstanceOf(Store::class, $adj->store);
    }

    public function test_adjustment_belongs_to_admin_user(): void
    {
        $adj = ManualAdjustment::factory()->create();
        $this->assertInstanceOf(User::class, $adj->admin);
    }

    public function test_factory_credit_and_debit_states(): void
    {
        $this->assertSame(AdjustmentType::Credit, ManualAdjustment::factory()->credit()->create()->type);
        $this->assertSame(AdjustmentType::Debit,  ManualAdjustment::factory()->debit()->create()->type);
    }
}
```

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

- [ ] **Step 3: Implement** the enum, migration, model, factory, and the two `HasMany` relations on `Store` + `User`. Run `docker compose exec -T laravel.test php artisan migrate`.

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

### Task 2: Add `voided_by_admin_id` + `voided_at` + `resolution_note` to `payouts`

**Files:**
- Create: `api/database/migrations/2026_05_12_100002_add_admin_columns_to_payouts_table.php`
- Update: `api/app/Models/Payout.php` (extend `$fillable`, `$casts`, PHPDoc, add `voidedBy(): BelongsTo`)
- Update: `api/database/factories/PayoutFactory.php` (`voided()` state already exists from Plan 2 — extend to stamp the new columns when invoked)
- Test: `api/tests/Feature/Payouts/PayoutAdminColumnsTest.php`

```php
public function up(): void
{
    Schema::table('payouts', function (Blueprint $t) {
        $t->foreignUuid('voided_by_admin_id')->nullable()->after('failure_reason')
            ->constrained('users')->nullOnDelete();
        $t->timestamp('voided_at')->nullable()->after('voided_by_admin_id');
        $t->text('resolution_note')->nullable()->after('voided_at');
    });
}

public function down(): void
{
    Schema::table('payouts', function (Blueprint $t) {
        $t->dropForeign(['voided_by_admin_id']);
        $t->dropColumn(['voided_by_admin_id', 'voided_at', 'resolution_note']);
    });
}
```

> **Plan note (`resolution_note` purpose) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Spec line 51 mentions admin can write a `resolution_note` on failed payouts. **Two design choices:**
> - **(a) `resolution_note` is a single string** stamped on void or on the most recent retry decision. Plan 3's choice — simple, queryable, mirrors `OrderReturn::admin_resolution_note` from Layer 10.
> - **(b) `resolution_note` is a separate `payout_resolution_notes` table** keyed by `(payout_id, admin_user_id, created_at)`. Threaded history.
> **Going with (a).** The activity log already gives us threaded history (one log row per retry / void / resolution edit). The column is the "current note" surface for the admin UI; the log is the "all decisions ever" surface. Same split Layer 10 uses for `OrderReturn::admin_resolution_note` vs the spatie log.

> **Plan note (`voided_by_admin_id` is nullable):** A `Payout` can land in `void` state two ways: (i) `PayoutService::executeScheduled`'s `voidUnhealthy` path (Connect account disabled — no admin involved), (ii) admin-initiated void via `POST /v1/admin/payouts/{payout}/void`. The column captures the latter; the former leaves it `NULL`. The activity log distinguishes the two ("payout.voided" with admin causer vs "payout.voided_unhealthy" via system).

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

```php
public function test_payouts_table_has_admin_columns(): void
public function test_voided_by_admin_id_is_nullable(): void
public function test_voided_by_admin_relation_resolves(): void
public function test_resolution_note_is_nullable_text(): void
```

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

- [ ] **Step 3: Implement** the migration + model updates.

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

---

## Phase B — `LedgerWriter::recordAdjustmentCredit` + `recordAdjustmentDebit` + `LedgerAdjustmentService`

### Task 3: Extend `LedgerWriter` with the 5th and 6th writer methods

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

> **Plan note (writer signature — `(Store, int, ManualAdjustment)` vs `(Store, int, string $reason)`) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Two patterns coexist in `LedgerWriter`:
> - `recordOrderEarned(Order $order)` — model is the single arg; amount derived from the model
> - `recordOrderRefunded(OrderReturn $return, int $amountCents)` — model + amount split
> - `recordLabelCost(Store $store, int $costCents, Model $source)` — store + amount + polymorphic source
> - `recordPayoutSettled(Payout $payout)` — single arg
>
> **Plan 3 chooses `recordAdjustmentCredit(Store $store, int $amountCents, ManualAdjustment $adjustment)`** — the polymorphic-source pattern from `recordLabelCost`. Why pass `Store` explicitly when it's redundant with `$adjustment->store`? Two reasons: (i) keeps the writer's domain visible at the call site — "this debits the store" rather than buried inside the model relationship; (ii) `LedgerAdjustmentService` already has both values (it just created the `ManualAdjustment` with the `Store` reference) so the redundancy is free at the call site. The description embeds the truncated reason — see implementation below.

Implementation (append to existing `LedgerWriter`):

```php
use App\Models\ManualAdjustment;

/**
 * Credit the seller's ledger for a manual admin adjustment (goodwill,
 * fee waiver, error correction). Available immediately.
 */
public function recordAdjustmentCredit(
    Store $store,
    int $amountCents,
    ManualAdjustment $adjustment,
): SellerLedger {
    if ($amountCents <= 0) {
        throw new InvalidArgumentException("Adjustment amount must be positive; got {$amountCents}.");
    }

    return $this->persist([
        'store_id' => $store->id,
        'entry_type' => LedgerEntryType::AdjustmentCredit,
        'direction' => LedgerDirection::Credit,
        'amount_cents' => $amountCents,
        'source_type' => ManualAdjustment::class,
        'source_id' => $adjustment->id,
        'available_at' => now(),
        'description' => 'Adjustment credit: '.$this->truncateReason($adjustment->reason),
    ]);
}

/**
 * Debit the seller's ledger for a manual admin adjustment (clawback,
 * error correction, etc.). Available immediately.
 */
public function recordAdjustmentDebit(
    Store $store,
    int $amountCents,
    ManualAdjustment $adjustment,
): SellerLedger {
    if ($amountCents <= 0) {
        throw new InvalidArgumentException("Adjustment amount must be positive; got {$amountCents}.");
    }

    return $this->persist([
        'store_id' => $store->id,
        'entry_type' => LedgerEntryType::AdjustmentDebit,
        'direction' => LedgerDirection::Debit,
        'amount_cents' => $amountCents,
        'source_type' => ManualAdjustment::class,
        'source_id' => $adjustment->id,
        'available_at' => now(),
        'description' => 'Adjustment debit: '.$this->truncateReason($adjustment->reason),
    ]);
}

private function truncateReason(string $reason): string
{
    return strlen($reason) > 80 ? substr($reason, 0, 77).'...' : $reason;
}
```

> **Plan note (description includes the truncated reason):** Mirrors `OrderReturn::admin_resolution_note` rendering in Layer 10. Sellers see the reason inline on their statements page without needing to click through. The DB column is 255-char varchar (Plan 1 schema); 80-char truncation leaves room for the "Adjustment credit: " prefix.

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

```php
public function test_record_adjustment_credit_writes_credit_entry(): void
public function test_record_adjustment_credit_uses_manual_adjustment_as_source(): void
public function test_record_adjustment_credit_is_available_immediately(): void
public function test_record_adjustment_credit_embeds_truncated_reason_in_description(): void
public function test_record_adjustment_credit_rejects_zero_or_negative_amount(): void
public function test_record_adjustment_debit_writes_debit_entry(): void
public function test_record_adjustment_debit_uses_manual_adjustment_as_source(): void
public function test_record_adjustment_debit_rejects_zero_or_negative_amount(): void
public function test_long_reason_is_truncated_to_under_255_chars_for_description(): void
```

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

- [ ] **Step 3: Implement** both methods + the `truncateReason` helper.

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

### Task 4: `LedgerAdjustmentService` — orchestrator for the admin endpoint

**Files:**
- Create: `api/app/Modules/Ledger/Services/LedgerAdjustmentService.php`
- Test: `api/tests/Feature/Ledger/LedgerAdjustmentServiceTest.php`

```php
<?php

declare(strict_types=1);

namespace App\Modules\Ledger\Services;

use App\Models\ManualAdjustment;
use App\Models\Store;
use App\Models\User;
use App\Modules\Notifications\Notifications\LedgerAdjustmentNotification;
use App\Support\Enums\AdjustmentType;
use Illuminate\Support\Facades\DB;

final class LedgerAdjustmentService
{
    public function __construct(private readonly LedgerWriter $ledger) {}

    /**
     * Creates a ManualAdjustment row, writes the offsetting ledger entry,
     * logs the admin action, and notifies the seller. Returns the
     * persisted adjustment (with the ledger entry id available on it for
     * the controller response if needed).
     */
    public function apply(
        Store $store,
        AdjustmentType $type,
        int $amountCents,
        string $reason,
        User $admin,
    ): ManualAdjustment {
        if ($amountCents <= 0) {
            throw new \InvalidArgumentException("Adjustment amount must be positive; got {$amountCents}.");
        }
        if (trim($reason) === '') {
            throw new \InvalidArgumentException('Adjustment reason is required.');
        }

        $adjustment = DB::transaction(function () use ($store, $type, $amountCents, $reason, $admin) {
            $adj = ManualAdjustment::create([
                'store_id' => $store->id,
                'admin_user_id' => $admin->id,
                'type' => $type,
                'amount_cents' => $amountCents,
                'reason' => $reason,
            ]);

            if ($type === AdjustmentType::Credit) {
                $this->ledger->recordAdjustmentCredit($store, $amountCents, $adj);
            } else {
                $this->ledger->recordAdjustmentDebit($store, $amountCents, $adj);
            }

            activity('admin')
                ->causedBy($admin)
                ->performedOn($adj)
                ->withProperties([
                    'store_id' => $store->id,
                    'type' => $type->value,
                    'amount_cents' => $amountCents,
                    'reason' => $reason,
                ])
                ->log('ledger.admin_adjustment');

            return $adj;
        });

        // Notification fires OUTSIDE the transaction so notification
        // dispatch failures don't roll back the ledger write.
        $store->loadMissing('owner');
        if ($store->owner !== null) {
            $store->owner->notify(new LedgerAdjustmentNotification($adjustment->fresh()));
        }

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

> **Plan note (notification fires outside the transaction):** If `Notification::send` is configured to queue (`ShouldQueue`), a queue connection blip while the DB transaction is open would either rollback the ledger write (bad — admin intervention is meant to be definitive) or commit then fail silently (better, but ambiguous to the admin). **Fire after commit.** If a notification dispatch failure ever needs surfacing, the activitylog row exists and the admin can see the adjustment landed regardless. The notification can be re-fired by a hypothetical Plan 4 retry button — out of scope here.

> **Plan note (notification cadence — always notify, both credit and debit) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Two stances:
> - **(a) Always notify** — Plan 3's choice. Sellers should be aware of any change to their balance, credit or debit, especially debits (clawbacks) which they'd otherwise discover by surprise on the next payout. Transparency wins.
> - **(b) Only notify on debits** — credits are "good news"; sellers find out when they see their balance go up.
> **Going with (a).** The credit notification doubles as a "we issued you a goodwill credit, here's why" courtesy. The reason is embedded in both email + database channels. Sellers can mute the `Payouts` category if they don't want it.

> **Plan note (activitylog row's `performedOn` target — `ManualAdjustment` vs `Store`) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** `performedOn($adj)` makes the log row queryable per-adjustment (a Plan 4 "show me every action on this adjustment" view, if ever needed). Alternative: `performedOn($store)` makes it queryable per-store ("show me every admin action on this store"). **Going with `performedOn($adj)`** plus `store_id` in the `withProperties` payload — that way both queries work. Same pattern Layer 8 uses for `StoreSuspender` (logs `performedOn($store)`) and Layer 10 uses for `ReturnEscalation` resolutions.

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Ledger;

use App\Models\ManualAdjustment;
use App\Models\SellerLedger;
use App\Models\Store;
use App\Models\User;
use App\Modules\Ledger\Services\LedgerAdjustmentService;
use App\Modules\Notifications\Notifications\LedgerAdjustmentNotification;
use App\Support\Enums\AdjustmentType;
use App\Support\Enums\LedgerDirection;
use App\Support\Enums\LedgerEntryType;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;

class LedgerAdjustmentServiceTest extends TestCase
{
    use RefreshDatabase;

    public function test_apply_credit_creates_manual_adjustment_row(): void
    {
        $store = Store::factory()->create();
        $admin = User::factory()->create();

        $adj = app(LedgerAdjustmentService::class)->apply(
            $store, AdjustmentType::Credit, 1500, 'Goodwill', $admin,
        );

        $this->assertSame(AdjustmentType::Credit, $adj->type);
        $this->assertSame(1500, $adj->amount_cents);
        $this->assertSame('Goodwill', $adj->reason);
        $this->assertSame($admin->id, $adj->admin_user_id);
    }

    public function test_apply_credit_writes_ledger_credit_entry(): void
    public function test_apply_debit_writes_ledger_debit_entry(): void
    public function test_apply_links_ledger_entry_to_manual_adjustment_via_source(): void
    public function test_apply_logs_to_activitylog_with_admin_causer(): void
    public function test_apply_log_includes_store_id_type_amount_reason_in_properties(): void
    public function test_apply_dispatches_ledger_adjustment_notification_to_owner(): void
    public function test_apply_rejects_zero_amount(): void
    public function test_apply_rejects_negative_amount(): void
    public function test_apply_rejects_empty_reason(): void
    public function test_apply_rejects_whitespace_only_reason(): void
    public function test_apply_rolls_back_ledger_write_on_log_failure(): void
    // (mock activity()->log to throw; assert no ManualAdjustment + no ledger entry persist)
    public function test_apply_does_not_notify_when_store_has_no_owner(): void
    public function test_balance_service_picks_up_credit_immediately(): void
    // (call apply; immediately read BalanceService::forStore; assert available_cents increased)
}
```

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

- [ ] **Step 3: Implement** the service. Use `Notification::fake()` in tests to assert dispatch.

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

---

## Phase C — Late-refund `reverseTransfer` integration

### Task 5: Extend `ReturnRefundIssuer::issue` with conditional `reverseTransfer`

**Files:**
- Update: `api/app/Modules/Returns/Services/ReturnRefundIssuer.php`
- Test: `api/tests/Feature/Returns/ReturnRefundIssuerReverseTransferTest.php` *(new — focused on the new conditional)*
- Update: `api/tests/Feature/Returns/ReturnTransitionerTest.php` *(extend if existing markReceived tests need regression coverage for the new code path)*

> **Plan note (the conditional query and its precise gating) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** The reverseTransfer should fire iff **all three** of the following hold:
> 1. An `order_earned` credit exists on the seller_ledger for this order (`source_type = Order::class, source_id = $order->id, entry_type = order_earned`).
> 2. That credit's `payout_id IS NOT NULL` (funds already bundled into a Payout — within-hold case is the `payout_id IS NULL` branch and skips this path entirely, preserving Plan 2 behaviour).
> 3. The referenced `Payout` has `stripe_transfer_id IS NOT NULL` (funds actually shipped to Stripe — not in the rare `void` window where a Payout was scheduled but never transferred).
>
> **Edge case — multi-credit order:** v1 orders write **one** `order_earned` credit per delivery, so there's at most one matching row. Plan 3 documents the assumption and uses `->first()`; a future multi-credit order (split delivery? Out of scope) would need to sum or iterate. The query orders by `created_at DESC` so the most recent credit wins if ever there's more than one — defensive against a hypothetical re-delivery write.
>
> **Edge case — no `order_earned` credit at all:** an order delivered before Layer 11 shipped would not have a Plan-1 ledger entry. Plan 3's query returns `null` in that case and skips the reverseTransfer — defensive, preserves current production behaviour (which doesn't reverseTransfer on returns today). Same path the within-hold case takes; the buyer is refunded and the seller's books take the hit (which is the status quo).

Updated method (additions only — existing logic preserved):

```php
public function issue(
    OrderReturn $return,
    ?int $overrideAmountCents = null,
    bool $includeOriginalShipping = false,
): void {
    $return->loadMissing('order.purchase');

    $pi = $return->order?->purchase?->stripe_payment_intent_id;
    if ($pi === null) {
        throw new RuntimeException('Order has no PaymentIntent to refund.');
    }

    $shippingFlag = $includeOriginalShipping || (bool) $return->refund_original_shipping;

    $amount = $this->computeRefundAmount($return, $overrideAmountCents, $shippingFlag);

    // Existing: buyer-card refund via Stripe.
    $refund = $this->stripe->refundForOrder(
        $pi,
        $amount,
        "return-refund-{$return->id}",
    );

    $return->update([
        'stripe_refund_id' => $refund->id,
        'refund_amount_cents' => $amount,
        'refunded_at' => now(),
    ]);

    // NEW (Plan 3): if the original order_earned credit has already
    // shipped to the seller's Connect balance, reverseTransfer to claw
    // back. Within-hold refunds (credit still pending) skip this path —
    // their ledger debit alone offsets the credit at next-cycle bundling.
    $this->reverseTransferIfPayoutSettled($return, $amount);
}

private function reverseTransferIfPayoutSettled(OrderReturn $return, int $refundAmount): void
{
    $orderEarned = \App\Models\SellerLedger::query()
        ->where('source_type', \App\Models\Order::class)
        ->where('source_id', $return->order_id)
        ->where('entry_type', \App\Support\Enums\LedgerEntryType::OrderEarned->value)
        ->whereNotNull('payout_id')
        ->orderByDesc('created_at')
        ->first();

    if ($orderEarned === null) {
        // Either no Plan-1 ledger entry exists (pre-Layer-11 order), OR
        // the credit is still in the hold window (payout_id IS NULL).
        // Either way: refund-only, no transfer reversal.
        return;
    }

    $payout = \App\Models\Payout::query()->find($orderEarned->payout_id);

    if ($payout === null || $payout->stripe_transfer_id === null) {
        // Payout was scheduled but never transferred (e.g., voided
        // by admin or by voidUnhealthy path). No transfer to reverse.
        return;
    }

    try {
        $this->stripe->reverseTransfer(
            transferId: $payout->stripe_transfer_id,
            amountCents: $refundAmount,
            idempotencyKey: "late-return-reversal:{$return->id}",
            metadata: [
                'return_id' => $return->id,
                'order_id' => $return->order_id,
                'payout_id' => $payout->id,
            ],
        );
    } catch (\Throwable $e) {
        // Log + persist the error on the return for admin follow-up,
        // but DO NOT re-throw. The buyer refund + ledger debit have
        // already happened; the reverseTransfer is the seller-side
        // mechanism only. Admin reconcile via Plan 3's failed-payouts
        // queue + ReconcileFailedMoneyMovements (which already handles
        // reversal retries for dispute/admin paths).
        \Illuminate\Support\Facades\Log::error(
            'Late-return reverseTransfer failed; buyer refunded but seller transfer not reversed',
            [
                'return_id' => $return->id,
                'order_id' => $return->order_id,
                'payout_id' => $payout->id,
                'transfer_id' => $payout->stripe_transfer_id,
                'amount_cents' => $refundAmount,
                'error' => $e->getMessage(),
            ],
        );
    }
}
```

> **Plan note (idempotency key shape):** `"late-return-reversal:{$return->id}"` matches the convention `AdminOrderActions::reverseTransfer` uses (`"admin-standalone-reversal:{$order->id}"`) — colon-separated namespace + entity id. Stripe stores idempotency keys for 24 hours; a duplicate refund-issuance within that window dedupes at Stripe's side. If a return ever has two refunds issued (Layer 10 doesn't allow this today — the state machine forbids re-refund — but defence-in-depth), the same key would prevent a double reversal.

> **Plan note (swallow vs re-throw on reversal failure) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** The Stripe refund to the buyer has already succeeded (line above the try block); the ledger debit has already been written (by `ReturnTransitioner::markReceived` upstream — Plan 1 hook). If `reverseTransfer` fails, the system is in a known-recoverable state: the buyer has their money, the seller's books show the debit, but the funds haven't been clawed back from Stripe Connect. **Swallow and log, don't re-throw.** Same posture `AdminOrderActions::reverseTransfer` takes (lines 156-172 of `AdminOrderActions.php` — it sets `$stripeError = $e->getMessage()`, returns the warning, doesn't throw). The admin failed-payouts queue surfaces these (next-cycle balance read will reflect the imbalance; admin can fire a manual reverseTransfer via the existing `POST /v1/admin/orders/{order}/reverse-transfer` endpoint).

> **Plan note (no separate "needs reconciliation" flag on `returns`):** Plan 3 doesn't add a `reversal_failed_at` or similar column. The activity log row + the `ReconcileFailedMoneyMovements` command (already runs hourly) + the unattached debit ledger entry are sufficient signal. If Plan 4 ever wants a richer "reversal needs retry" surface, add the column then; don't speculate now.

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Returns;

use App\Models\Order;
use App\Models\OrderReturn;
use App\Models\Payout;
use App\Models\SellerLedger;
use App\Modules\Checkout\Services\StripeService;
use App\Modules\Returns\Services\ReturnRefundIssuer;
use App\Support\Enums\LedgerDirection;
use App\Support\Enums\LedgerEntryType;
use App\Support\Enums\PayoutState;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;

class ReturnRefundIssuerReverseTransferTest extends TestCase
{
    use RefreshDatabase;

    public function test_within_hold_refund_does_not_call_reverse_transfer(): void
    {
        $return = $this->makeReturnWithDeliveredOrder();
        SellerLedger::factory()->create([
            'store_id' => $return->order->store_id,
            'source_type' => Order::class,
            'source_id' => $return->order_id,
            'entry_type' => LedgerEntryType::OrderEarned,
            'direction' => LedgerDirection::Credit,
            'amount_cents' => 5000,
            'payout_id' => null,                  // within-hold
            'available_at' => now()->addDays(10),
        ]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('refundForOrder')->once()->andReturn(
            $this->fakeRefund('re_test_xyz')
        );
        $stripe->shouldNotReceive('reverseTransfer');
        $this->app->instance(StripeService::class, $stripe);

        app(ReturnRefundIssuer::class)->issue($return, overrideAmountCents: 5000);
    }

    public function test_after_hold_refund_calls_reverse_transfer_with_payout_transfer_id(): void
    {
        $return = $this->makeReturnWithDeliveredOrder();
        $payout = Payout::factory()->succeeded()->create([
            'store_id' => $return->order->store_id,
            'stripe_transfer_id' => 'tr_test_abc',
        ]);
        SellerLedger::factory()->create([
            'store_id' => $return->order->store_id,
            'source_type' => Order::class,
            'source_id' => $return->order_id,
            'entry_type' => LedgerEntryType::OrderEarned,
            'direction' => LedgerDirection::Credit,
            'amount_cents' => 5000,
            'payout_id' => $payout->id,           // already paid out
            'available_at' => now()->subDays(15),
        ]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('refundForOrder')->once()->andReturn(
            $this->fakeRefund('re_test_xyz')
        );
        $stripe->shouldReceive('reverseTransfer')
            ->once()
            ->with(
                Mockery::on(fn ($args) => true),  // see argument assertions in following tests
                Mockery::any(),
                Mockery::any(),
                Mockery::any(),
            )
            ->andReturn($this->fakeReversal('trr_test_xyz'));
        $this->app->instance(StripeService::class, $stripe);

        app(ReturnRefundIssuer::class)->issue($return, overrideAmountCents: 5000);
    }

    public function test_after_hold_refund_passes_refund_amount_as_reversal_amount(): void
    public function test_after_hold_refund_uses_late_return_reversal_idempotency_key(): void
    public function test_after_hold_refund_includes_return_order_payout_ids_in_metadata(): void

    public function test_no_order_earned_credit_does_not_call_reverse_transfer(): void
    {
        // Defensive — pre-Layer-11 order, no ledger entry exists.
        $return = $this->makeReturnWithDeliveredOrder();

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('refundForOrder')->once()->andReturn(
            $this->fakeRefund('re_test_xyz')
        );
        $stripe->shouldNotReceive('reverseTransfer');
        $this->app->instance(StripeService::class, $stripe);

        app(ReturnRefundIssuer::class)->issue($return, overrideAmountCents: 5000);
    }

    public function test_payout_with_null_transfer_id_does_not_call_reverse_transfer(): void
    {
        // Edge: Payout was voided or void-unhealthy'd before transfer.
        // stripe_transfer_id remained NULL. Don't reverse a transfer that
        // doesn't exist.
        $return = $this->makeReturnWithDeliveredOrder();
        $payout = Payout::factory()->create([
            'store_id' => $return->order->store_id,
            'state' => PayoutState::Void,
            'stripe_transfer_id' => null,
        ]);
        SellerLedger::factory()->create([
            'store_id' => $return->order->store_id,
            'source_type' => Order::class,
            'source_id' => $return->order_id,
            'entry_type' => LedgerEntryType::OrderEarned,
            'direction' => LedgerDirection::Credit,
            'amount_cents' => 5000,
            'payout_id' => $payout->id,
            'available_at' => now()->subDays(15),
        ]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('refundForOrder')->once()->andReturn(
            $this->fakeRefund('re_test_xyz')
        );
        $stripe->shouldNotReceive('reverseTransfer');
        $this->app->instance(StripeService::class, $stripe);

        app(ReturnRefundIssuer::class)->issue($return, overrideAmountCents: 5000);
    }

    public function test_reverse_transfer_failure_is_swallowed_and_logged(): void
    {
        // Buyer refund still succeeds; only the seller-side reverseTransfer
        // throws. The method must NOT re-throw — caller (ReturnTransitioner)
        // already wrote the ledger debit before reaching here, and would
        // roll back the state transition if we propagated the exception.
        \Illuminate\Support\Facades\Log::shouldReceive('error')->once();

        $return = $this->makeReturnWithDeliveredOrder();
        $payout = Payout::factory()->succeeded()->create([
            'store_id' => $return->order->store_id,
            'stripe_transfer_id' => 'tr_test_abc',
        ]);
        SellerLedger::factory()->create([
            'store_id' => $return->order->store_id,
            'source_type' => Order::class,
            'source_id' => $return->order_id,
            'entry_type' => LedgerEntryType::OrderEarned,
            'direction' => LedgerDirection::Credit,
            'amount_cents' => 5000,
            'payout_id' => $payout->id,
            'available_at' => now()->subDays(15),
        ]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('refundForOrder')->andReturn($this->fakeRefund('re_x'));
        $stripe->shouldReceive('reverseTransfer')->andThrow(new \RuntimeException('Insufficient funds'));
        $this->app->instance(StripeService::class, $stripe);

        // No exception bubbles up.
        app(ReturnRefundIssuer::class)->issue($return, overrideAmountCents: 5000);
        $this->addToAssertionCount(1);
    }

    public function test_multiple_order_earned_credits_only_reverses_once(): void
    {
        // Defensive: if ever the data has two order_earned credits for
        // the same order (shouldn't but be safe), the most recent (paid
        // out) one is used. The method calls reverseTransfer exactly once.
        // Implementation orders by created_at DESC + ->first().
    }

    // --- helpers ---
    private function makeReturnWithDeliveredOrder(): OrderReturn { /* ... */ }
    private function fakeRefund(string $id): \Stripe\Refund { /* mocked SDK object */ }
    private function fakeReversal(string $id): \Stripe\TransferReversal { /* mocked SDK object */ }
}
```

- [ ] **Step 2: Run, confirm failure** — `reverseTransfer` isn't called from `ReturnRefundIssuer` today.

- [ ] **Step 3: Implement** the new `reverseTransferIfPayoutSettled` private method + the call site. Add the `Log::error` import.

- [ ] **Step 4: Run; iterate to 9/9 PASS** + re-run the full `Returns` + `Ledger` + `Payouts` suites to confirm no regressions on the within-hold path (Plan 2's behaviour preserved).

---

## Phase D — Admin endpoints

### Task 6: `POST /v1/admin/stores/{store}/ledger-adjustments`

**Files:**
- Create: `api/app/Modules/Admin/Controllers/AdminLedgerAdjustmentController.php`
- Create: `api/app/Modules/Admin/Requests/StoreLedgerAdjustmentRequest.php` *(FormRequest validation)*
- Update: `api/app/Modules/Admin/routes.php`
- Test: `api/tests/Feature/Admin/AdminLedgerAdjustmentEndpointTest.php`

```php
<?php

declare(strict_types=1);

namespace App\Modules\Admin\Controllers;

use App\Models\Store;
use App\Modules\Admin\Requests\StoreLedgerAdjustmentRequest;
use App\Modules\Ledger\Services\LedgerAdjustmentService;
use App\Support\Enums\AdjustmentType;
use Illuminate\Http\JsonResponse;

class AdminLedgerAdjustmentController
{
    public function store(
        StoreLedgerAdjustmentRequest $request,
        Store $store,
        LedgerAdjustmentService $service,
    ): JsonResponse {
        $adjustment = $service->apply(
            store: $store,
            type: AdjustmentType::from($request->validated('type')),
            amountCents: (int) $request->validated('amount_cents'),
            reason: (string) $request->validated('reason'),
            admin: $request->user(),
        );

        return response()->json([
            'data' => [
                'id' => $adjustment->id,
                'store_id' => $adjustment->store_id,
                'admin_user_id' => $adjustment->admin_user_id,
                'type' => $adjustment->type->value,
                'amount_cents' => $adjustment->amount_cents,
                'reason' => $adjustment->reason,
                'created_at' => $adjustment->created_at->toIso8601String(),
            ],
        ], 201);
    }
}
```

FormRequest:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Admin\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreLedgerAdjustmentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // admin middleware on the route group handles it
    }

    public function rules(): array
    {
        return [
            'type' => ['required', 'string', 'in:credit,debit'],
            'amount_cents' => ['required', 'integer', 'min:1', 'max:10000000'],
            'reason' => ['required', 'string', 'min:3', 'max:1000'],
        ];
    }
}
```

> **Plan note (max amount = $100,000):** `max:10000000` cents = $100k. A guard rail against fat-fingered admin errors. Goodwill credits in v1 marketplace volume should be well under this. If a future high-value adjustment is needed, lift the cap deliberately rather than discovering it's missing in an emergency.

Route additions to `api/app/Modules/Admin/routes.php` (inside the existing `middleware(['auth:sanctum', 'admin'])->prefix('admin')->group(...)` block):

```php
Route::post('/stores/{store}/ledger-adjustments', [AdminLedgerAdjustmentController::class, 'store']);
```

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

```php
public function test_admin_can_post_credit_adjustment(): void
public function test_admin_can_post_debit_adjustment(): void
public function test_non_admin_returns_403(): void
public function test_unauthenticated_returns_401(): void
public function test_missing_type_returns_422(): void
public function test_invalid_type_returns_422(): void
public function test_zero_amount_returns_422(): void
public function test_negative_amount_returns_422(): void
public function test_missing_reason_returns_422(): void
public function test_short_reason_returns_422(): void
// (less than 3 chars)
public function test_amount_over_max_returns_422(): void
public function test_response_includes_adjustment_id_and_created_at(): void
public function test_endpoint_writes_activity_log_row(): void
public function test_endpoint_notifies_store_owner(): void
```

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

- [ ] **Step 3: Implement** controller + FormRequest + route registration.

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

### Task 7: `GET /v1/admin/financials/balances` — aggregate + per-store

**Files:**
- Create: `api/app/Modules/Admin/Controllers/AdminFinancialsController.php`
- Update: `api/app/Modules/Ledger/Services/BalanceService.php` (add `marketplaceAggregate(): array<string,int>` method)
- Update: `api/app/Modules/Admin/routes.php`
- Test: `api/tests/Feature/Admin/AdminFinancialsBalancesEndpointTest.php`
- Test: `api/tests/Feature/Ledger/BalanceServiceMarketplaceAggregateTest.php`

> **Plan note (single-query aggregate vs sum-per-store-then-sum) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Two implementations:
> - **(a) Single Postgres aggregate query** — `SELECT SUM(CASE WHEN direction='credit' AND available_at <= now() AND payout_id IS NULL THEN amount_cents WHEN direction='debit' AND available_at <= now() AND payout_id IS NULL THEN -amount_cents ELSE 0 END) AS available, ... AS pending FROM seller_ledger`. One DB round-trip. Aggregates across the whole marketplace.
> - **(b) Iterate stores + sum** — call `BalanceService::forStore` per store, sum in PHP. N+1 query, but reuses tested code.
> **Going with (a)** for the marketplace aggregate. The per-store breakdown still uses `BalanceService::forStore` via iteration (the list is paginated to 25-50 stores; that's a manageable N+1, and the per-store query is already covered by Plan 1's `BalanceServiceTest`). The aggregate has its own focused test that asserts the math matches summing all per-store values. Reuses Plan 1's Postgres-safe `selectRaw('... as net')->value('net')` pattern.

`BalanceService` extension:

```php
/**
 * Aggregate marketplace-wide balance across all stores. Returns
 * { available_cents, pending_cents }. Used by the admin financials
 * balances page.
 */
public function marketplaceAggregate(): array
{
    $now = \Carbon\CarbonImmutable::now('UTC');

    $available = (int) \DB::table('seller_ledger')
        ->where('available_at', '<=', $now)
        ->whereNull('payout_id')
        ->selectRaw(
            "COALESCE(SUM(CASE WHEN direction = 'credit' THEN amount_cents ELSE -amount_cents END), 0) AS net"
        )
        ->value('net');

    $pending = (int) \DB::table('seller_ledger')
        ->where('available_at', '>', $now)
        ->selectRaw(
            "COALESCE(SUM(CASE WHEN direction = 'credit' THEN amount_cents ELSE -amount_cents END), 0) AS net"
        )
        ->value('net');

    return [
        'available_cents' => $available,
        'pending_cents' => $pending,
    ];
}
```

Controller:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Admin\Controllers;

use App\Models\Store;
use App\Modules\Ledger\Services\BalanceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class AdminFinancialsController
{
    public function __construct(private readonly BalanceService $balances) {}

    public function balances(Request $request): JsonResponse
    {
        $aggregate = $this->balances->marketplaceAggregate();

        $stores = Store::query()
            ->orderBy('name')
            ->paginate(
                perPage: (int) min(50, max(1, $request->integer('per_page', 25))),
            );

        $rows = collect($stores->items())->map(function (Store $s) {
            $snap = $this->balances->forStore($s);
            return [
                'store_id' => $s->id,
                'store_name' => $s->name,
                'payouts_enabled' => (bool) $s->payouts_enabled,
                'disabled_reason' => $s->disabled_reason,
                'available_cents' => $snap->available_cents,
                'pending_cents' => $snap->pending_cents,
            ];
        });

        return response()->json([
            'data' => [
                'aggregate' => $aggregate,
                'stores' => $rows,
            ],
            'meta' => [
                'current_page' => $stores->currentPage(),
                'last_page' => $stores->lastPage(),
                'total' => $stores->total(),
            ],
        ]);
    }
}
```

Route:

```php
Route::get('/financials/balances', [AdminFinancialsController::class, 'balances']);
```

> **Plan note (per-store N+1 query):** Each rendered store row triggers two `selectRaw` aggregate queries via `BalanceService::forStore`. With 25 stores per page that's 50 queries — well within Laravel's debugbar comfort zone. If a future marketplace scales to thousands of stores per page, this becomes a real N+1; a single-query GROUPed aggregate (`GROUP BY store_id`) would replace the loop. Out of scope for Plan 3; flag as an open item.

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

```php
public function test_aggregate_returns_zero_for_empty_ledger(): void
public function test_aggregate_sums_credits_across_stores(): void
public function test_aggregate_subtracts_debits_across_stores(): void
public function test_aggregate_excludes_paid_out_entries(): void
public function test_aggregate_separates_available_from_pending_by_date(): void
public function test_aggregate_matches_sum_of_per_store_snapshots(): void
```

And `AdminFinancialsBalancesEndpointTest.php`:

```php
public function test_admin_can_list_balances(): void
public function test_non_admin_returns_403(): void
public function test_response_includes_aggregate_and_stores(): void
public function test_per_store_row_includes_payouts_enabled_and_disabled_reason(): void
public function test_response_is_paginated(): void
public function test_ordered_by_store_name(): void
```

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

- [ ] **Step 3: Implement** the BalanceService extension + controller + route.

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

### Task 8: `GET /v1/admin/financials/payouts` — filterable queue

**Files:**
- Update: `api/app/Modules/Admin/Controllers/AdminFinancialsController.php` (add `payouts` method)
- Update: `api/app/Modules/Admin/routes.php`
- Update: `api/app/Modules/Ledger/Resources/PayoutResource.php` *(if a PayoutResource exists from Plan 2; otherwise create — see Plan 2 Task 12)*
- Test: `api/tests/Feature/Admin/AdminFinancialsPayoutsEndpointTest.php`

```php
public function payouts(Request $request): JsonResponse
{
    $query = Payout::query()
        ->with(['store:id,name', 'voidedBy:id,name'])
        ->orderByDesc('scheduled_for');

    if ($state = $request->string('state')->toString()) {
        // Validate against the enum; default to 'failed' if unspecified
        // (the queue's primary use case is reviewing failures).
        $allowed = array_column(PayoutState::cases(), 'value');
        if (in_array($state, $allowed, true)) {
            $query->where('state', $state);
        }
    } else {
        $query->where('state', PayoutState::Failed);
    }

    if ($storeId = $request->string('store_id')->toString()) {
        $query->where('store_id', $storeId);
    }

    $payouts = $query->paginate(
        perPage: (int) min(50, max(1, $request->integer('per_page', 25))),
    );

    return response()->json([
        'data' => collect($payouts->items())->map(fn (Payout $p) => [
            'id' => $p->id,
            'store_id' => $p->store_id,
            'store_name' => $p->store?->name,
            'period_start' => $p->period_start->toIso8601String(),
            'period_end' => $p->period_end->toIso8601String(),
            'scheduled_for' => $p->scheduled_for->toIso8601String(),
            'gross_cents' => $p->gross_cents,
            'debits_cents' => $p->debits_cents,
            'net_cents' => $p->net_cents,
            'state' => $p->state->value,
            'stripe_transfer_id' => $p->stripe_transfer_id,
            'transferred_at' => $p->transferred_at?->toIso8601String(),
            'failed_at' => $p->failed_at?->toIso8601String(),
            'failure_reason' => $p->failure_reason,
            'retries' => $p->retries,
            'voided_at' => $p->voided_at?->toIso8601String(),
            'voided_by' => $p->voidedBy ? ['id' => $p->voidedBy->id, 'name' => $p->voidedBy->name] : null,
            'resolution_note' => $p->resolution_note,
        ]),
        'meta' => [
            'current_page' => $payouts->currentPage(),
            'last_page' => $payouts->lastPage(),
            'total' => $payouts->total(),
        ],
    ]);
}
```

Route:

```php
Route::get('/financials/payouts', [AdminFinancialsController::class, 'payouts']);
```

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

```php
public function test_default_filter_returns_only_failed_payouts(): void
public function test_state_filter_returns_matching_payouts(): void
public function test_store_id_filter_returns_only_that_stores_payouts(): void
public function test_invalid_state_falls_back_to_default(): void
public function test_pagination_metadata_in_response(): void
public function test_includes_store_name_in_each_row(): void
public function test_includes_voided_by_when_admin_voided(): void
public function test_includes_resolution_note(): void
public function test_non_admin_returns_403(): void
```

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

- [ ] **Step 3: Implement.**

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

### Task 9: `POST /v1/admin/payouts/{payout}/retry`

**Files:**
- Create: `api/app/Modules/Admin/Controllers/AdminPayoutController.php`
- Update: `api/app/Modules/Admin/routes.php`
- Test: `api/tests/Feature/Admin/AdminPayoutRetryEndpointTest.php`

> **Plan note (no new retry mechanism — delegates to `PayoutService::executeScheduled`):** Spec explicitly says "Admin's retry button delegates to PayoutService::executeScheduled (Plan 2's existing method) by first resetting state to scheduled. No new retry logic." Plan 3 honours this. The retry endpoint flips state from `failed` → `scheduled`, clears `failed_at` + `failure_reason`, leaves `retries` UNCHANGED (so the backoff bookkeeping survives — if this is the 3rd retry, the underlying logic still treats it as the 3rd), then invokes `executeScheduled`. The admin's intent is "try this again now" — they're overriding the backoff window, not resetting the attempt counter.

```php
<?php

declare(strict_types=1);

namespace App\Modules\Admin\Controllers;

use App\Models\Payout;
use App\Modules\Ledger\Services\PayoutService;
use App\Support\Enums\PayoutState;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class AdminPayoutController
{
    public function retry(
        Request $request,
        Payout $payout,
        PayoutService $service,
    ): JsonResponse {
        if ($payout->state !== PayoutState::Failed) {
            return response()->json([
                'error' => 'Only failed payouts can be retried.',
                'current_state' => $payout->state->value,
            ], 422);
        }

        $payout->update([
            'state' => PayoutState::Scheduled,
            'failed_at' => null,
            'failure_reason' => null,
            // retries deliberately preserved
        ]);

        $service->executeScheduled($payout->fresh());

        activity('admin')
            ->causedBy($request->user())
            ->performedOn($payout)
            ->withProperties([
                'retries' => $payout->retries,
                'previous_failure_reason' => $payout->getOriginal('failure_reason'),
            ])
            ->log('payout.retried');

        return response()->json([
            'data' => $this->serialize($payout->fresh()),
        ]);
    }

    public function void(
        Request $request,
        Payout $payout,
        VoidPayoutRequest $voidRequest, // FormRequest with required `resolution_note`
    ): JsonResponse {
        if ($payout->state->isTerminal()) {
            return response()->json([
                'error' => 'Payout is already terminal.',
                'current_state' => $payout->state->value,
            ], 422);
        }
        if ($payout->state === PayoutState::InFlight) {
            return response()->json([
                'error' => 'In-flight payouts cannot be voided; await Stripe webhook resolution.',
            ], 422);
        }

        \DB::transaction(function () use ($payout, $voidRequest, $request) {
            $payout->update([
                'state' => PayoutState::Void,
                'voided_at' => now(),
                'voided_by_admin_id' => $request->user()->id,
                'resolution_note' => (string) $voidRequest->validated('resolution_note'),
            ]);

            // Release bundled non-settled ledger entries back to the pool.
            \App\Models\SellerLedger::query()
                ->where('payout_id', $payout->id)
                ->where('entry_type', '!=', \App\Support\Enums\LedgerEntryType::PayoutSettled->value)
                ->getQuery()
                ->update(['payout_id' => null]);

            activity('admin')
                ->causedBy($request->user())
                ->performedOn($payout)
                ->withProperties([
                    'previous_state' => $payout->getOriginal('state'),
                    'resolution_note' => $voidRequest->validated('resolution_note'),
                ])
                ->log('payout.voided');
        });

        return response()->json([
            'data' => $this->serialize($payout->fresh()),
        ]);
    }

    private function serialize(Payout $p): array { /* same shape as AdminFinancialsController::payouts row */ }
}
```

`VoidPayoutRequest`:

```php
public function rules(): array
{
    return [
        'resolution_note' => ['required', 'string', 'min:3', 'max:1000'],
    ];
}
```

Routes:

```php
Route::post('/payouts/{payout}/retry', [AdminPayoutController::class, 'retry']);
Route::post('/payouts/{payout}/void',  [AdminPayoutController::class, 'void']);
```

> **Plan note (void releases bundled entries via QueryBuilder bypass):** Same pattern Plan 2's `voidUnhealthy` uses. The ledger is append-only at the model level (`SellerLedger::update()` throws), but `payout_id` is the one column the design permits mutating via the QueryBuilder. Plan 2's note (Phase B, plan-note on `payout_id` mutation) documents this; Plan 3 reuses verbatim. The `payout_settled` entries (if any) stay attached — they're the offsetting debit and represent funds that actually moved; releasing them would let the same funds be paid out a second time.

> **Plan note (in-flight payouts cannot be admin-voided):** Once Stripe has accepted the Transfer (`stripe_transfer_id` populated, state = `in_flight`), funds are in motion. Voiding from the DB side wouldn't reverse the Stripe transfer — that requires the separate `reverseTransfer` endpoint admin already has (`POST /v1/admin/orders/{order}/reverse-transfer`). The 422 response steers the admin to wait for the `transfer.paid` / `transfer.failed` webhook to resolve the state, then act.

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

```php
public function test_admin_can_retry_failed_payout(): void
public function test_retry_resets_state_to_scheduled(): void
public function test_retry_clears_failed_at_and_failure_reason(): void
public function test_retry_preserves_retries_counter(): void
public function test_retry_invokes_execute_scheduled(): void
public function test_retry_writes_activity_log_with_payout_retried(): void
public function test_retry_on_non_failed_payout_returns_422(): void
public function test_retry_unauthorized_returns_403(): void
```

And `AdminPayoutVoidEndpointTest.php`:

```php
public function test_admin_can_void_scheduled_payout(): void
public function test_admin_can_void_failed_payout(): void
public function test_void_in_flight_payout_returns_422(): void
public function test_void_succeeded_payout_returns_422(): void
public function test_void_already_void_payout_returns_422(): void
public function test_void_releases_bundled_non_settled_entries(): void
public function test_void_keeps_payout_settled_entries_attached(): void
public function test_void_stamps_voided_at_and_voided_by_admin_id(): void
public function test_void_stamps_resolution_note(): void
public function test_void_requires_resolution_note(): void
public function test_void_writes_activity_log_with_payout_voided(): void
```

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

- [ ] **Step 3: Implement** controller + FormRequest + routes.

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

---

## Phase E — Seller statements endpoints + CSV export

### Task 10: `GET /v1/stores/{store}/ledger` — paginated entries with date-range filter

**Files:**
- Create: `api/app/Modules/Ledger/Controllers/StoreLedgerController.php`
- Update: `api/app/Modules/Ledger/routes.php`
- Create: `api/app/Modules/Ledger/Resources/SellerLedgerResource.php` *(if no resource exists from Plan 2; otherwise reuse the existing `SellerLedgerEntry` schema)*
- Test: `api/tests/Feature/Ledger/StoreLedgerEndpointTest.php`

```php
<?php

declare(strict_types=1);

namespace App\Modules\Ledger\Controllers;

use App\Models\SellerLedger;
use App\Models\Store;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StoreLedgerController
{
    public function index(Request $request, Store $store): JsonResponse
    {
        $query = $store->ledgerEntries()
            ->with('source')
            ->orderByDesc('created_at');

        if ($from = $request->string('from')->toString()) {
            $query->where('created_at', '>=', Carbon::parse($from)->startOfDay());
        }
        if ($to = $request->string('to')->toString()) {
            $query->where('created_at', '<=', Carbon::parse($to)->endOfDay());
        }

        $entries = $query->paginate(
            perPage: (int) min(100, max(1, $request->integer('per_page', 50))),
        );

        return response()->json([
            'data' => collect($entries->items())->map(fn (SellerLedger $e) => $this->serialize($e)),
            'meta' => [
                'current_page' => $entries->currentPage(),
                'last_page' => $entries->lastPage(),
                'total' => $entries->total(),
                'per_page' => $entries->perPage(),
            ],
        ]);
    }

    private function serialize(SellerLedger $e): array
    {
        return [
            'id' => $e->id,
            'entry_type' => $e->entry_type->value,
            'direction' => $e->direction->value,
            'amount_cents' => $e->amount_cents,
            'signed_amount_cents' => $e->direction->value === 'credit' ? $e->amount_cents : -$e->amount_cents,
            'source_type' => $e->source_type,
            'source_id' => $e->source_id,
            'available_at' => $e->available_at->toIso8601String(),
            'payout_id' => $e->payout_id,
            'description' => $e->description,
            'created_at' => $e->created_at->toIso8601String(),
        ];
    }
}
```

Route:

```php
Route::get('/stores/{store}/ledger', [StoreLedgerController::class, 'index']);
```

> **Plan note (`signed_amount_cents` denormalisation in the response):** The frontend statements table wants a signed value (so it can render "−$12.34" for debits and "+$87.65" for credits without a JS-side mapping). The DB stores positive `amount_cents` + a `direction` discriminator; the API computes the signed value for convenience. Saves a render-time conditional in the React component.

> **Plan note (`from` / `to` are inclusive day boundaries):** Carbon's `startOfDay` / `endOfDay` interpret the date in the request as a calendar day (local server time — UTC in production). A seller asking for `from=2026-05-01&to=2026-05-31` gets all of May. The UI uses ISO dates without time; the API converts. No timezone shenanigans — the API server runs in UTC, sellers see times rendered locally on the frontend.

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

```php
public function test_get_ledger_returns_entries_for_store_owner(): void
public function test_get_ledger_returns_403_for_non_owner(): void
public function test_get_ledger_filters_by_from_date(): void
public function test_get_ledger_filters_by_to_date(): void
public function test_get_ledger_filters_by_from_and_to_range(): void
public function test_get_ledger_orders_by_created_at_desc(): void
public function test_get_ledger_paginates(): void
public function test_get_ledger_includes_signed_amount_cents(): void
public function test_get_ledger_excludes_other_stores_entries(): void
public function test_get_ledger_includes_payout_id_for_settled_entries(): void
```

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

- [ ] **Step 3: Implement.**

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

### Task 11: `GET /v1/stores/{store}/statements/export.csv` — streaming CSV

**Files:**
- Update: `api/app/Modules/Ledger/Controllers/StoreLedgerController.php` (add `exportCsv` method)
- Update: `api/app/Modules/Ledger/routes.php`
- Test: `api/tests/Feature/Ledger/StoreStatementsCsvExportTest.php`

> **Plan note (streaming CSV via `streamDownload` + `cursor()`) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** No existing CSV export precedent in the codebase (verified — no `streamDownload` or `StreamedResponse` usages found in `api/app/`). Plan 3 introduces the pattern. **Two strategies:**
> - **(a) `response()->streamDownload($callback, $filename)` + `Builder::cursor()`** — Plan 3's choice. Iterates rows one-at-a-time, writing each to the output buffer via `fputcsv($handle, $row)`. Memory stays flat regardless of row count. Stripe and Shopify both use this pattern for their large exports.
> - **(b) In-memory array + `Response::make` with CSV body.** Simpler; works for small exports; would OOM on a multi-year statement export.
> **Going with (a).** First CSV export in the codebase — set the pattern right. A seller's statement may span many cycles' worth of entries (potentially thousands of rows over a year); `cursor()` keeps memory predictable.

> **Plan note (CSV column shape) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Plan 3 commits to these columns in this order: **Date, Description, Type, Direction, Amount (signed dollars), Available (date), Payout ID**. Header row included. UTF-8 with BOM prefix (`\xEF\xBB\xBF`) for Excel compatibility — Excel without the BOM mangles non-ASCII characters in column headers and descriptions. Filename format: `statements-{store-slug}-{from}-to-{to}.csv`. Currency rendered as a signed decimal string with two decimal places (`-12.34`, `87.65`) for Excel/Sheets-friendly parsing — no `$` symbol (Excel will then auto-format as currency if the user wants).

```php
public function exportCsv(Request $request, Store $store): \Symfony\Component\HttpFoundation\StreamedResponse
{
    $from = $request->string('from')->toString() ?: null;
    $to = $request->string('to')->toString() ?: null;

    $query = $store->ledgerEntries()
        ->with('source')
        ->orderBy('created_at');

    if ($from) {
        $query->where('created_at', '>=', Carbon::parse($from)->startOfDay());
    }
    if ($to) {
        $query->where('created_at', '<=', Carbon::parse($to)->endOfDay());
    }

    $filename = sprintf(
        'statements-%s-%s-to-%s.csv',
        $store->slug ?? $store->id,
        $from ?? 'all',
        $to ?? now()->format('Y-m-d'),
    );

    return response()->streamDownload(function () use ($query) {
        $handle = fopen('php://output', 'w');

        // UTF-8 BOM for Excel.
        fwrite($handle, "\xEF\xBB\xBF");

        // Header row.
        fputcsv($handle, ['Date', 'Description', 'Type', 'Direction', 'Amount', 'Available', 'Payout ID']);

        foreach ($query->cursor() as $entry) {
            $signed = $entry->direction->value === 'credit'
                ? $entry->amount_cents / 100
                : -$entry->amount_cents / 100;

            fputcsv($handle, [
                $entry->created_at->format('Y-m-d H:i:s'),
                $entry->description ?? '',
                $entry->entry_type->value,
                $entry->direction->value,
                number_format($signed, 2, '.', ''),
                $entry->available_at->format('Y-m-d'),
                $entry->payout_id ?? '',
            ]);
        }

        fclose($handle);
    }, $filename, [
        'Content-Type' => 'text/csv; charset=UTF-8',
    ]);
}
```

Route:

```php
Route::get('/stores/{store}/statements/export.csv', [StoreLedgerController::class, 'exportCsv']);
```

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

```php
public function test_csv_export_returns_200_with_csv_content_type(): void
public function test_csv_export_includes_utf8_bom(): void
public function test_csv_export_includes_header_row(): void
public function test_csv_export_includes_one_row_per_entry(): void
public function test_csv_export_renders_signed_amount(): void
// (debit shows negative, credit shows positive)
public function test_csv_export_respects_date_range_filter(): void
public function test_csv_export_filename_includes_store_slug_and_range(): void
public function test_csv_export_returns_only_target_stores_entries(): void
public function test_csv_export_returns_403_for_non_owner(): void
public function test_csv_export_matches_ledger_index_endpoint_row_for_row(): void
// (golden test: fetch same date range via /ledger and via /export.csv; assert row count and signed amounts match)
public function test_csv_export_handles_empty_result_with_just_header(): void
```

The "matches row-for-row" test is the acceptance contract from the spec — CSV must be the same data the seller sees in the UI.

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

- [ ] **Step 3: Implement.**

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

---

## Phase F — Notification (`LedgerAdjustmentNotification`)

### Task 12: `LedgerAdjustmentNotification`

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/LedgerAdjustmentNotification.php`
- Update: `api/app/Modules/Notifications/Services/NotificationCategoryMap.php` (map → `NotificationCategory::Payouts`)
- Test: `api/tests/Feature/Ledger/LedgerAdjustmentNotificationTest.php`

> **Plan note (notification category = Payouts):** No new category needed. `NotificationCategory::Payouts` already exists (Plan 2 shipped it for the three payout notifications). Adjustments are a payout-adjacent concern — they affect the next payout's amount — so categorising them together gives sellers a single "Payouts" mute switch.

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

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

class LedgerAdjustmentNotification extends Notification
{
    use Queueable;

    public function __construct(public readonly ManualAdjustment $adjustment) {}

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

    public function toMail(object $notifiable): MailMessage
    {
        $sign = $this->adjustment->type->value === 'credit' ? '+' : '−';
        $dollars = number_format($this->adjustment->amount_cents / 100, 2);
        $label = $this->adjustment->type->value === 'credit' ? 'credited' : 'debited';

        return (new MailMessage)
            ->subject("Your account has been {$label}")
            ->greeting("Hi {$notifiable->name},")
            ->line("A platform admin has {$label} your account {$sign}\${$dollars}.")
            ->line("Reason: {$this->adjustment->reason}")
            ->line('The change is reflected in your available balance immediately and will be visible on your next statement.')
            ->action('View statements', config('app.frontend_url').'/seller/statements');
    }

    public function toDatabase(object $notifiable): array
    {
        return [
            'manual_adjustment_id' => $this->adjustment->id,
            'type' => $this->adjustment->type->value,
            'amount_cents' => $this->adjustment->amount_cents,
            'reason' => $this->adjustment->reason,
            'created_at' => $this->adjustment->created_at?->toIso8601String(),
        ];
    }
}
```

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

```php
public function test_notification_routes_to_seller_only(): void
public function test_notification_uses_mail_and_database_channels(): void
public function test_credit_notification_subject_includes_credited(): void
public function test_debit_notification_subject_includes_debited(): void
public function test_database_payload_includes_adjustment_id_type_amount_reason(): void
public function test_mail_includes_reason_text(): void
public function test_mail_action_links_to_statements_page(): void
public function test_notification_is_in_payouts_category(): void
```

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

- [ ] **Step 3: Implement** the notification + category-map registration.

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

---

## Phase G — OpenAPI + types + api-client

### Task 13: OpenAPI spec additions

**Files:**
- Update: `api/contracts/openapi.yaml`

Add five paths + four component schemas:

```yaml
# --- Seller paths ---
/v1/stores/{store}/ledger:
  get:
    tags: [Seller]
    operationId: listStoreLedger
    summary: Paginated ledger entries for a store, optionally date-filtered.
    parameters:
      - { $ref: '#/components/parameters/StoreIdPathParam' }
      - in: query
        name: from
        schema: { type: string, format: date }
        description: Inclusive start date (server interprets as UTC start-of-day).
      - in: query
        name: to
        schema: { type: string, format: date }
        description: Inclusive end date (server interprets as UTC end-of-day).
      - in: query
        name: per_page
        schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
    responses:
      '200':
        description: Paginated ledger entries
        content:
          application/json:
            schema:
              type: object
              properties:
                data:
                  type: array
                  items: { $ref: '#/components/schemas/SellerLedgerEntryDetail' }
                meta: { $ref: '#/components/schemas/PaginationMeta' }
      '401': { $ref: '#/components/responses/Unauthenticated' }
      '403': { $ref: '#/components/responses/Forbidden' }

/v1/stores/{store}/statements/export.csv:
  get:
    tags: [Seller]
    operationId: exportStoreStatementsCsv
    summary: Stream CSV of ledger entries for a date range.
    parameters:
      - { $ref: '#/components/parameters/StoreIdPathParam' }
      - in: query
        name: from
        schema: { type: string, format: date }
      - in: query
        name: to
        schema: { type: string, format: date }
    responses:
      '200':
        description: CSV stream
        content:
          text/csv:
            schema: { type: string, format: binary }

# --- Admin paths ---
/v1/admin/financials/balances:
  get:
    tags: [Admin]
    operationId: adminFinancialsBalances
    summary: Marketplace-wide aggregate balance + per-store breakdown.
    parameters:
      - in: query
        name: per_page
        schema: { type: integer, minimum: 1, maximum: 50, default: 25 }
    responses:
      '200':
        description: Aggregate + paginated store rows
        content:
          application/json:
            schema:
              type: object
              properties:
                data:
                  type: object
                  properties:
                    aggregate: { $ref: '#/components/schemas/MarketplaceBalanceAggregate' }
                    stores:
                      type: array
                      items: { $ref: '#/components/schemas/AdminStoreBalanceRow' }
                meta: { $ref: '#/components/schemas/PaginationMeta' }

/v1/admin/financials/payouts:
  get:
    tags: [Admin]
    operationId: adminFinancialsPayouts
    summary: Filterable payouts queue (default state=failed).
    parameters:
      - in: query
        name: state
        schema: { type: string, enum: [scheduled, in_flight, succeeded, failed, void] }
      - in: query
        name: store_id
        schema: { type: string, format: uuid }
      - 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/AdminPayoutRow' }
                meta: { $ref: '#/components/schemas/PaginationMeta' }

/v1/admin/payouts/{payout}/retry:
  post:
    tags: [Admin]
    operationId: adminRetryPayout
    parameters:
      - in: path
        name: payout
        required: true
        schema: { type: string, format: uuid }
    responses:
      '200': { description: Retried payout }
      '422': { description: Payout not in failed state }

/v1/admin/payouts/{payout}/void:
  post:
    tags: [Admin]
    operationId: adminVoidPayout
    parameters:
      - in: path
        name: payout
        required: true
        schema: { type: string, format: uuid }
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [resolution_note]
            properties:
              resolution_note: { type: string, minLength: 3, maxLength: 1000 }
    responses:
      '200': { description: Voided payout }
      '422': { description: Payout already terminal or in flight }

/v1/admin/stores/{store}/ledger-adjustments:
  post:
    tags: [Admin]
    operationId: adminPostLedgerAdjustment
    parameters:
      - { $ref: '#/components/parameters/StoreIdPathParam' }
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [type, amount_cents, reason]
            properties:
              type: { type: string, enum: [credit, debit] }
              amount_cents: { type: integer, minimum: 1, maximum: 10000000 }
              reason: { type: string, minLength: 3, maxLength: 1000 }
    responses:
      '201':
        description: Adjustment created
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/ManualAdjustment' }

# --- Components ---
SellerLedgerEntryDetail:
  allOf:
    - { $ref: '#/components/schemas/SellerLedgerEntry' }  # already from Plan 2
    - type: object
      properties:
        signed_amount_cents:
          type: integer
          description: 'amount_cents × (+1 for credit, −1 for debit). Convenience for UI.'

MarketplaceBalanceAggregate:
  type: object
  required: [available_cents, pending_cents]
  properties:
    available_cents: { type: integer }
    pending_cents:   { type: integer }

AdminStoreBalanceRow:
  type: object
  required: [store_id, store_name, payouts_enabled, available_cents, pending_cents]
  properties:
    store_id:        { type: string, format: uuid }
    store_name:      { type: string }
    payouts_enabled: { type: boolean }
    disabled_reason: { type: string, nullable: true }
    available_cents: { type: integer }
    pending_cents:   { type: integer }

AdminPayoutRow:
  allOf:
    - { $ref: '#/components/schemas/Payout' }  # already from Plan 2
    - type: object
      properties:
        store_name:      { type: string, nullable: true }
        voided_at:       { type: string, format: date-time, nullable: true }
        voided_by:
          type: object
          nullable: true
          properties:
            id:   { type: string, format: uuid }
            name: { type: string }
        resolution_note: { type: string, nullable: true }

ManualAdjustment:
  type: object
  required: [id, store_id, admin_user_id, type, amount_cents, reason, created_at]
  properties:
    id:            { type: string, format: uuid }
    store_id:      { type: string, format: uuid }
    admin_user_id: { type: string, format: uuid }
    type:          { type: string, enum: [credit, debit] }
    amount_cents:  { type: integer }
    reason:        { type: string }
    created_at:    { type: string, format: date-time }
```

Validate: `python3 -c "import yaml; yaml.safe_load(open('api/contracts/openapi.yaml'))"`.

- [ ] **Step 1: Edit YAML.**
- [ ] **Step 2: Validate.**

### Task 14: Sync to web + types + api-client

**Files:**
- Sync: `~/projects/alqove-web/contracts/openapi.yaml` (via `./bin/sync-openapi.sh`)
- Build: `npm run build:types`
- Update: `web/packages/api-client/src/endpoints/seller.ts` (add `ledger`, `statementsCsvUrl`)
- Update: `web/packages/api-client/src/endpoints/payouts.ts` (Plan 2 module — add nothing new for sellers; this is admin territory)
- Create: `web/packages/api-client/src/endpoints/admin-financials.ts`
- Update: `web/packages/api-client/src/client.ts` (register the new module)

`seller.ts` additions:

```ts
export interface SellerLedgerEntryDetail extends SellerLedgerEntry {
  signed_amount_cents: number;
}

export interface SellerLedgerListResponse {
  data: SellerLedgerEntryDetail[];
  meta: { current_page: number; last_page: number; total: number; per_page: number };
}

export function createSellerEndpoints(client: AlqoveClient) {
  return {
    // ... existing ...
    ledger(storeId: string, params: { from?: string; to?: string; per_page?: number } = {}) {
      const qs = new URLSearchParams();
      if (params.from) qs.set('from', params.from);
      if (params.to) qs.set('to', params.to);
      if (params.per_page) qs.set('per_page', String(params.per_page));
      const q = qs.toString();
      return client.get<SellerLedgerListResponse>(
        `/v1/stores/${storeId}/ledger${q ? `?${q}` : ''}`,
      );
    },
    statementsCsvUrl(storeId: string, params: { from?: string; to?: string } = {}): string {
      const qs = new URLSearchParams();
      if (params.from) qs.set('from', params.from);
      if (params.to) qs.set('to', params.to);
      const q = qs.toString();
      return `${client.baseUrl}/v1/stores/${storeId}/statements/export.csv${q ? `?${q}` : ''}`;
    },
  };
}
```

> **Plan note (CSV download URL returned, not fetched):** Browsers can't fetch a streaming binary response and trigger a "Save As" dialog from JavaScript without bouncing through a `<a download href="...">` click. `statementsCsvUrl()` returns the URL; the React component renders an `<a>` with the auth bearer attached via the existing fetch interceptor pattern OR (simpler) opens a new tab with the URL and the cookie/bearer attached by the browser. The Plan-3 implementation chooses the latter — same pattern Layer 8 uses for downloading admin invoice PDFs (verify in `web/src/lib/api.ts` if a precedent exists; if not, this is acceptable as a first-of-kind).

`admin-financials.ts`:

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

export interface MarketplaceBalanceAggregate {
  available_cents: number;
  pending_cents: number;
}

export interface AdminStoreBalanceRow {
  store_id: string;
  store_name: string;
  payouts_enabled: boolean;
  disabled_reason: string | null;
  available_cents: number;
  pending_cents: number;
}

export interface AdminPayoutRow extends Payout {
  store_name: string | null;
  voided_at: string | null;
  voided_by: { id: string; name: string } | null;
  resolution_note: string | null;
}

export interface AdminFinancialsBalancesResponse {
  data: { aggregate: MarketplaceBalanceAggregate; stores: AdminStoreBalanceRow[] };
  meta: { current_page: number; last_page: number; total: number };
}

export interface AdminFinancialsPayoutsResponse {
  data: AdminPayoutRow[];
  meta: { current_page: number; last_page: number; total: number };
}

export interface ManualAdjustment {
  id: string;
  store_id: string;
  admin_user_id: string;
  type: 'credit' | 'debit';
  amount_cents: number;
  reason: string;
  created_at: string;
}

export function createAdminFinancialsEndpoints(client: AlqoveClient) {
  return {
    balances(perPage = 25) {
      return client.get<AdminFinancialsBalancesResponse>(
        `/v1/admin/financials/balances?per_page=${perPage}`,
      );
    },
    payouts(filters: { state?: AdminPayoutRow['state']; store_id?: string; per_page?: number } = {}) {
      const qs = new URLSearchParams();
      if (filters.state) qs.set('state', filters.state);
      if (filters.store_id) qs.set('store_id', filters.store_id);
      if (filters.per_page) qs.set('per_page', String(filters.per_page));
      const q = qs.toString();
      return client.get<AdminFinancialsPayoutsResponse>(
        `/v1/admin/financials/payouts${q ? `?${q}` : ''}`,
      );
    },
    retryPayout(payoutId: string) {
      return client.post<{ data: AdminPayoutRow }>(
        `/v1/admin/payouts/${payoutId}/retry`,
        {},
      );
    },
    voidPayout(payoutId: string, resolution_note: string) {
      return client.post<{ data: AdminPayoutRow }>(
        `/v1/admin/payouts/${payoutId}/void`,
        { resolution_note },
      );
    },
    postAdjustment(
      storeId: string,
      body: { type: 'credit' | 'debit'; amount_cents: number; reason: string },
    ) {
      return client.post<{ data: ManualAdjustment }>(
        `/v1/admin/stores/${storeId}/ledger-adjustments`,
        body,
      );
    },
  };
}
```

- [ ] **Step 1: Sync openapi.**
- [ ] **Step 2: Build types.**
- [ ] **Step 3: Wire api-client modules.**
- [ ] **Step 4: `npm run typecheck` clean.**

---

## Phase H — Frontend

### Task 15: Seller statements page (`/seller/statements`)

**Files:**
- Create: `web/src/app/(seller)/seller/statements/page.tsx`
- Create: `web/src/app/(seller)/seller/statements/statements-client.tsx`
- Create: `web/src/app/(seller)/seller/statements/__tests__/statements-client.test.tsx`
- Create: `web/src/lib/queries/use-seller-ledger.ts`
- Create: `web/src/lib/queries/__tests__/use-seller-ledger.test.ts`
- Update: `web/src/app/(seller)/layout.tsx` (add `{ href: '/seller/statements', label: 'Statements' }` between "Payouts" and "Settings")

`use-seller-ledger.ts`:

```ts
'use client';

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

export const LEDGER_KEYS = {
  forStore: (storeId: string, from: string | null, to: string | null, perPage: number) =>
    ['seller', 'ledger', storeId, from ?? 'all', to ?? 'all', perPage] as const,
};

export function useSellerLedger(
  storeId: string | null | undefined,
  filters: { from?: string | null; to?: string | null; perPage?: number } = {},
) {
  const { from = null, to = null, perPage = 50 } = filters;
  return useQuery({
    queryKey: LEDGER_KEYS.forStore(storeId ?? 'none', from, to, perPage),
    enabled: Boolean(storeId),
    queryFn: async () => {
      return api.seller.ledger(storeId!, {
        from: from ?? undefined,
        to: to ?? undefined,
        per_page: perPage,
      });
    },
  });
}
```

`statements-client.tsx`:

```tsx
'use client';

import { useState } from 'react';
import { useSellerLedger } from '@/lib/queries/use-seller-ledger';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import { formatPrice } from '@alqove/shared';

export function StatementsClient() {
  const { user } = useAuthStore();
  const storeId = user?.store_id ?? null;
  const [from, setFrom] = useState<string>('');
  const [to, setTo] = useState<string>('');

  const { data, isLoading, isError } = useSellerLedger(storeId, {
    from: from || null,
    to: to || null,
  });

  const csvHref = storeId ? api.seller.statementsCsvUrl(storeId, { from: from || undefined, to: to || undefined }) : '#';

  if (isLoading) return <div className="p-6 text-forest/60">Loading statements…</div>;
  if (isError || !data) {
    return <div className="p-6 text-coral">Couldn’t load statements.</div>;
  }

  return (
    <div className="p-6">
      <h1 className="text-2xl font-semibold text-ink">Statements</h1>
      <div className="mt-4 flex flex-wrap items-end gap-3">
        <label className="text-sm">
          <span className="block text-forest/70">From</span>
          <input type="date" value={from} onChange={(e) => setFrom(e.target.value)}
                 className="mt-1 rounded border border-forest/20 px-2 py-1" />
        </label>
        <label className="text-sm">
          <span className="block text-forest/70">To</span>
          <input type="date" value={to} onChange={(e) => setTo(e.target.value)}
                 className="mt-1 rounded border border-forest/20 px-2 py-1" />
        </label>
        <a href={csvHref} target="_blank" rel="noreferrer"
           className="rounded bg-forest px-3 py-1.5 text-sm text-white hover:bg-forest/90">
          Export CSV
        </a>
      </div>

      {data.data.length === 0 ? (
        <p className="mt-6 text-sm text-forest/60">No entries in this range.</p>
      ) : (
        <table className="mt-4 w-full text-sm">
          <thead><tr className="text-left text-forest/70">
            <th className="py-2">Date</th>
            <th>Description</th>
            <th>Type</th>
            <th className="text-right">Amount</th>
            <th>Available</th>
            <th>Payout</th>
          </tr></thead>
          <tbody>
            {data.data.map((e) => (
              <tr key={e.id} className="border-t border-forest/10">
                <td className="py-2">{new Date(e.created_at).toLocaleDateString()}</td>
                <td>{e.description ?? '—'}</td>
                <td><span className="text-xs text-forest/70">{e.entry_type}</span></td>
                <td className={`text-right ${e.direction === 'credit' ? 'text-forest' : 'text-coral-dark'}`}>
                  {e.direction === 'credit' ? '+' : '−'}{formatPrice(Math.abs(e.signed_amount_cents))}
                </td>
                <td>{new Date(e.available_at).toLocaleDateString()}</td>
                <td>{e.payout_id ? <span className="font-mono text-xs">{e.payout_id.slice(0, 8)}…</span> : '—'}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </div>
  );
}
```

`page.tsx`:

```tsx
import { Suspense } from 'react';
import { StatementsClient } from './statements-client';

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

- [ ] **Step 1: Write the failing tests** (~6 cases): loading state, empty state, populated rows, date filter updates query key, CSV link href contains date params, signed amount renders with correct sign + color class.

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

- [ ] **Step 3: Implement** the page + hook + add nav entry.

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

### Task 16: Connect-account health banner

**Files:**
- Create: `web/src/components/seller/connect-health-banner.tsx`
- Create: `web/src/components/seller/__tests__/connect-health-banner.test.tsx`
- Update: `web/src/app/(seller)/seller/page.tsx` (insert `<ConnectHealthBanner>` above `<SellerBalanceWidget>`)
- Update: `web/src/app/(seller)/seller/payouts/page.tsx` (insert above the payouts list)
- Update: `web/src/components/seller/seller-balance-widget.tsx` (add an "Account on hold" inline state when `payouts_enabled === false`)

> **Plan note (banner placement: home + payouts only, NOT every seller page) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Two stances:
> - **(a) Every `/seller/*` page.** Maximum visibility; sellers can't miss the issue.
> - **(b) Just dashboard + payouts pages.** Plan 3's choice. Sellers who need to onboard see the banner where they go for money. Cluttering listings / inbox / settings doesn't change the outcome (they still need to complete Stripe onboarding) and makes those pages feel anxious.
> **Going with (b).** Documented; if the support team reports sellers missing the banner, expand to a layout-level mount in a follow-up.

> **Plan note (banner data source — extends `useBalance` or its own query):** The `payouts_enabled` + `disabled_reason` fields aren't currently in the `/v1/stores/{store}/balance` response (Plan 1 shape). **Two options:**
> - **(a) Extend `SellerBalance` schema to include the two fields.** Single fetch on dashboard load.
> - **(b) Separate `useStoreConnectHealth` hook.** Fresh query; clearer separation.
> **Going with (a) — extend `SellerBalance`.** The fields are tiny (one boolean + one nullable string), they piggyback on the existing query, and the seller-balance widget needs them anyway for the inline "Account on hold" state. Update OpenAPI's `SellerBalance` schema in Phase G; both consumers (widget + banner) read from the same hook.

`connect-health-banner.tsx`:

```tsx
'use client';

import { useBalance } from '@/lib/queries/use-balance';

interface Props {
  storeId: string;
}

export function ConnectHealthBanner({ storeId }: Props) {
  const { data } = useBalance(storeId);

  if (!data || data.payouts_enabled !== false) return null;

  const reason = data.disabled_reason ?? 'Your Stripe Connect account has restrictions that prevent payouts.';

  return (
    <div role="alert" className="rounded-md border border-coral bg-coral/10 p-4 text-sm text-coral-dark">
      <div className="font-semibold">Payouts are on hold</div>
      <p className="mt-1">{reason}</p>
      <a
        href={`/seller/stripe-onboarding/redirect?store_id=${storeId}`}
        className="mt-2 inline-block rounded bg-coral-dark px-3 py-1.5 text-white"
      >
        Resolve in Stripe
      </a>
    </div>
  );
}
```

> **Plan note (redirect URL — `/seller/stripe-onboarding/redirect`) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** The seller route group already has an onboarding redirect handler (verify by reading `web/src/app/(seller)/seller/settings/page.tsx` — if Layer 4 wired a "Continue Stripe onboarding" CTA there, mirror its URL). If no such route exists, Plan 3 ships a new redirect endpoint backed by `StripeService::createConnectAccount` (which already exists at `api/app/Modules/Checkout/Services/StripeService.php:26`). The CTA links to the hosted onboarding URL Stripe returns — no in-app redesign.

Banner tests:

```tsx
test('renders nothing when payouts_enabled is true', () => { /* ... */ });
test('renders banner when payouts_enabled is false', () => { /* ... */ });
test('shows disabled_reason text when present', () => { /* ... */ });
test('falls back to generic copy when disabled_reason is null', () => { /* ... */ });
test('links to stripe-onboarding redirect with store id', () => { /* ... */ });
```

`SellerBalanceWidget` inline "Account on hold" state — add at the top of the widget body:

```tsx
if (data.payouts_enabled === false) {
  return (
    <div className="rounded-md border border-coral/40 bg-coral/5 p-4 text-sm text-coral-dark">
      <div className="font-semibold">Account on hold</div>
      <div className="mt-1">
        Available: {formatPrice(data.available_cents)} · Pending: {formatPrice(data.pending_cents)}
      </div>
      <div className="mt-1 text-xs text-coral-dark/80">
        {data.disabled_reason ?? 'Resolve Stripe onboarding to release payouts.'}
      </div>
    </div>
  );
}
```

- [ ] **Step 1: Write the failing tests** (~5 banner + ~2 widget inline state).
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to 7/7 PASS.**

### Task 17: Admin financials nav + balances page

**Files:**
- Update: `web/src/app/(admin)/layout.tsx` (add two new nav items; see plan note for structure)
- Create: `web/src/app/(admin)/admin/financials/balances/page.tsx`
- Create: `web/src/app/(admin)/admin/financials/balances/balances-client.tsx`
- Create: `web/src/app/(admin)/admin/financials/balances/__tests__/balances-client.test.tsx`
- Create: `web/src/lib/queries/use-admin-financials.ts`
- Create: `web/src/lib/queries/__tests__/use-admin-financials.test.ts`

> **Plan note (admin nav: nested "Financials" section vs flat) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Audit of `web/src/app/(admin)/layout.tsx` (verified) shows a **flat** `navItems` array — no parent/child sectioning, no collapse primitive. Two options:
> - **(a) Add two flat entries** at the bottom: `{ href: '/admin/financials/balances', label: 'Balances' }`, `{ href: '/admin/financials/payouts', label: 'Failed payouts' }`. Plan 3's choice. Zero new UI primitives.
> - **(b) Introduce nested-nav structure** with a "Financials" parent label and two children. Larger change; warrants its own task; risks regressing the existing flat-nav tests.
> **Going with (a).** Two top-level entries keep the diff small and the visual hierarchy is fine for 9 total entries (current 7 + 2). If the sidebar starts feeling crowded later, a nested-section refactor is a separate concern.

`balances-client.tsx`:

```tsx
'use client';

import { useAdminFinancialsBalances } from '@/lib/queries/use-admin-financials';
import Link from 'next/link';

function fmt(cents: number) {
  const sign = cents < 0 ? '−' : '';
  return `${sign}$${(Math.abs(cents) / 100).toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
}

export function BalancesClient() {
  const { data, isLoading, isError } = useAdminFinancialsBalances();

  if (isLoading) return <div className="text-sm text-slate-400">Loading…</div>;
  if (isError || !data) return <p className="text-sm text-red-700">Couldn’t load balances.</p>;

  const { aggregate, stores } = data.data;

  return (
    <div>
      <h1 className="text-2xl font-bold text-slate-900">Marketplace balances</h1>

      <div className="mt-4 grid grid-cols-2 gap-3 max-w-xl">
        <div className="rounded border border-slate-200 bg-white p-3">
          <div className="text-xs uppercase text-slate-500">Available</div>
          <div className="mt-1 text-xl font-semibold">{fmt(aggregate.available_cents)}</div>
        </div>
        <div className="rounded border border-slate-200 bg-white p-3">
          <div className="text-xs uppercase text-slate-500">Pending</div>
          <div className="mt-1 text-xl font-semibold">{fmt(aggregate.pending_cents)}</div>
        </div>
      </div>

      <table className="mt-6 w-full text-sm">
        <thead><tr className="text-left text-slate-500">
          <th className="py-2">Store</th>
          <th className="text-right">Available</th>
          <th className="text-right">Pending</th>
          <th>Status</th>
        </tr></thead>
        <tbody>
          {stores.map((s) => (
            <tr key={s.store_id} className="border-t border-slate-200">
              <td className="py-2">
                <Link href={`/admin/stores/${s.store_id}`} className="text-emerald-700 hover:underline">
                  {s.store_name}
                </Link>
              </td>
              <td className="text-right">{fmt(s.available_cents)}</td>
              <td className="text-right">{fmt(s.pending_cents)}</td>
              <td>
                {s.payouts_enabled
                  ? <span className="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700">Enabled</span>
                  : <span className="rounded bg-red-100 px-2 py-0.5 text-xs text-red-700">On hold</span>}
                {s.disabled_reason && (
                  <span className="ml-2 text-xs text-slate-500" title={s.disabled_reason}>
                    {s.disabled_reason.length > 40 ? `${s.disabled_reason.slice(0, 40)}…` : s.disabled_reason}
                  </span>
                )}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
```

- [ ] **Step 1: Write the failing tests** (~5 cases): loading, aggregate rendering, per-store rows, status badge variants, link to store detail.
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to 5/5 PASS.**

### Task 18: Admin failed-payouts queue + retry/void actions

**Files:**
- Create: `web/src/app/(admin)/admin/financials/payouts/page.tsx`
- Create: `web/src/app/(admin)/admin/financials/payouts/payouts-queue-client.tsx`
- Create: `web/src/app/(admin)/admin/financials/payouts/__tests__/payouts-queue-client.test.tsx`
- Update: `web/src/lib/queries/use-admin-financials.ts` (add `useAdminPayouts`, `useAdminRetryPayout`, `useAdminVoidPayout`)
- Reuse: `web/src/components/admin/confirm-with-justification-dialog.tsx` *(existing — Layer 8; used for store suspend/verify; perfect for void action's resolution_note prompt)*

`payouts-queue-client.tsx` highlights:

- State filter dropdown (default "failed").
- Table: Store, Period, Net, State, Failed at, Retries, Failure reason (truncated), Actions.
- Retry button enabled iff `state === 'failed'`. Void button enabled iff `state in ['scheduled', 'failed']`. Disabled when `state === 'in_flight'`.
- Retry button: posts directly; optimistic invalidate on success.
- Void button: opens `ConfirmWithJustificationDialog` (existing Layer 8 component) with the prompt "Resolution note (required)". On confirm posts to the void endpoint with `resolution_note`.

```tsx
'use client';

import { useState } from 'react';
import {
  useAdminPayouts, useAdminRetryPayout, useAdminVoidPayout,
} from '@/lib/queries/use-admin-financials';
import { ConfirmWithJustificationDialog } from '@/components/admin/confirm-with-justification-dialog';
import type { AdminPayoutRow } from '@alqove/api-client';

const STATES: AdminPayoutRow['state'][] = ['failed', 'scheduled', 'in_flight', 'succeeded', 'void'];

export function PayoutsQueueClient() {
  const [stateFilter, setStateFilter] = useState<AdminPayoutRow['state']>('failed');
  const { data, isLoading } = useAdminPayouts({ state: stateFilter });
  const retry = useAdminRetryPayout();
  const voidIt = useAdminVoidPayout();
  const [voidTarget, setVoidTarget] = useState<AdminPayoutRow | null>(null);

  // ... loading, error, render filter chips ...

  return (
    <div>
      <h1 className="text-2xl font-bold text-slate-900">Payouts queue</h1>
      <div className="mt-4 flex gap-2">
        {STATES.map((s) => (
          <button key={s} onClick={() => setStateFilter(s)}
                  className={`rounded px-3 py-1 text-sm ${stateFilter === s ? 'bg-emerald-600 text-white' : 'bg-slate-200 text-slate-700'}`}>
            {s}
          </button>
        ))}
      </div>
      {/* ... table ... */}
      {voidTarget && (
        <ConfirmWithJustificationDialog
          title={`Void payout ${voidTarget.id.slice(0, 8)}…`}
          confirmLabel="Void"
          onConfirm={(note) => {
            voidIt.mutate({ payoutId: voidTarget.id, resolution_note: note }, {
              onSuccess: () => setVoidTarget(null),
            });
          }}
          onCancel={() => setVoidTarget(null)}
        />
      )}
    </div>
  );
}
```

- [ ] **Step 1: Write the failing tests** (~7 cases): loading; state filter switches the query; retry button fires the mutation; retry button disabled for non-failed states; void dialog opens and posts the note; void button disabled for in_flight; row count matches API response.
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to 7/7 PASS.**

### Task 19: Admin store-detail "Ledger" tab + manual-adjustment modal

**Files:**
- Update: `web/src/app/(admin)/admin/stores/[id]/store-detail-client.tsx` (introduce a minimal local tab switcher; add Ledger tab content)
- Create: `web/src/app/(admin)/admin/stores/[id]/ledger-tab.tsx`
- Create: `web/src/app/(admin)/admin/stores/[id]/manual-adjustment-modal.tsx`
- Create: `web/src/app/(admin)/admin/stores/[id]/__tests__/ledger-tab.test.tsx`
- Create: `web/src/app/(admin)/admin/stores/[id]/__tests__/manual-adjustment-modal.test.tsx`
- Update: `web/src/lib/queries/use-admin-financials.ts` (add `useAdminStoreLedger`, `useAdminPostAdjustment`)

> **Plan note (no shared tabs primitive exists today) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Audit of `store-detail-client.tsx` (217 lines, no tabs) shows the page is currently flat: a header + a metadata grid + action buttons. **Plan 3 introduces a minimal in-file tab switcher** rather than building a shared shadcn `Tabs` primitive. Two tabs only: "Overview" (the current page contents) and "Ledger" (new — list of entries + "New adjustment" CTA). A `useState<'overview' | 'ledger'>` + two buttons + conditional render. If a third tab is ever needed elsewhere in admin, refactor to a shared component then. Don't over-build now.

```tsx
type Tab = 'overview' | 'ledger';

export function StoreDetailClient({ storeId }: { storeId: string }) {
  const [tab, setTab] = useState<Tab>('overview');
  // ... existing data fetch ...
  return (
    <div>
      {/* header unchanged */}
      <div className="mt-4 flex gap-2 border-b border-slate-200">
        <button onClick={() => setTab('overview')}
                className={`px-3 py-2 text-sm ${tab === 'overview' ? 'border-b-2 border-emerald-500 text-emerald-700' : 'text-slate-500'}`}>
          Overview
        </button>
        <button onClick={() => setTab('ledger')}
                className={`px-3 py-2 text-sm ${tab === 'ledger' ? 'border-b-2 border-emerald-500 text-emerald-700' : 'text-slate-500'}`}>
          Ledger
        </button>
      </div>
      {tab === 'overview' ? /* existing content */ : <LedgerTab storeId={storeId} />}
    </div>
  );
}
```

`ledger-tab.tsx` — lists `SellerLedger` entries paginated, default no date filter, with a "New adjustment" button that opens the modal:

```tsx
export function LedgerTab({ storeId }: { storeId: string }) {
  const { data, isLoading } = useAdminStoreLedger(storeId);
  const [modalOpen, setModalOpen] = useState(false);

  return (
    <div className="mt-4">
      <div className="flex justify-end">
        <button onClick={() => setModalOpen(true)}
                className="rounded bg-emerald-600 px-3 py-1.5 text-sm text-white">
          New adjustment
        </button>
      </div>
      {/* ... table of entries (mirrors seller statements but with admin styling) ... */}
      {modalOpen && (
        <ManualAdjustmentModal
          storeId={storeId}
          onClose={() => setModalOpen(false)}
        />
      )}
    </div>
  );
}
```

> **Plan note (admin endpoint vs reuse seller endpoint for the ledger tab) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** The seller endpoint (`GET /v1/stores/{store}/ledger`) is owner-gated by `store.owner` middleware. An admin viewing the ledger tab is NOT the store owner. **Two options:**
> - **(a) Add an admin route `GET /v1/admin/stores/{store}/ledger`** with admin middleware. Different controller (or shared controller behind a polymorphic auth guard).
> - **(b) Loosen the existing seller endpoint** to accept admin users.
> **Going with (a).** Cleaner authorization story; the admin route can additionally include the `ManualAdjustment` source eager-loaded for richer audit display, where the seller route doesn't need that level of detail. Wire the new route in Phase D (Task 6's same controller, additional method) OR fold into Phase E's `StoreLedgerController` with a separate admin-prefix route. Recommend: add `GET /v1/admin/stores/{store}/ledger` as a new method on `AdminLedgerAdjustmentController` (which is already the admin-side ledger controller).

`manual-adjustment-modal.tsx`:

```tsx
'use client';

import { useState } from 'react';
import { useAdminPostAdjustment } from '@/lib/queries/use-admin-financials';

interface Props {
  storeId: string;
  onClose: () => void;
}

export function ManualAdjustmentModal({ storeId, onClose }: Props) {
  const [type, setType] = useState<'credit' | 'debit'>('credit');
  const [amount, setAmount] = useState<string>(''); // dollars input → cents on submit
  const [reason, setReason] = useState<string>('');
  const post = useAdminPostAdjustment(storeId);

  const submit = () => {
    const cents = Math.round(parseFloat(amount || '0') * 100);
    if (cents <= 0 || reason.trim().length < 3) return;
    post.mutate({ type, amount_cents: cents, reason }, { onSuccess: onClose });
  };

  return (
    <div role="dialog" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
      <div className="w-full max-w-md rounded bg-white p-6">
        <h2 className="text-lg font-semibold">New ledger adjustment</h2>
        <div className="mt-4 flex gap-2">
          <button onClick={() => setType('credit')}
                  className={`flex-1 rounded px-3 py-1.5 text-sm ${type === 'credit' ? 'bg-emerald-600 text-white' : 'bg-slate-200'}`}>
            Credit
          </button>
          <button onClick={() => setType('debit')}
                  className={`flex-1 rounded px-3 py-1.5 text-sm ${type === 'debit' ? 'bg-red-600 text-white' : 'bg-slate-200'}`}>
            Debit
          </button>
        </div>
        <label className="mt-4 block text-sm">
          <span className="block text-slate-600">Amount (USD)</span>
          <input type="number" step="0.01" min="0.01" value={amount} onChange={(e) => setAmount(e.target.value)}
                 className="mt-1 w-full rounded border border-slate-300 px-2 py-1" />
        </label>
        <label className="mt-3 block text-sm">
          <span className="block text-slate-600">Reason (required)</span>
          <textarea value={reason} onChange={(e) => setReason(e.target.value)} rows={3} maxLength={1000}
                    className="mt-1 w-full rounded border border-slate-300 px-2 py-1" />
        </label>
        {post.error && <p className="mt-2 text-sm text-red-700">{(post.error as Error).message}</p>}
        <div className="mt-4 flex justify-end gap-2">
          <button onClick={onClose} className="rounded px-3 py-1.5 text-sm text-slate-600">Cancel</button>
          <button onClick={submit} disabled={post.isPending}
                  className="rounded bg-emerald-600 px-3 py-1.5 text-sm text-white disabled:opacity-50">
            {post.isPending ? 'Saving…' : 'Post adjustment'}
          </button>
        </div>
      </div>
    </div>
  );
}
```

Mutation hook:

```ts
export function useAdminPostAdjustment(storeId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (body: { type: 'credit' | 'debit'; amount_cents: number; reason: string }) =>
      api.adminFinancials.postAdjustment(storeId, body),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['admin', 'ledger', storeId] });
      qc.invalidateQueries({ queryKey: ['admin', 'financials', 'balances'] });
    },
  });
}
```

- [ ] **Step 1: Write the failing tests** (~8 cases): tab switching renders correct content; new-adjustment button opens modal; modal posts to endpoint with cents-converted amount; submit blocked when reason too short; submit blocked when amount zero; credit/debit toggle switches type; success closes modal and invalidates queries; error renders inline.
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement** the tab + modal + ledger-tab list + admin ledger query hook + the new admin ledger route.
- [ ] **Step 4: Run; iterate to 8/8 PASS.**

### Task 20: Frontend hooks consolidation + smoke tests

**Files:**
- Review: `web/src/lib/queries/use-admin-financials.ts` — confirm all five hooks exist (`useAdminFinancialsBalances`, `useAdminPayouts`, `useAdminRetryPayout`, `useAdminVoidPayout`, `useAdminStoreLedger`, `useAdminPostAdjustment`).
- Review: `web/src/lib/queries/use-seller-ledger.ts` — confirm exports.
- Review: `web/src/lib/api.ts` — confirm both new endpoint modules are registered on the singleton client.

- [ ] **Step 1: Run all web tests** — `npm run test`. Expected: previous **267 → ~290** (~23 new web tests: 6 statements + 7 banner+widget + 5 balances + 7 payouts queue + 8 ledger tab + adj modal; some overlap from existing dashboard regression).
- [ ] **Step 2: Run web typecheck** — `npm run typecheck` clean.
- [ ] **Step 3: Run web lint** — `npm run lint` baseline.

---

## Phase I — Wrap-up

### Task 21: Full sweep

- [ ] **Step 1: Backend** — `docker compose exec -T laravel.test php artisan test`. Expected: **890 → ~970** (~80 new API tests across:  7 ManualAdjustmentSchema + 4 PayoutAdminColumns + 9 LedgerWriter adjustment + 14 LedgerAdjustmentService + 9 ReturnRefundIssuer reverseTransfer + 14 AdminLedgerAdjustment endpoint + 6 BalanceService aggregate + 6 Admin financials balances + 9 Admin financials payouts + 8 Admin payout retry + 11 Admin payout void + 10 StoreLedger endpoint + 11 StoreStatements CSV + 8 LedgerAdjustmentNotification, minus overlap with extended existing tests).

- [ ] **Step 2: Backend lint** — `cd api && ./vendor/bin/pint app/Modules/Ledger app/Modules/Admin app/Modules/Returns/Services/ReturnRefundIssuer.php app/Modules/Notifications/Notifications/LedgerAdjustmentNotification.php app/Models/ManualAdjustment.php app/Models/Payout.php app/Support/Enums/AdjustmentType.php tests/Feature/Ledger tests/Feature/Admin tests/Feature/Returns/ReturnRefundIssuerReverseTransferTest.php database/migrations/2026_05_12_*`. 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: **267 → ~290** (~23 new).

- [ ] **Step 6: Local build** — `npm run build:web`. Clean static build; three new routes (`/seller/statements`, `/admin/financials/balances`, `/admin/financials/payouts`) plus the existing `/admin/stores/[id]` page gains the Ledger tab — no new route from the tab.

### Task 22: Manual QA scenarios (the late-refund path is the riskiest piece)

- [ ] **Scenario A — Manual adjustment full round-trip.**
  1. As admin: navigate to `/admin/stores/{id}` → Ledger tab → click "New adjustment". Post a $25 credit with reason "Test goodwill".
  2. Verify: admin sees the new entry in the Ledger tab. `BalanceService::forStore` returns +$25 more available.
  3. As that seller: refresh `/seller`. Balance widget shows updated available. `/seller/statements` shows the entry with the truncated reason in the description column.
  4. Check email inbox: `LedgerAdjustmentNotification` email arrived with the reason.
  5. Check activity log: one `ledger.admin_adjustment` row with the admin as causer.

- [ ] **Scenario B — Late refund (after-hold reverseTransfer).**
  1. Seed: a delivered order from > 14 days ago whose `order_earned` credit was bundled into a settled Payout (run `payouts:run-cycle --force` if needed).
  2. As buyer: file a return on that order. As seller: approve. Let the tracker advance to delivered (or use the seller manual override `mark-received`).
  3. Verify: the `ReturnRefundIssuer::issue` path was hit and `StripeService::reverseTransfer` was called against the Payout's `stripe_transfer_id`. Check Stripe dashboard for the reversal entry.
  4. Verify: seller's available balance dropped by the refund amount; no double-debit (only the `order_refunded` debit appears, no extra entry).

- [ ] **Scenario C — Within-hold refund (no reverseTransfer).**
  1. Seed: a delivered order from < 14 days ago; `order_earned` credit's `payout_id IS NULL`.
  2. Buyer files return → seller approves → tracker delivered.
  3. Verify: `StripeService::refundForOrder` called (buyer gets card refund); `StripeService::reverseTransfer` NOT called.
  4. Verify: ledger has the `order_refunded` debit; balance read correctly reflects the offset.

- [ ] **Scenario D — Admin retry a failed payout.**
  1. Seed: a Payout in `failed` state with `retries = 1`, `failure_reason = 'Test'`.
  2. As admin: navigate to `/admin/financials/payouts`. See the row. Click Retry.
  3. Verify: state flips to `scheduled` → `in_flight` (assuming Stripe mock succeeds). `failure_reason` cleared. `retries` unchanged (= 1). Activity log has `payout.retried`.

- [ ] **Scenario E — Admin void a scheduled payout.**
  1. Seed: a Payout in `scheduled` state with three bundled ledger entries.
  2. As admin: click Void on the row, enter resolution note "Test void".
  3. Verify: state = `void`. `voided_at` stamped. `voided_by_admin_id` set. The three ledger entries' `payout_id` is now `NULL`. Activity log has `payout.voided` with the note.

- [ ] **Scenario F — CSV export integrity.**
  1. As seller: navigate to `/seller/statements`. Set date range covering a few entries.
  2. Click "Export CSV".
  3. Open the CSV in Excel + a text editor. Verify: header row present, UTF-8 BOM present (text editor shows correct encoding), row count matches the on-screen table, signed amounts render correctly (debits negative, credits positive), Payout ID column populated for settled entries.

### Task 23: Commit + push

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

```
feat(ledger): admin financials, seller statements, late-refund reverseTransfer
```

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

```
feat(ledger): /seller/statements, admin financials, manual-adjustment modal, connect-health banner
```

- [ ] **Step 3:** Push both. Watch GitHub Actions.

  **Expected gotchas / lessons from Plan 1+2 to apply preemptively:**
  - The new `manual_adjustments` table's CHECK constraints must match Postgres syntax exactly — copy verbatim from `seller_ledger`'s precedent.
  - Tests that fixture coherent values: a `ManualAdjustment` factory + a corresponding `SellerLedger` row written by the writer service must align on `store_id`, `amount_cents`, and `source_id`. Plan 2's `cece02f` lesson (auto-compute helpers) suggests factory states like `withLedgerEntry()` that write both rows atomically — apply if test verbosity grows.
  - The marketplace-aggregate query uses `selectRaw('… as net')->value('net')` (Plan 1's `f36d8b3` pattern) — Postgres-safe; do NOT use `value(DB::raw(...))` which caused CI failure in Plan 1.
  - For the CSV export tests, `Builder::cursor()` does NOT respect query-builder transformations the same way `get()` does — verify the test fixtures iterate correctly. If a model accessor is needed in CSV output, prefer pulling raw columns and rendering in the streamDownload closure.
  - Webhook + reverseTransfer tests: mock `StripeService` via `$this->app->instance(StripeService::class, $mock)` (Mockery) — same pattern Plan 2 settled on. The PHP-level static SDK calls (`\Stripe\Transfer::createReversal`) cannot be mocked directly without higher-level helpers.

---

## Open items deferred beyond Layer 11

- **Per-store custom payout cadence.** Marketplace-wide cadence in v1 — every store gets paid on the same day. Per-store opt-in deferred (spec line 62).
- **Instant payout.** Stripe's paid feature; cost discussion deferred (spec line 56).
- **Custom 1099-K generation.** Stripe Connect Express auto-generates; Plan 3 does NOT add tax-form generation. A help-text link on `/seller/statements` could point sellers to Stripe's tax portal — out of scope here; trivial follow-up if desired.
- **Stripe debit authorization for negative balances.** Sellers staying negative roll over until they re-earn or admin manually adjusts. No auto-debit from their bank. Spec line 58 defers this.
- **Multi-currency.** USD only; no `currency` column on `seller_ledger` or `payouts`. Future layer if marketplace ever expands.
- **Reserve / holdback percentages** beyond the flat 14-day hold. Out of scope (spec line 60).
- **Card payouts.** ACH (Stripe Connect default) only.
- **Disputed-period payout pause** beyond Layer 8's existing dispute mechanics. Spec line 63 documents the existing flow takes precedence.
- **Tax withholding** for non-US sellers. USD-only marketplace; deferred.
- **EasyPost label-refund automated handling.** Plan 3 ships the `ManualAdjustment` admin endpoint as the manual recovery path when a seller voids a purchased label within EasyPost's refund window. Automated webhook handling deferred.
- **24h-lookahead `PayoutScheduledNotification`.** Plan 2 fires at `scheduleForCycle` time (same day as cron). A separate "tomorrow's payouts" command can be added if sellers ask for advance notice.
- **Per-payout detail page (`/seller/payouts/[id]`).** Plan 2 ships the API endpoint; Plan 3's `/seller/statements` filtered to a specific `payout_id` covers the same need. A standalone detail page is a minor UX win not worth duplicating.
- **`transfer.reversed` Stripe webhook handling.** Not handled in any plan of Layer 11. Out of scope until a use case forces it (rare; usually only in fraud-investigation contexts).
- **Legacy `TransferFundsToStore` capture-time job retirement.** Spec line 249 calls this out. **Plan 3 does NOT retire it** — too risky to touch the capture-time money-movement path in the same layer that ships the new payout flow. A separate Layer 12 (or quick follow-up) audit + retirement is warranted once the new cron has been in production for a billing cycle.
- **N+1 in `/admin/financials/balances`** at scale (`forStore` called per row). Acceptable for v1 marketplace volume; a single GROUPed aggregate replaces the loop when the marketplace scales to hundreds of stores per page.
- **Nested admin nav structure.** Plan 3 ships two flat entries (Balances, Failed payouts). A "Financials" parent label with collapsible children is a follow-up if the sidebar grows beyond ~10 entries.
- **Shared `Tabs` primitive in the admin layout.** Plan 3 ships an in-file 2-tab switcher on the store-detail page. Promote to a shared component if a third tab is ever needed elsewhere.
- **Failed reverseTransfer reconciliation surface.** When the late-refund `reverseTransfer` swallows a Stripe error, the failure is in the activity log + `ReconcileFailedMoneyMovements` (existing Plan 8 command). A richer admin "reversal failed; needs retry" queue would surface these proactively. Out of Plan 3 scope — defer to a Layer 12 reconciliation improvement.
- **Bulk admin actions** on the failed-payouts queue (select-all + retry, etc.). Single-row actions only in v1.
- **`/admin/financials/payouts` filter persistence.** State filter resets on page reload. Query-string sync is a UX polish for a future iteration.
- **Notification preferences UI for `NotificationCategory::Payouts`.** Plan 2 + Plan 3 all five payout-related notifications share the category. The existing notification-preferences page (Layer 7-ish) should already let sellers mute the category; verify and document if a regression is found.
- **Stripe onboarding redirect endpoint.** Plan 3 assumes a redirect handler exists or will be added via Layer 4's `StripeService::createConnectAccount`. If the route doesn't currently exist, Plan 3 ships a thin wrapper as part of Task 16; otherwise the banner CTA reuses the existing flow.
