# Layer 11 Plan 1: Ledger Foundation

> **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:** Stand up the append-only `SellerLedger` that becomes the source of truth for every dollar a seller has earned, been refunded, or spent on shipping. Plan 1 lays the foundation only: schema + model with `update()` / `delete()` overrides that throw, a single `LedgerWriter` service that is the only legal write surface, three live hook-ins (order-delivered, return-refund-issued, EasyPost label purchase for both outbound and return shipments), and one read-only API — `GET /v1/seller/balance` — backing a two-line `SellerBalanceWidget` on the seller home. No payout machinery (Plan 2), no statements page or admin adjustment endpoint (Plan 3), no Stripe calls at all. The entry-type enum stubs `payout_settled` and `adjustment_credit | adjustment_debit` for forward compatibility but Plan 1 ships no writers for those types. Acceptance: after a freshly delivered order, the seller dashboard widget shows the order's net (subtotal − platform fee − label cost) under "Pending"; once 14 days elapse (asserted via `Carbon::setTestNow`) the same amount moves to "Available" with a `next_payout_date` derived from a static schedule helper. A refund issued before the 14-day mark causes both the credit and an offsetting `order_refunded` debit to appear in pending; the available balance never sees the funds. The ledger is append-only and externally read-only.

**Architecture:** (1) **Schema** — one migration creates `seller_ledger` (UUID PK, `store_id` FK, `entry_type` string-enum, `direction` enum, `amount_cents` unsigned int, `available_at` timestamp, `payout_id` nullable UUID, `nullableMorphs('source')`, `description`, immutable `created_at`, no `updated_at`); two new enums (`LedgerEntryType`, `LedgerDirection`); one new model with `update()`/`delete()` overrides that throw `LogicException`; a factory. (2) **Writer service** — `LedgerWriter` (final, DI-friendly) is the single allowed write API; exposes one method per Plan-1 entry type: `recordOrderEarned(Order)`, `recordOrderRefunded(OrderReturn, int $amountCents)`, `recordLabelCost(Store, int $costCents, Model $source)`. Each method computes `direction`, `amount_cents`, `available_at`, `source_type`, `source_id`, and a human `description`, then writes one row via a private `persist()` helper that bypasses the public `update`/`delete` guards (uses `Model::query()->insert(...)` to dodge mass-assignment + Eloquent `saving` events). (3) **Event hooks** — Plan 1 wires three existing call sites (no event listeners — transactional consistency requires the ledger write to live inside the same `DB::transaction` that mutates the source row): `OrderFulfillmentService::markDelivered` gains a `$this->ledger->recordOrderEarned($order)` line after the status update; `ReturnTransitioner::markReceived` gains a `$this->ledger->recordOrderRefunded(...)` line immediately after `$this->refunds->issue(...)` returns; `OrderFulfillmentService::purchaseLabel` and `ReturnLabelService::issue` each gain a `$this->ledger->recordLabelCost(...)` line after the EasyPost call succeeds. Two service constructors (`OrderFulfillmentService`, `ReturnTransitioner`, `ReturnLabelService`) gain a `LedgerWriter` dependency — DI all the way; **no facades, no `app()` calls**. (4) **Balance read** — `BalanceService::forStore(Store): BalanceSnapshot` computes `available_cents`, `pending_cents`, and `next_payout_date` from a static `PayoutSchedule::nextCycleDate(CarbonImmutable)` helper that reads `config/payouts.php` (`cycle_anchor` ISO date + `cycle_days` int, default 14). `SellerBalanceController::show` returns it. (5) **Frontend** — `useBalance(storeId)` TanStack hook in `web/src/lib/queries/use-balance.ts`; `<SellerBalanceWidget>` component rendered at the top of `/seller`. Two lines, no interactivity, no destructive UI.

**Tech Stack:** Laravel 12, PHPUnit class-based feature tests under `api/tests/Feature/Ledger/` (mirrors `tests/Feature/Returns/` precedent), Postgres 17, `ramsey/uuid`-backed `HasUuid` trait (existing on `Order`, `OrderReturn`, etc.), `Carbon::setTestNow` for time-shifted tests (no time-mocking libraries), OpenAPI → `openapi-typescript`, Next.js 15 App Router, TanStack Query v5, Tailwind, Vitest + React Testing Library.

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

**Prerequisites:**
- API head: `c709f45` (Layer 10 Plan 3 fully shipped + pagination/escalation-reason follow-ups). **704 tests passing.** No ledger code exists anywhere in the codebase.
- Web head: `c99c93f`. **241 tests passing, 1 skipped.**
- The integration points Plan 1 wires into already exist:
  - `App\Modules\Orders\Services\OrderFulfillmentService` at `api/app/Modules/Orders/Services/OrderFulfillmentService.php` — `markDelivered(Order)` is the single canonical "advance to delivered" code path; called from `TrackingService::applyUpdate` (carrier webhook) and from `OrderFulfillmentController::markDelivered` (seller manual override). **No event listener; both paths go through this method.**
  - `App\Modules\Returns\Services\ReturnRefundIssuer` at `api/app/Modules/Returns/Services/ReturnRefundIssuer.php` — `issue(OrderReturn, ?int $overrideAmountCents, bool $includeOriginalShipping)` mutates `refund_amount_cents` + `stripe_refund_id` + `refunded_at` on the return. Plan 1 hooks *after* the issuer returns, inside `ReturnTransitioner::markReceived` (which calls the issuer at line 179 of `api/app/Modules/Returns/Services/ReturnTransitioner.php`).
  - `App\Modules\Shipping\Services\EasyPostProvider::buyCheapestLabel(ShipmentRequest): PurchasedLabel` returns a `PurchasedLabel` DTO with a `public readonly int $rateCents` field — the cost we record on the debit entry. Two callers: `OrderFulfillmentService::purchaseLabel` (outbound) and `App\Modules\Returns\Services\ReturnLabelService::issue` (return).
  - `Store` has a `HasMany` relation pattern; `Order::seller_payout` is precomputed at checkout (`subtotal - platform_fee`); `Order::delivered_at` is stamped by `markDelivered`.
- **No `payout_id` FK target yet** — Plan 1's migration declares the column nullable + indexed but does **not** add a foreign-key constraint (Plan 2 creates the `payouts` table and adds the FK in its own migration).

**Successor plan:** `2026-XX-XX-layer-11-payouts-and-cron.md` (Plan 2) — `Payout` model + migration + biweekly Laravel scheduled job (`PayoutScheduleCommand`), `PayoutService::scheduleForCycle` and `::executeScheduled`, `StripeService::createTransfer` integration, Stripe Connect webhook handling, `Payout` state machine (`scheduled | in_flight | succeeded | failed | void`), retry job with exponential backoff, the three `Payout*Notification` classes, and the seller payouts history page at `/seller/payouts`. Plan 2 backfills the `payout_id` FK constraint and uses `LedgerWriter::recordPayoutSettled(Payout)` (added in Plan 2, no Plan 1 stub).

---

## Phase A — Schema

### Task 1: Enums + `seller_ledger` migration + model + factory

**Files:**
- Create: `api/app/Support/Enums/LedgerEntryType.php`
- Create: `api/app/Support/Enums/LedgerDirection.php`
- Create: `api/database/migrations/2026_05_11_100001_create_seller_ledger_table.php`
- Create: `api/app/Models/SellerLedger.php`
- Create: `api/database/factories/SellerLedgerFactory.php`
- Update: `api/app/Models/Store.php` (add `ledgerEntries(): HasMany` relation)
- Test: `api/tests/Feature/Ledger/SellerLedgerSchemaTest.php`

Enum values (matches the spec's `entry_type` table line 92–98):

```php
// LedgerEntryType
case OrderEarned = 'order_earned';
case OrderRefunded = 'order_refunded';
case LabelCostDebit = 'label_cost_debit';
case PayoutSettled = 'payout_settled';        // stub — no writer in Plan 1
case AdjustmentCredit = 'adjustment_credit';  // stub — no writer in Plan 1
case AdjustmentDebit = 'adjustment_debit';    // stub — no writer in Plan 1

// LedgerDirection
case Credit = 'credit';
case Debit = 'debit';
```

Migration:

```php
public function up(): void
{
    Schema::create('seller_ledger', function (Blueprint $t) {
        $t->uuid('id')->primary();
        $t->foreignUuid('store_id')->constrained('stores');
        $t->string('entry_type', 32);              // LedgerEntryType
        $t->string('direction', 8);                // LedgerDirection
        $t->unsignedInteger('amount_cents');       // always positive
        $t->nullableMorphs('source');              // source_type + source_id (compound index for free)
        $t->timestamp('available_at');             // event_time + 14d for credits; event_time for debits
        $t->uuid('payout_id')->nullable();         // FK added in Plan 2 once `payouts` exists
        $t->string('description', 255);
        $t->timestamp('created_at')->useCurrent(); // immutable; no `updated_at`

        $t->index(['store_id', 'available_at', 'payout_id'], 'seller_ledger_balance_read_idx');
        $t->index('payout_id');
    });

    // Postgres CHECK constraints — direction + positive amount
    DB::statement("ALTER TABLE seller_ledger ADD CONSTRAINT seller_ledger_amount_positive CHECK (amount_cents > 0)");
    DB::statement("ALTER TABLE seller_ledger ADD CONSTRAINT seller_ledger_direction_valid CHECK (direction IN ('credit', 'debit'))");

    // Documenting the append-only intent at the DB layer for any human reading the schema.
    DB::statement("COMMENT ON TABLE seller_ledger IS 'Append-only: writes only via App\\Modules\\Ledger\\Services\\LedgerWriter. update()/delete() on the SellerLedger model throw LogicException.'");
}

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

> **Plan note (table name singular vs plural):** Eloquent's default would be `seller_ledgers`. We override with `protected $table = 'seller_ledger';` on the model — "ledger" reads better as a collective noun, matching how the spec and the code refer to it ("the ledger"). Same singular convention as `activity_log` (used by `spatie/laravel-activitylog` elsewhere in the codebase).

> **Plan note (Postgres CHECK constraints vs application-layer validation):** Two CHECK constraints (amount > 0; direction in valid set) move the invariant into the database. Defence-in-depth — if a future writer bug bypasses the service, the DB still rejects the row. Both are cheap, both match a check used in the `activity_log` migration precedent.

> **Plan note (`nullableMorphs` vs explicit columns) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** The spec lists `source_type` + `source_id` as polymorphic. **Plan 1 uses Laravel's `$table->nullableMorphs('source')`** (creates `source_type` varchar + `source_id` UUID + compound `(source_type, source_id)` index automatically). Nullable because `adjustment_*` entries in Plan 3 may write a row before the `ManualAdjustment` model exists for the cycle's first goodwill case. Matches Laravel idiom; no hand-rolled index needed. If a future audit prefers explicit columns for clarity, the migration is the one place to revisit.

> **Plan note (no `currency` column):** USD only — matches the spec line 59 "Multi-currency: USD only". A future multi-currency layer can add the column as a separate migration. Don't pre-emptively widen the schema.

> **Plan note (no `updated_at`):** Append-only means rows are never updated. `created_at` is the event timestamp; `available_at` is the release time. The absence of `updated_at` is itself a hint to readers that the table is special. Laravel's `$timestamps = false` on the model enforces it.

> **Plan note (no FK constraint on `payout_id` in Plan 1) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** The column exists (column-name commitment to the spec data model on line 80) but no `references('id')->on('payouts')` constraint, because the `payouts` table doesn't exist until Plan 2's migration. **Plan 1 trade-off:** a buggy Plan-1 writer could insert garbage into `payout_id`; the column is unused in Plan 1 so the risk is theoretical. Plan 2's migration backfills the constraint via `$table->foreign('payout_id')->references('id')->on('payouts')`. Alternative: leave the column out of Plan 1 entirely and add it in Plan 2 — but the read query in Phase D uses `WHERE payout_id IS NULL` to filter "not yet paid out", so the column has to be there from Plan 1.

Model:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use App\Support\Enums\LedgerDirection;
use App\Support\Enums\LedgerEntryType;
use App\Support\Traits\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use LogicException;

class SellerLedger extends Model
{
    use HasFactory;
    use HasUuid;

    protected $table = 'seller_ledger';

    // No `updated_at` column exists.
    public const UPDATED_AT = null;

    protected $guarded = [];

    protected $casts = [
        'entry_type' => LedgerEntryType::class,
        'direction' => LedgerDirection::class,
        'amount_cents' => 'integer',
        'available_at' => 'datetime',
    ];

    /** Append-only: external writes go through LedgerWriter; Eloquent update is blocked. */
    public function update(array $attributes = [], array $options = []): bool
    {
        throw new LogicException('seller_ledger is append-only; mutations are forbidden. Write a counter-entry via LedgerWriter instead.');
    }

    /** Append-only: deletion is forbidden. */
    public function delete(): ?bool
    {
        throw new LogicException('seller_ledger is append-only; deletion is forbidden.');
    }

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

    public function source(): MorphTo
    {
        return $this->morphTo();
    }
}
```

> **Plan note (append-only enforcement strategy: throw on Model::update / Model::delete) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Two reasonable strategies:
> - **(a) Override `update()` and `delete()` to throw** (Plan 1's choice). Guarantees no accidental writes from anywhere — controllers, jobs, REPL, tests that fat-finger a `->update()` call. The `LedgerWriter::persist()` helper uses `static::query()->insert([...])` to bypass the model entirely (no Eloquent events fire, no `update()` is ever called on a hydrated instance). Test setup uses `SellerLedger::factory()->create([...])` directly — `create()` is unaffected by the overrides; only `update()` and `delete()` throw.
> - **(b) Code-review-only.** Flexible but easy to circumvent and impossible to enforce against future contributors who haven't read this doc.
> **Going with (a).** The test-setup implication is documented at every fixture: tests that need an entry with a past `available_at` use `SellerLedger::factory()->create(['available_at' => now()->subDays(15)])`, **never** create-then-update.

Factory:

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\Order;
use App\Models\SellerLedger;
use App\Models\Store;
use App\Support\Enums\LedgerDirection;
use App\Support\Enums\LedgerEntryType;
use Illuminate\Database\Eloquent\Factories\Factory;

class SellerLedgerFactory extends Factory
{
    protected $model = SellerLedger::class;

    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'entry_type' => LedgerEntryType::OrderEarned,
            'direction' => LedgerDirection::Credit,
            'amount_cents' => $this->faker->numberBetween(500, 20000),
            'source_type' => null,
            'source_id' => null,
            'available_at' => now(),
            'payout_id' => null,
            'description' => 'Test entry',
            'created_at' => now(),
        ];
    }

    public function pending(): self
    {
        return $this->state(['available_at' => now()->addDays(7)]);
    }

    public function debit(LedgerEntryType $type = LedgerEntryType::LabelCostDebit): self
    {
        return $this->state(['entry_type' => $type, 'direction' => LedgerDirection::Debit]);
    }
}
```

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Ledger;

use App\Models\Order;
use App\Models\SellerLedger;
use App\Support\Enums\LedgerDirection;
use App\Support\Enums\LedgerEntryType;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use LogicException;
use Tests\TestCase;

class SellerLedgerSchemaTest extends TestCase
{
    use RefreshDatabase;

    public function test_seller_ledger_table_exists_with_expected_columns(): void
    {
        $this->assertTrue(Schema::hasTable('seller_ledger'));
        foreach ([
            'id', 'store_id', 'entry_type', 'direction', 'amount_cents',
            'source_type', 'source_id', 'available_at', 'payout_id',
            'description', 'created_at',
        ] as $col) {
            $this->assertTrue(
                Schema::hasColumn('seller_ledger', $col),
                "seller_ledger.$col missing",
            );
        }
    }

    public function test_seller_ledger_has_no_updated_at_column(): void
    {
        $this->assertFalse(
            Schema::hasColumn('seller_ledger', 'updated_at'),
            'append-only table must not have updated_at',
        );
    }

    public function test_entry_type_enum_cases(): void
    {
        $this->assertEqualsCanonicalizing(
            ['order_earned', 'order_refunded', 'label_cost_debit', 'payout_settled', 'adjustment_credit', 'adjustment_debit'],
            array_map(fn ($c) => $c->value, LedgerEntryType::cases()),
        );
    }

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

    public function test_amount_cents_must_be_positive(): void
    {
        $entry = SellerLedger::factory()->make(['amount_cents' => 0]);
        $this->expectException(QueryException::class);
        SellerLedger::query()->insert($entry->toArray() + ['id' => (string) \Illuminate\Support\Str::uuid()]);
    }

    public function test_direction_must_be_valid(): void
    {
        $store = \App\Models\Store::factory()->create();
        $this->expectException(QueryException::class);
        SellerLedger::query()->insert([
            'id' => (string) \Illuminate\Support\Str::uuid(),
            'store_id' => $store->id,
            'entry_type' => 'order_earned',
            'direction' => 'sideways',
            'amount_cents' => 100,
            'available_at' => now(),
            'description' => 'bad',
            'created_at' => now(),
        ]);
    }

    public function test_model_update_throws(): void
    {
        $entry = SellerLedger::factory()->create();
        $this->expectException(LogicException::class);
        $this->expectExceptionMessageMatches('/append-only/');
        $entry->update(['amount_cents' => 999]);
    }

    public function test_model_delete_throws(): void
    {
        $entry = SellerLedger::factory()->create();
        $this->expectException(LogicException::class);
        $this->expectExceptionMessageMatches('/append-only/');
        $entry->delete();
    }

    public function test_factory_default_creates_a_credit(): void
    {
        $entry = SellerLedger::factory()->create();
        $this->assertSame(LedgerEntryType::OrderEarned, $entry->entry_type);
        $this->assertSame(LedgerDirection::Credit, $entry->direction);
    }

    public function test_morph_relation_resolves(): void
    {
        $order = Order::factory()->create();
        $entry = SellerLedger::factory()->create([
            'source_type' => Order::class,
            'source_id' => $order->id,
        ]);
        $this->assertTrue($entry->source->is($order));
    }
}
```

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

- [ ] **Step 3: Implement** the migration + both enums + the model + the factory + the `Store::ledgerEntries(): HasMany` relation. Run `php artisan migrate`.

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

---

## Phase B — `LedgerWriter` service

### Task 2: `LedgerWriter` — the only legal write surface

**Files:**
- Create: `api/app/Modules/Ledger/Services/LedgerWriter.php`
- Create: `api/app/Modules/Ledger/README.md` *(optional but conventional — Modules/Shipping, Modules/Returns have one)*
- Test: `api/tests/Feature/Ledger/LedgerWriterTest.php`

`LedgerWriter` owns the rules: every entry-type method computes `direction`, `available_at`, `source_type`, `source_id`, and `description`. The shared private `persist()` writes via `SellerLedger::query()->insert(...)` to dodge the model's `update()` guard (and to keep Eloquent `saving` events from firing — which we don't want anyway).

```php
<?php

declare(strict_types=1);

namespace App\Modules\Ledger\Services;

use App\Models\Order;
use App\Models\OrderReturn;
use App\Models\SellerLedger;
use App\Models\Store;
use App\Support\Enums\LedgerDirection;
use App\Support\Enums\LedgerEntryType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use InvalidArgumentException;

final class LedgerWriter
{
    /** Hold-from-delivery, in days. Mirrors config/payouts.php cycle_days default. */
    private const HOLD_DAYS = 14;

    /** Credit the seller for an order's net proceeds (subtotal − platform fee).
     *  Released `HOLD_DAYS` after `delivered_at`. */
    public function recordOrderEarned(Order $order): SellerLedger
    {
        if ($order->delivered_at === null) {
            throw new InvalidArgumentException("Order {$order->id} has no delivered_at; cannot record order_earned.");
        }
        if ($order->seller_payout === null || $order->seller_payout <= 0) {
            throw new InvalidArgumentException("Order {$order->id} has no positive seller_payout; nothing to credit.");
        }

        return $this->persist([
            'store_id' => $order->store_id,
            'entry_type' => LedgerEntryType::OrderEarned,
            'direction' => LedgerDirection::Credit,
            'amount_cents' => (int) $order->seller_payout,
            'source_type' => Order::class,
            'source_id' => $order->id,
            'available_at' => $order->delivered_at->copy()->addDays(self::HOLD_DAYS),
            'description' => "Order #{$this->shortId($order->id)} delivered",
        ]);
    }

    /** Debit the seller for a refund issued on a return. `$amountCents` is the
     *  refund total (already computed by ReturnRefundIssuer and persisted as
     *  `refund_amount_cents`). Available immediately. */
    public function recordOrderRefunded(OrderReturn $return, int $amountCents): SellerLedger
    {
        if ($amountCents <= 0) {
            throw new InvalidArgumentException("Refund amount must be positive; got {$amountCents}.");
        }

        $return->loadMissing('order');

        return $this->persist([
            'store_id' => $return->order->store_id,
            'entry_type' => LedgerEntryType::OrderRefunded,
            'direction' => LedgerDirection::Debit,
            'amount_cents' => $amountCents,
            'source_type' => OrderReturn::class,
            'source_id' => $return->id,
            'available_at' => now(),
            'description' => "Return refund on order #{$this->shortId($return->order_id)}",
        ]);
    }

    /** Debit the seller for an EasyPost label cost (outbound OR return shipment).
     *  `$source` is whatever model attaches the label — `Order` for outbound,
     *  `OrderReturn` for return labels. Available immediately. */
    public function recordLabelCost(Store $store, int $costCents, Model $source): SellerLedger
    {
        if ($costCents <= 0) {
            throw new InvalidArgumentException("Label cost must be positive; got {$costCents}.");
        }

        return $this->persist([
            'store_id' => $store->id,
            'entry_type' => LedgerEntryType::LabelCostDebit,
            'direction' => LedgerDirection::Debit,
            'amount_cents' => $costCents,
            'source_type' => $source::class,
            'source_id' => $source->getKey(),
            'available_at' => now(),
            'description' => $source instanceof OrderReturn
                ? "Return label for order #{$this->shortId($source->order_id)}"
                : ($source instanceof Order
                    ? "Shipping label for order #{$this->shortId($source->id)}"
                    : 'Shipping label cost'),
        ]);
    }

    private function persist(array $attributes): SellerLedger
    {
        $id = (string) Str::uuid();
        $row = $attributes + [
            'id' => $id,
            'created_at' => now(),
        ];

        // Cast enums to scalars for the raw insert.
        $row['entry_type'] = $row['entry_type'] instanceof LedgerEntryType ? $row['entry_type']->value : $row['entry_type'];
        $row['direction'] = $row['direction'] instanceof LedgerDirection ? $row['direction']->value : $row['direction'];

        SellerLedger::query()->insert($row);

        return SellerLedger::query()->whereKey($id)->firstOrFail();
    }

    private function shortId(string $uuid): string
    {
        return substr($uuid, 0, 8);
    }
}
```

> **Plan note (`LedgerWriter` injection vs facade) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Two patterns coexist in the codebase: `ReturnTransitioner` constructor-injects its dependencies (`ReturnRefundIssuer`, `ReturnLabelService`, `MessagePoster`); `OrderFulfillmentService` constructor-injects `LabelProvider`. **Plan 1 commits to constructor injection** — every consumer (`OrderFulfillmentService`, `ReturnTransitioner`, `ReturnLabelService`) gains a `private readonly LedgerWriter $ledger` parameter. The class is `final` and stateless; Laravel auto-resolves it from the container. **No facades, no `app(LedgerWriter::class)` calls.** Tests can `$this->app->instance(LedgerWriter::class, $mock)` if they want to spy on writes, or just assert against `SellerLedger::query()` after the fact (preferred — fewer mocks, more realistic).

> **Plan note (raw `insert` vs Eloquent `create`):** `SellerLedger::query()->insert([...])` is a raw DB insert that skips model events and skips Eloquent's `save()` machinery — which would otherwise route through `update()` for existing instances. We could alternatively call `SellerLedger::create([...])` (which goes through `Model::save()` → `performInsert()`, not `performUpdate()`, so the throw doesn't trigger), but `create()` requires `$fillable` or `$guarded = []` plus it fires `creating` / `created` events. The raw insert is one less surface to reason about and the entries are simple flat rows — no observers needed.

> **Plan note (description format / i18n):** Descriptions are plain English ("Order #abcd1234 delivered"). No `__()` wrapping in Plan 1 because the seller-facing statements page (Plan 3) renders them as-is and we don't have a translation pipeline yet. The short-id prefix matches the existing system-message pattern (`MessagePoster::postSystem` uses raw English). If/when localisation arrives, descriptions become a future migration to translatable keys; Plan 1 makes no concession to that.

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Ledger;

use App\Models\Order;
use App\Models\OrderReturn;
use App\Models\SellerLedger;
use App\Models\Store;
use App\Modules\Ledger\Services\LedgerWriter;
use App\Support\Enums\LedgerDirection;
use App\Support\Enums\LedgerEntryType;
use Illuminate\Foundation\Testing\RefreshDatabase;
use InvalidArgumentException;
use Tests\TestCase;

class LedgerWriterTest extends TestCase
{
    use RefreshDatabase;

    public function test_record_order_earned_writes_credit_with_14d_hold(): void
    {
        $order = Order::factory()->create([
            'delivered_at' => '2026-05-01 12:00:00',
            'seller_payout' => 8500,
        ]);

        $entry = app(LedgerWriter::class)->recordOrderEarned($order);

        $this->assertSame(LedgerEntryType::OrderEarned, $entry->entry_type);
        $this->assertSame(LedgerDirection::Credit, $entry->direction);
        $this->assertSame(8500, $entry->amount_cents);
        $this->assertSame((string) $order->store_id, (string) $entry->store_id);
        $this->assertSame(Order::class, $entry->source_type);
        $this->assertSame($order->id, $entry->source_id);
        $this->assertSame('2026-05-15 12:00:00', $entry->available_at->format('Y-m-d H:i:s'));
        $this->assertNull($entry->payout_id);
    }

    public function test_record_order_earned_rejects_undelivered_order(): void
    {
        $order = Order::factory()->create(['delivered_at' => null, 'seller_payout' => 100]);
        $this->expectException(InvalidArgumentException::class);
        app(LedgerWriter::class)->recordOrderEarned($order);
    }

    public function test_record_order_earned_rejects_zero_payout(): void
    {
        $order = Order::factory()->create(['delivered_at' => now(), 'seller_payout' => 0]);
        $this->expectException(InvalidArgumentException::class);
        app(LedgerWriter::class)->recordOrderEarned($order);
    }

    public function test_record_order_refunded_writes_debit_available_now(): void
    {
        $return = OrderReturn::factory()->create();

        $entry = app(LedgerWriter::class)->recordOrderRefunded($return, 4200);

        $this->assertSame(LedgerEntryType::OrderRefunded, $entry->entry_type);
        $this->assertSame(LedgerDirection::Debit, $entry->direction);
        $this->assertSame(4200, $entry->amount_cents);
        $this->assertSame(OrderReturn::class, $entry->source_type);
        $this->assertSame($return->id, $entry->source_id);
        $this->assertTrue($entry->available_at->isToday());
    }

    public function test_record_label_cost_with_order_source(): void
    {
        $order = Order::factory()->create();

        $entry = app(LedgerWriter::class)->recordLabelCost($order->store, 765, $order);

        $this->assertSame(LedgerEntryType::LabelCostDebit, $entry->entry_type);
        $this->assertSame(LedgerDirection::Debit, $entry->direction);
        $this->assertSame(765, $entry->amount_cents);
        $this->assertSame(Order::class, $entry->source_type);
        $this->assertStringContainsString('Shipping label', $entry->description);
    }

    public function test_record_label_cost_with_return_source(): void
    {
        $return = OrderReturn::factory()->create();

        $entry = app(LedgerWriter::class)->recordLabelCost($return->order->store, 432, $return);

        $this->assertSame(OrderReturn::class, $entry->source_type);
        $this->assertSame($return->id, $entry->source_id);
        $this->assertStringContainsString('Return label', $entry->description);
    }

    public function test_record_label_cost_rejects_zero_cost(): void
    {
        $order = Order::factory()->create();
        $this->expectException(InvalidArgumentException::class);
        app(LedgerWriter::class)->recordLabelCost($order->store, 0, $order);
    }

    public function test_writer_uses_raw_insert_bypassing_update_guard(): void
    {
        // If LedgerWriter ever regressed to using ->save()/->update() on a
        // hydrated model, the model's LogicException override would fire.
        // Calling each public method must NOT throw.
        $order = Order::factory()->create(['delivered_at' => now(), 'seller_payout' => 100]);
        app(LedgerWriter::class)->recordOrderEarned($order);
        $this->addToAssertionCount(1);
    }
}
```

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

- [ ] **Step 3: Implement** `LedgerWriter`. Register no service-provider binding — Laravel's auto-resolution handles it (the class has no interface dependencies).

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

---

## Phase C — Event hooks

### Task 3: `OrderFulfillmentService::markDelivered` writes `order_earned`

**Files:**
- Update: `api/app/Modules/Orders/Services/OrderFulfillmentService.php` (inject `LedgerWriter`; call inside `markDelivered`)
- Update: `api/tests/Feature/Orders/OrderFulfillmentServiceTest.php` (or wherever `markDelivered` is tested today — search and extend)
- Create: `api/tests/Feature/Ledger/OrderDeliveredWritesLedgerTest.php` *(focused integration test)*

> **Plan note (where `OrderTransitioner` lives) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** The spec talks about "OrderTransitioner" but the **actual class** is `App\Modules\Orders\Services\OrderFulfillmentService` at `api/app/Modules/Orders/Services/OrderFulfillmentService.php`. Confirmed by reading it: `markDelivered(Order)` (line 99–109) is the single canonical "advance to delivered" code path. Two call sites converge on this method: `App\Modules\Shipping\Services\TrackingService::applyUpdate` (line 42 — carrier webhook) and `App\Modules\Orders\Controllers\OrderFulfillmentController::markDelivered` (seller manual override). **Plan 1 hooks the service method, not the controller or webhook, so both paths get the ledger write for free.** The `OrderDelivered::dispatch($order->fresh())` event is also fired here, but **we do not use an event listener** — listeners fire after the transaction commits and can be queued; the ledger entry must be transactionally consistent with the order's status change. Inline DI call, inside the same code block.

Updated `markDelivered`:

```php
public function __construct(
    private readonly LabelProvider $labelProvider,
    private readonly LedgerWriter $ledger,   // NEW
) {}

public function markDelivered(Order $order): void
{
    if ($order->status === OrderStatus::Delivered) {
        return;
    }

    DB::transaction(function () use ($order) {
        $order->update([
            'status' => OrderStatus::Delivered,
            'delivered_at' => now(),
        ]);

        // NEW: record the credit on the same transaction as the status change.
        // Idempotency: the early-return above blocks repeats; if Plan 2 ever
        // adds a "redeliver" path, it must also handle de-duplicating the
        // ledger entry (out of scope here).
        $this->ledger->recordOrderEarned($order->fresh());
    });

    OrderDelivered::dispatch($order->fresh());
}
```

> **Plan note (idempotency):** `markDelivered`'s early-return on `OrderStatus::Delivered` already short-circuits repeated webhook deliveries. The ledger write happens inside the transaction *after* the status update, so a concurrent racer hitting the same code path also short-circuits via the `status` check (Postgres row-level read-after-write within the transaction). No extra `firstOrCreate` guard on the ledger row needed in Plan 1; if a future bug ever bypasses the status check, the read query in Phase D would over-count — surface in monitoring, fix at the source.

- [ ] **Step 1: Write the failing tests** — `OrderDeliveredWritesLedgerTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Ledger;

use App\Models\Order;
use App\Models\SellerLedger;
use App\Modules\Orders\Services\OrderFulfillmentService;
use App\Support\Enums\LedgerDirection;
use App\Support\Enums\LedgerEntryType;
use App\Support\Enums\OrderStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;

class OrderDeliveredWritesLedgerTest extends TestCase
{
    use RefreshDatabase;

    public function test_mark_delivered_writes_order_earned_credit(): void
    {
        Carbon::setTestNow('2026-05-01 12:00:00');
        $order = Order::factory()->create([
            'status' => OrderStatus::Shipped,
            'delivered_at' => null,
            'seller_payout' => 12000,
        ]);

        app(OrderFulfillmentService::class)->markDelivered($order);

        $entry = SellerLedger::query()->where('source_id', $order->id)->sole();
        $this->assertSame(LedgerEntryType::OrderEarned, $entry->entry_type);
        $this->assertSame(LedgerDirection::Credit, $entry->direction);
        $this->assertSame(12000, $entry->amount_cents);
        $this->assertSame('2026-05-15 12:00:00', $entry->available_at->format('Y-m-d H:i:s'));
    }

    public function test_mark_delivered_is_idempotent_on_repeat(): void
    {
        $order = Order::factory()->create([
            'status' => OrderStatus::Shipped,
            'delivered_at' => null,
            'seller_payout' => 9000,
        ]);

        app(OrderFulfillmentService::class)->markDelivered($order);
        app(OrderFulfillmentService::class)->markDelivered($order->fresh());

        $this->assertSame(1, SellerLedger::query()->where('source_id', $order->id)->count());
    }

    public function test_mark_delivered_via_tracking_service_also_writes_entry(): void
    {
        // Webhook path — confirms the hook covers both call sites.
        $order = Order::factory()->create([
            'status' => OrderStatus::Shipped,
            'tracker_id' => 'trk_test_xyz',
            'seller_payout' => 5500,
        ]);

        app(\App\Modules\Shipping\Services\TrackingService::class)
            ->applyUpdate('trk_test_xyz', 'delivered');

        $this->assertSame(1, SellerLedger::query()->where('source_id', $order->id)->count());
    }
}
```

Also extend any existing `OrderFulfillmentServiceTest` that asserts on `markDelivered` so the new ledger-row side effect doesn't surprise the next reader.

- [ ] **Step 2: Run, confirm failure** — ledger entry not created yet.

- [ ] **Step 3: Implement** the constructor change + inline `recordOrderEarned` call inside a `DB::transaction` wrapping the status update. **Wrap in `DB::transaction` even though the original code didn't** — the ledger write must be atomic with the status change.

- [ ] **Step 4: Run; iterate to 3/3 PASS.** Also re-run the full Orders test suite to confirm no fallout from wrapping `markDelivered` in a transaction.

### Task 4: `ReturnTransitioner::markReceived` writes `order_refunded`

**Files:**
- Update: `api/app/Modules/Returns/Services/ReturnTransitioner.php` (inject `LedgerWriter`; call after `refunds->issue`)
- Update: `api/tests/Feature/Returns/ReturnTransitionerTest.php` (extend existing markReceived tests)
- Create: `api/tests/Feature/Ledger/ReturnRefundWritesLedgerTest.php`

> **Plan note (debit alongside existing reverseTransfer — but `reverseTransfer` is NOT called from returns today):** Spec'd text says "the refund flow currently calls `StripeService::reverseTransfer` unconditionally". **Audit shows that's not actually the case.** `ReturnRefundIssuer::issue` at `api/app/Modules/Returns/Services/ReturnRefundIssuer.php` calls `StripeService::refundForOrder` only — it does **not** call `reverseTransfer` anywhere. `reverseTransfer` is invoked only from the dispute / admin-order-action / reconcile-failed-money-movements paths today. So Plan 1's job is simpler than the spec implied: just add the debit entry; there's no existing reverse-transfer call in the return-refund path to step around. **No temporary duplication to clean up in Plan 3.** Plan 3's "audit reverseTransfer in refunds" still applies to the dispute/admin paths, but the return-refund path is already clean. Documenting the discrepancy here so a future reader understands why Plan 1's diff is small.

Updated `markReceived` (inside the existing transaction, immediately after `$this->refunds->issue(...)` and `$return->refresh()` on line 184):

```php
$this->refunds->issue(
    $return,
    overrideAmountCents: $return->refund_amount_override_cents,
    includeOriginalShipping: (bool) $return->refund_original_shipping,
);
$return->refresh();

// NEW: mirror the refund as a ledger debit. amount_cents = the same
// value the issuer just persisted on the return row.
$this->ledger->recordOrderRefunded($return, (int) $return->refund_amount_cents);

$return->update(['state' => ReturnState::Refunded]);
```

Constructor gains `private readonly LedgerWriter $ledger`.

Same in `ProactiveReturnService::issue` at `api/app/Modules/Returns/Services/ProactiveReturnService.php` line 83 — the keep-it path also fires `refunds->issue`; add the same `recordOrderRefunded` call immediately after. (The ship-back proactive path routes through `markReceived` later and gets the entry from Task 4's hook — don't double-write.)

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

```php
public function test_mark_received_writes_order_refunded_debit_with_same_amount_as_stripe_refund(): void
public function test_mark_received_writes_debit_available_now_not_held_14_days(): void
public function test_keep_it_proactive_refund_writes_debit_via_proactive_service(): void
public function test_ship_back_proactive_refund_only_writes_one_debit_not_two(): void
// (the proactive ship-back path creates the return → eventually markReceived fires;
//  ensure ProactiveReturnService does NOT also call recordOrderRefunded in that branch)
public function test_no_ledger_entry_when_refund_issuer_throws(): void
// (assertion: failed Stripe refund → no debit row, because the call sits inside
//  the same DB::transaction as the issuer; transaction rolls back)
```

Plus extend the existing `ReturnTransitionerTest::test_mark_received_*` cases with an assertion that `SellerLedger::query()->count()` increased by exactly one and the entry has `entry_type = OrderRefunded`.

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

- [ ] **Step 3: Implement** the constructor change in `ReturnTransitioner` + the inline call. Then update `ProactiveReturnService::issue`'s keep-it branch similarly; constructor gains `LedgerWriter` there too.

- [ ] **Step 4: Run; iterate to 5/5 PASS** + the extended `ReturnTransitionerTest` cases still green.

### Task 5: EasyPost label hooks — outbound + return both write `label_cost_debit`

**Files:**
- Update: `api/app/Modules/Orders/Services/OrderFulfillmentService.php` (inject `LedgerWriter` once — Task 3 already added it — call after the EasyPost `buyCheapestLabel` succeeds)
- Update: `api/app/Modules/Returns/Services/ReturnLabelService.php` (inject `LedgerWriter`; call after the label-update block)
- Update: `api/tests/Feature/Returns/ReturnLabelServiceTest.php` (existing tests confirm no regression; add a ledger assertion)
- Update: existing Orders fulfillment test (extend `purchaseLabel` happy-path assertion)
- Create: `api/tests/Feature/Ledger/LabelCostWritesLedgerTest.php`

Updated `OrderFulfillmentService::purchaseLabel` (inside the existing `DB::transaction`, after `$order->update([...])`):

```php
DB::transaction(function () use ($order, $label) {
    $order->update([
        'tracker_id' => $label->trackerId,
        // ...existing fields...
        'status' => OrderStatus::Shipped,
    ]);

    // NEW: charge the seller for the EasyPost label cost. $label->rateCents
    // is the integer cost in cents already.
    if ($label->rateCents > 0) {
        $this->ledger->recordLabelCost($order->store, $label->rateCents, $order->fresh());
    }
});
```

Updated `ReturnLabelService::issue` (after the `$return->update([...])` block that persists shipment artifacts):

```php
$return->update([
    'easypost_shipment_id' => $label->shipmentId,
    // ...existing fields...
    'easypost_shipment_cost_cents' => $label->rateCents,
    'easypost_label_error' => null,
    'label_issued_at' => now(),
]);

// NEW: debit the seller for the return label cost.
if ($label->rateCents > 0) {
    $return->loadMissing('order.store');
    $this->ledger->recordLabelCost($return->order->store, $label->rateCents, $return);
}
```

> **Plan note (`$label->rateCents > 0` guard):** Both `EasyPostProvider` and `FakeLabelProvider` return a `PurchasedLabel` DTO with `public readonly int $rateCents`. In test environments with `FakeLabelProvider`, `rateCents` is typically a positive faker value; in production EasyPost returns the actual carrier rate. The `> 0` guard handles the theoretical case of a zero-rated label (shouldn't happen with real carriers but cheap defence).

> **Plan note (no idempotency on label cost):** Labels are purchased exactly once per order/return. The status guard in `purchaseLabel` (`in_array($order->status, [Pending, Processing])`) and the state guard in `ReturnLabelService` callers prevent re-purchase. If retry logic later allows label re-purchase (Plan 2 doesn't; Plan 3 might if the spec's "EasyPost label expiration" open item gets addressed), the writer call must be guarded against duplicate entries. Out of Plan 1 scope.

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

```php
public function test_outbound_label_purchase_writes_label_cost_debit(): void
{
    $order = Order::factory()->create(['status' => OrderStatus::Pending]);
    $preset = StoreParcelPreset::factory()->create(['store_id' => $order->store_id]);

    $fakeLabel = new PurchasedLabel(
        shipmentId: 'shp_1', trackerId: 'trk_1', trackingNumber: '...',
        trackingUrl: '...', labelUrl: '...', carrier: 'USPS',
        service: 'Priority', rateCents: 875,
    );
    $provider = Mockery::mock(LabelProvider::class);
    $provider->shouldReceive('buyCheapestLabel')->andReturn($fakeLabel);
    $this->app->instance(LabelProvider::class, $provider);

    app(OrderFulfillmentService::class)->purchaseLabel($order, $preset);

    $entry = SellerLedger::query()
        ->where('entry_type', LedgerEntryType::LabelCostDebit)
        ->where('source_id', $order->id)->sole();
    $this->assertSame(875, $entry->amount_cents);
    $this->assertSame(LedgerDirection::Debit, $entry->direction);
}

public function test_return_label_issue_writes_label_cost_debit(): void
public function test_label_cost_debit_uses_return_as_source_type_for_return_labels(): void
public function test_label_cost_debit_uses_order_as_source_type_for_outbound_labels(): void
public function test_zero_rate_label_does_not_write_entry(): void
public function test_label_cost_debit_is_available_immediately(): void
```

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

- [ ] **Step 3: Implement** the two service updates. `OrderFulfillmentService` already gained `LedgerWriter` in Task 3 — just add the call site. `ReturnLabelService` gets a fresh constructor dependency.

- [ ] **Step 4: Run; iterate to 6/6 PASS** + existing `ReturnLabelServiceTest` + `OrderFulfillmentServiceTest` still green.

---

## Phase D — Balance read service + endpoint

### Task 6: `PayoutSchedule::nextCycleDate()` helper

**Files:**
- Create: `api/config/payouts.php`
- Create: `api/app/Modules/Ledger/Support/PayoutSchedule.php`
- Test: `api/tests/Feature/Ledger/PayoutScheduleTest.php`

> **Plan note (static helper now, real cron later) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Plan 1's `GET /v1/seller/balance` response includes `next_payout_date` (spec line 187). Plan 2 hasn't built the cron yet. We could (a) return `null` from Plan 1 and backfill in Plan 2, or (b) ship a static helper now that computes the next biweekly anchor; Plan 2's real cron then *uses* the same helper. **Going with (b).** The helper isn't throwaway — Plan 2's `PayoutService::scheduleForCycle` reuses it. Edge cases the helper handles: anchor at midnight, anchor mid-cycle, day-rollover at the anchor instant. DST is a non-issue because we work in UTC (`Carbon::now('UTC')`).

`config/payouts.php`:

```php
<?php

return [
    /*
    | Anchor date: the first Sunday of the marketplace's launch fortnight.
    | All cycle boundaries are computed by adding `cycle_days` repeatedly.
    | Stored as ISO date — interpreted as UTC 00:00:00.
    */
    'cycle_anchor' => env('PAYOUTS_CYCLE_ANCHOR', '2026-05-03'),
    'cycle_days' => (int) env('PAYOUTS_CYCLE_DAYS', 14),
];
```

`PayoutSchedule.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Ledger\Support;

use Carbon\CarbonImmutable;

final class PayoutSchedule
{
    /** Returns the next cycle boundary strictly after `$now`, in UTC. */
    public static function nextCycleDate(CarbonImmutable $now): CarbonImmutable
    {
        $anchor = CarbonImmutable::parse(config('payouts.cycle_anchor'), 'UTC')->startOfDay();
        $cycleDays = (int) config('payouts.cycle_days', 14);

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

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

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

        $daysSinceAnchor = $anchor->diffInDays($nowUtc);
        $cyclesElapsed = intdiv($daysSinceAnchor, $cycleDays);
        $next = $anchor->addDays(($cyclesElapsed + 1) * $cycleDays);

        // If we landed exactly on a boundary, advance one more cycle so the
        // "next" boundary is strictly in the future.
        return $next->lessThanOrEqualTo($nowUtc)
            ? $next->addDays($cycleDays)
            : $next;
    }
}
```

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

```php
public function test_now_at_anchor_returns_anchor_plus_cycle_days(): void
{
    config(['payouts.cycle_anchor' => '2026-05-03', 'payouts.cycle_days' => 14]);
    $next = PayoutSchedule::nextCycleDate(CarbonImmutable::parse('2026-05-03 00:00:00', 'UTC'));
    $this->assertSame('2026-05-17', $next->format('Y-m-d'));
}

public function test_mid_cycle_returns_next_boundary(): void
{
    config(['payouts.cycle_anchor' => '2026-05-03', 'payouts.cycle_days' => 14]);
    $next = PayoutSchedule::nextCycleDate(CarbonImmutable::parse('2026-05-10 12:00:00', 'UTC'));
    $this->assertSame('2026-05-17', $next->format('Y-m-d'));
}

public function test_one_minute_past_boundary_returns_following_boundary(): void
{
    config(['payouts.cycle_anchor' => '2026-05-03', 'payouts.cycle_days' => 14]);
    $next = PayoutSchedule::nextCycleDate(CarbonImmutable::parse('2026-05-17 00:01:00', 'UTC'));
    $this->assertSame('2026-05-31', $next->format('Y-m-d'));
}

public function test_before_anchor_returns_anchor(): void
{
    config(['payouts.cycle_anchor' => '2026-05-03', 'payouts.cycle_days' => 14]);
    $next = PayoutSchedule::nextCycleDate(CarbonImmutable::parse('2026-04-20 00:00:00', 'UTC'));
    $this->assertSame('2026-05-03', $next->format('Y-m-d'));
}

public function test_zero_or_negative_cycle_days_throws(): void
public function test_cycle_days_of_7_produces_weekly_boundaries(): void
// (config override; assert pacing)
```

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

- [ ] **Step 3: Implement** the config file and the helper.

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

### Task 7: `BalanceService::forStore` + `BalanceSnapshot`

**Files:**
- Create: `api/app/Modules/Ledger/Services/BalanceService.php`
- Create: `api/app/Modules/Ledger/DTOs/BalanceSnapshot.php` *(readonly DTO — same shape as the API response)*
- Test: `api/tests/Feature/Ledger/BalanceServiceTest.php`

```php
<?php

declare(strict_types=1);

namespace App\Modules\Ledger\Services;

use App\Models\Store;
use App\Modules\Ledger\DTOs\BalanceSnapshot;
use App\Modules\Ledger\Support\PayoutSchedule;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;

final class BalanceService
{
    public function forStore(Store $store): BalanceSnapshot
    {
        $now = CarbonImmutable::now('UTC');

        // SUM(amount × direction) over available, unpaid entries.
        $available = (int) DB::table('seller_ledger')
            ->where('store_id', $store->id)
            ->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');

        // Same expression, restricted to future-dated entries (the "pending" bucket).
        $pending = (int) DB::table('seller_ledger')
            ->where('store_id', $store->id)
            ->where('available_at', '>', $now)
            ->selectRaw(
                "COALESCE(SUM(CASE WHEN direction = 'credit' THEN amount_cents ELSE -amount_cents END), 0) AS net"
            )
            ->value('net');

        return new BalanceSnapshot(
            available_cents: $available,
            pending_cents: $pending,
            next_payout_date: PayoutSchedule::nextCycleDate($now)->format('Y-m-d'),
        );
    }
}
```

`BalanceSnapshot`:

```php
final readonly class BalanceSnapshot
{
    public function __construct(
        public int $available_cents,
        public int $pending_cents,
        public string $next_payout_date,  // ISO date
    ) {}
}
```

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

```php
public function test_empty_ledger_returns_zero_zero_with_next_date(): void
public function test_credit_with_past_available_at_counts_as_available(): void
public function test_credit_with_future_available_at_counts_as_pending(): void
public function test_debit_with_past_available_at_subtracts_from_available(): void
public function test_debit_with_future_available_at_subtracts_from_pending(): void  // edge case but write the test
public function test_entry_with_payout_id_is_excluded_from_available(): void
// (Plan 2 will use this; Plan 1 just guarantees the column is honoured)
public function test_negative_available_balance_is_returned_as_negative_int(): void
// (refund > earnings post-payout — admin sees the negative; not clamped to zero)
public function test_balance_only_counts_entries_for_the_target_store(): void
public function test_14_day_hold_pending_to_available_transition_via_setTestNow(): void
// (write a credit with available_at = now+14d; setTestNow forward; re-read; assert moved)
public function test_response_includes_next_payout_date_from_schedule(): void
```

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

- [ ] **Step 3: Implement** `BalanceSnapshot` + `BalanceService`.

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

### Task 8: `SellerBalanceController::show` + route

**Files:**
- Create: `api/app/Modules/Ledger/Controllers/SellerBalanceController.php`
- Create: `api/app/Modules/Ledger/routes.php` *(or fold into the existing `Stores/routes.php` for the `/stores/{store}` prefix)*
- Update: `api/bootstrap/app.php` or `api/routes/api.php` *(wherever module route files are loaded)*
- Test: `api/tests/Feature/Ledger/SellerBalanceEndpointTest.php`

```php
<?php

declare(strict_types=1);

namespace App\Modules\Ledger\Controllers;

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

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

    public function show(Store $store): JsonResponse
    {
        $snapshot = $this->balances->forStore($store);

        return response()->json([
            'data' => [
                'available_cents' => $snapshot->available_cents,
                'pending_cents' => $snapshot->pending_cents,
                'next_payout_date' => $snapshot->next_payout_date,
            ],
        ]);
    }
}
```

Route — chooses to live under the existing `auth:sanctum + store.owner` group in `api/app/Modules/Stores/routes.php` (mirrors `dashboard/metrics` precedent on line 26):

```php
Route::get('/stores/{store}/balance', [SellerBalanceController::class, 'show']);
```

> **Plan note (path: `/stores/{store}/balance` vs `/seller/balance`) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** The spec line 187 says `GET /v1/seller/balance` (no store id in path), but the existing seller-dashboard endpoint is `/stores/{store}/dashboard/metrics` — store-scoped, store-owner-middleware gated. **Plan 1 follows the existing pattern**: `GET /v1/stores/{store}/balance`. Authorisation via `store.owner` middleware (same as `dashboard/metrics`). Pros: composable for a future multi-store seller, free auth guard, matches existing conventions. The spec's `/seller/balance` shape would need a different auth pattern (lookup the user's store implicitly). Plan 1 documents this divergence from the spec; if multi-store sellers are not on the roadmap, a future cleanup can collapse to `/seller/balance`. **OpenAPI in Task 9 documents the actual path as `/v1/stores/{store}/balance`.**

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

```php
public function test_get_balance_returns_snapshot_for_store_owner(): void
public function test_get_balance_returns_403_for_non_owner(): void
public function test_get_balance_requires_authentication(): void
public function test_response_shape_includes_three_keys(): void
public function test_endpoint_excludes_other_stores_balances(): void
// (two stores; only one's entries should appear)
```

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

- [ ] **Step 3: Implement** the controller + route registration. Re-use the existing `store.owner` middleware.

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

---

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

### Task 9: OpenAPI spec

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

Add one path + one component schema:

```yaml
/v1/stores/{store}/balance:
  get:
    tags: [Seller]
    summary: Current ledger balance for a store (seller-owner only)
    parameters:
      - { $ref: '#/components/parameters/StoreIdPathParam' }
    responses:
      '200':
        description: Balance snapshot
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/SellerBalance' }
      '401': { $ref: '#/components/responses/Unauthenticated' }
      '403': { $ref: '#/components/responses/Forbidden' }

# Under components.schemas:
SellerBalance:
  type: object
  required: [available_cents, pending_cents, next_payout_date]
  properties:
    available_cents: { type: integer, description: 'Net of credits − debits with available_at ≤ now and payout_id IS NULL. May be negative if recent refunds exceed earnings.' }
    pending_cents:   { type: integer, description: 'Net of credits − debits with available_at > now (still in 14-day hold).' }
    next_payout_date:
      type: string
      format: date
      description: 'ISO date of the next biweekly payout cycle boundary (computed from config/payouts.php).'
```

If `StoreIdPathParam` doesn't already exist as a reusable parameter, define it inline (UUID string).

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

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

### Task 10: Sync to web + types + api-client wrapper

**Files:**
- Sync: `~/projects/alqove-web/contracts/openapi.yaml` (via `./bin/sync-openapi.sh`)
- Build: `npm run build:types` (regenerates `web/packages/types/src/generated.ts`)
- Update: `web/packages/api-client/src/endpoints/seller.ts`

```ts
export interface SellerBalance {
  available_cents: number;
  pending_cents: number;
  next_payout_date: string;  // ISO date
}

export function createSellerEndpoints(client: AlqoveClient) {
  return {
    dashboardMetrics(storeId: string) { /* existing */ },
    balance(storeId: string) {
      return client.get<ApiResponse<SellerBalance>>(`/v1/stores/${storeId}/balance`);
    },
  };
}
```

- [ ] **Step 1: Sync.**
- [ ] **Step 2: Build types.**
- [ ] **Step 3: Wire api-client endpoint.**
- [ ] **Step 4: Typecheck** — `npm run typecheck` at root + `npx tsc --noEmit` in `web/`. Clean.

---

## Phase F — Frontend

### Task 11: `useBalance` TanStack hook

**Files:**
- Create: `web/src/lib/queries/use-balance.ts`
- Test: `web/src/lib/queries/__tests__/use-balance.test.ts` *(small smoke test — assert the hook calls the right endpoint and surfaces the snapshot)*

```ts
'use client';

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

export const BALANCE_KEYS = {
  forStore: (storeId: string) => ['seller', 'balance', storeId] as const,
};

const REFETCH_INTERVAL_MS = 60_000;

export function useBalance(storeId: string | null | undefined) {
  return useQuery<SellerBalance>({
    queryKey: BALANCE_KEYS.forStore(storeId ?? 'none'),
    enabled: Boolean(storeId),
    queryFn: async () => {
      const res = await api.seller.balance(storeId!);
      return res.data;
    },
    refetchInterval: REFETCH_INTERVAL_MS,
    staleTime: 30_000,
  });
}
```

- [ ] **Step 1: Write the failing test** — mock `api.seller.balance` to return a snapshot; render via `renderHook` + `QueryClientProvider`; assert `result.current.data` matches.

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

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

- [ ] **Step 4: Run; iterate to PASS** (~2 tests).

### Task 12: `<SellerBalanceWidget>` component

**Files:**
- Create: `web/src/components/seller/seller-balance-widget.tsx`
- Test: `web/src/components/seller/__tests__/seller-balance-widget.test.tsx`

Two-line display, matches the spec line 199 "Available: $X.XX (paying out [date])" / "Pending: $Y.YY":

```tsx
'use client';

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

interface Props {
  storeId: string;
}

export function SellerBalanceWidget({ storeId }: Props) {
  const { data, isLoading } = useBalance(storeId);

  if (isLoading || !data) {
    return (
      <div className="rounded-md border border-forest/20 bg-white p-4 text-sm text-forest/50">
        Loading balance…
      </div>
    );
  }

  const formatDate = (iso: string) =>
    new Date(`${iso}T00:00:00Z`).toLocaleDateString(undefined, {
      month: 'short',
      day: 'numeric',
      timeZone: 'UTC',
    });

  return (
    <div className="rounded-md border border-forest/20 bg-white p-4">
      <div className="text-xs uppercase tracking-wide text-forest/70">Balance</div>
      <div className="mt-1 text-sm text-ink">
        <span className="font-semibold">Available:</span> {formatPrice(data.available_cents)}{' '}
        <span className="text-forest/70">
          (paying out {formatDate(data.next_payout_date)})
        </span>
      </div>
      <div className="mt-0.5 text-sm text-ink">
        <span className="font-semibold">Pending:</span> {formatPrice(data.pending_cents)}{' '}
        <span className="text-forest/70">(releasing as orders deliver + 14 days)</span>
      </div>
    </div>
  );
}
```

Tests assert: (a) loading state renders the skeleton, (b) populated state renders both lines with formatted dollars + date, (c) negative available balance renders as expected, (d) the storeId prop drives the hook query.

- [ ] **Step 1: Write the failing tests** (~4 cases).

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

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

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

### Task 13: Mount on `/seller` home

**Files:**
- Update: `web/src/app/(seller)/seller/page.tsx` (add `<SellerBalanceWidget>` above the KPI row)
- Update: `web/src/app/(seller)/seller/__tests__/page.test.tsx` *(if it exists — if not, just verify the existing dashboard test doesn't regress)*

Insert directly under the `<h1>` and above the KPI row:

```tsx
{storeId ? <SellerBalanceWidget storeId={storeId} /> : null}
```

> **Plan note (the existing `next_payout` KPI tile):** `useSellerDashboard` already returns a `next_payout: { amount_cents, arrival_date }` field rendered in a KPI tile (see `web/src/app/(seller)/seller/page.tsx` line 78–81). That field is computed from a *different* code path today (a Plan-pre-11 placeholder using delivered-but-not-paid order subtotals). **Plan 1 does NOT replace the KPI tile.** The new widget shows ledger-derived balance; the old tile continues to compute its own estimate. Plan 2 will reconcile the two (and likely retire the KPI tile in favour of the widget) once the real `Payout` model exists. Document the temporary duplication.

- [ ] **Step 1: Wire the widget.**

- [ ] **Step 2: Run all web tests; confirm the page still renders.**

---

## Phase G — Wrap-up

### Task 14: Full sweep

- [ ] **Step 1: Backend** — `docker compose exec -T laravel.test php artisan test`. Expected: **704 → ~740** (~36 new tests: 10 schema + 8 writer + 3 order-delivered hook + 5 return-refund hook + 6 label-cost hook + 6 payout-schedule + 10 balance-service + 5 balance-endpoint; minor double-counting where an existing test was extended rather than newly authored).

- [ ] **Step 2: Backend lint** — `cd api && ./vendor/bin/pint app/Modules/Ledger app/Modules/Orders/Services/OrderFulfillmentService.php app/Modules/Returns/Services/ReturnTransitioner.php app/Modules/Returns/Services/ReturnLabelService.php app/Modules/Returns/Services/ProactiveReturnService.php app/Models/SellerLedger.php app/Support/Enums/LedgerEntryType.php app/Support/Enums/LedgerDirection.php tests/Feature/Ledger config/payouts.php`. Expected PASS or auto-fix.

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

- [ ] **Step 4: Web lint** — `npm run lint`. Expected baseline (no new warnings introduced by the widget).

- [ ] **Step 5: Web tests** — `npm run test`. Expected: **241 → ~247** (1 hook smoke + 4 widget + 1 dashboard regression check).

- [ ] **Step 6: Local build** — `npm run build:web`. Should be a clean static build; the widget is client-side via `'use client'`.

- [ ] **Step 7: No manual QA scenarios in Plan 1.** The end-to-end flows are covered by the integration tests (`OrderDeliveredWritesLedgerTest`, `LabelCostWritesLedgerTest`, `ReturnRefundWritesLedgerTest`, `BalanceServiceTest`'s `test_14_day_hold_pending_to_available_transition_via_setTestNow`). No destructive UI flows exist — the widget is read-only — so manual smoke is optional. **Skip the manual-QA section that Layer 10 plans had.**

### Task 15: Commit + push

- [ ] **Step 1:** In `~/projects/alqove-api`, stage `app config contracts database tests docs` and commit with `feat(ledger): seller_ledger foundation + balance read API`.

- [ ] **Step 2:** In `~/projects/alqove-web`, stage `packages web contracts` and commit with `feat(ledger): seller balance widget on /seller dashboard`.

- [ ] **Step 3:** Push both. Watch GH Actions on each — both should be green inside 3 minutes (no new CI env vars needed; the ledger is purely internal).

---

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

- **`Payout` model + biweekly cron + `StripeService::createTransfer` wiring.** Plan 2. The `payout_id` column shipped in Plan 1 is unused until Plan 2 backfills the FK constraint and starts populating it via `PayoutService::scheduleForCycle`.
- **`LedgerWriter::recordPayoutSettled(Payout)` method.** Plan 2 adds this — Plan 1 ships the enum stub (`LedgerEntryType::PayoutSettled`) but no writer.
- **Admin manual-adjustment endpoint + `ManualAdjustment` model.** Plan 3 — `POST /v1/admin/stores/{store}/ledger-adjustments`. Plan 1 ships the enum stubs (`LedgerEntryType::AdjustmentCredit | AdjustmentDebit`) but no writer.
- **`/seller/statements` page + CSV export.** Plan 3.
- **`/admin/financials/balances` + `/admin/financials/payouts` admin pages.** Plan 3.
- **Connect-account health banner** (the seller-facing "your Stripe account is restricted" surface). Plan 3.
- **Late-refund vs within-hold-refund branching** in `ReturnRefundIssuer`. The spec's "after-hold refund → debit + reverseTransfer; within-hold → debit only" distinction is **deferred to Plan 3**, which will audit all `StripeService::reverseTransfer` call sites and add the conditional. Plan 1's audit revealed that `ReturnRefundIssuer` does **not** currently call `reverseTransfer` at all — so there's no temporary duplication to clean up; just a future feature to gate behind ledger state.
- **Per-store custom payout cadence.** Spec out-of-scope; one marketplace-wide cadence in v1.
- **Multi-currency.** No `currency` column on `seller_ledger`. Deliberate KISS choice; future-migration when justified.
- **`pre_transit` / `failure` ledger entries.** Carrier failures don't write ledger rows today. If a label is voided post-purchase by EasyPost (refund flow), Plan 1 has no automated handler — `adjustment_credit` via the Plan-3 admin endpoint is the manual recovery path.
- **Migrating the existing `next_payout` KPI tile** on `/seller` to read from the ledger. Plan 1 leaves the tile unchanged (it uses its own placeholder estimator); the new widget is additive. Plan 2 retires the placeholder once real Payout records exist.
- **Negative-balance UX.** If `available_cents` is negative (rare — only when refunds outpace earnings in the same window post-payout), the widget displays "Available: −$X.YZ" verbatim. No "in arrears" copy or admin alert; Plan 3's admin balances page surfaces these for follow-up.
- **`PayoutSchedule::nextCycleDate` DST.** Helper works in UTC throughout; the seller-visible date is rendered with `timeZone: 'UTC'` in the widget. If sellers ever ask for "next payout in *my* timezone", that's a per-user display layer — keep the underlying schedule in UTC.
- **Idempotency on re-delivery.** `OrderFulfillmentService::markDelivered` already short-circuits via the `status === Delivered` check; if a future "redeliver" path bypasses that check, it must also dedupe the ledger row (probably via a `firstOrCreate` on `(source_type, source_id, entry_type)`). Out of Plan 1 scope.
