# Orphan Invoice Compensation Implementation Plan

> **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:** Eliminate permanent empty invoices from abandoned duplicate/generate flows via guarded discard, scheduled prune, quit-guards, and double-submit protection.

**Architecture:** No happy-path changes. Reserve-then-fill stays; four compensating exits added (manual discard, cancel-with-discard, scheduled prune, navigation quit-guard) plus one prevention (double-submit guard). Backend guards re-checked at delete time so UI can stay simple.

**Tech Stack:** Laravel 13, Blade + Alpine, vanilla JS in `form.js`, Pest v4, spatie permissions (admin bypass active; `isAdmin()` returns true under `testing`, so policy-denial paths cannot be Pest-tested — noted per task).

## Global Constraints

- `declare(strict_types=1);` on all PHP files.
- Controllers delegate to services/actions; thin methods.
- `orders.invoice_id` non-nullable + cascade: never delete an invoice with an order, payment, or recipe.
- Invoice-first flow preserved: duplicate/generate still reserve a `Created` invoice up front.
- No migration. No new dependency.
- Gates per task: `./vendor/bin/pint --test`, `./vendor/bin/phpstan analyse --memory-limit=1G`, `php artisan test --filter=<name>`.

---

## File Structure

- Modify `app/Http/Controllers/InvoiceController.php` — add `destroy` (guarded discard). Single choke point for all deletes.
- Create `app/Console/Commands/PruneEmptyInvoices.php` — scheduler-safe prune, `--dry-run` preview.
- Modify `app/Console/Kernel.php` — daily schedule (mirrors `activities:archive`).
- Modify `app/Http/Controllers/OrderController.php` — pass `pendingInvoiceId`/`pendingInvoiceNumber`/`cancelUrl` from `showOrderCreationForm`.
- Modify `resources/views/partials/invoice-action-items.blade.php` — Discard action in `@else` (orderless) branch.
- Modify `resources/views/invoices/show.blade.php` — Discard button for orderless `Created` (same guards as partial).
- Modify `resources/views/orders/create.blade.php` — `__quitGuard` config + cancel link attrs + back-link attr.
- Modify `resources/views/orders/edit.blade.php` — `__quitGuard` config + back-link/cancel attrs.
- Modify `resources/views/orders/partials/step-navigation-buttons.blade.php` — render cancel for create mode too.
- Modify `resources/js/orders/form.js` — dirty snapshot, quit-guard intercepts, `beforeunload`, submit disarm.
- Modify `resources/views/partials/invoice-list.blade.php` + `invoice-actions.blade.php` — `confirmDiscardInvoice` + duplicate double-submit guard.
- Create `tests/Feature/Invoices/DiscardEmptyInvoiceTest.php`, `tests/Feature/Invoices/PruneEmptyInvoicesTest.php`, `tests/Feature/Orders/OrderFormQuitGuardTest.php`.

---

### Task 1: Guarded destroy endpoint

**Files:**
- Modify: `app/Http/Controllers/InvoiceController.php` (insert after `duplicate`, ~line 211)
- Test: `tests/Feature/Invoices/DiscardEmptyInvoiceTest.php`

**Interfaces:**
- Consumes: `Invoice::hasOrder()`, `payments()`, `flowerRecipe()`, `InvoiceStatus::Created`, `InvoicePolicy::delete` (exists).
- Produces: `DELETE /invoices/{invoice}` → `InvoiceController@destroy` (route exists via resource).

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

```php
<?php

declare(strict_types=1);

use App\Enums\InvoiceStatus;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('destroy discards an orderless created invoice', function () {
    $this->actingAs(User::factory()->create());
    $invoice = Invoice::factory()->create(['status' => InvoiceStatus::Created]);

    $this->delete(route('invoices.destroy', $invoice))
        ->assertRedirect(route('invoices.index'));

    expect(Invoice::query()->find($invoice->id))->toBeNull();
});

test('destroy refuses an invoice that already has an order', function () {
    $this->actingAs(User::factory()->create());
    $invoice = Invoice::factory()->create(['status' => InvoiceStatus::Open]);
    Order::factory()->for($invoice, 'invoice')->create();

    $this->delete(route('invoices.destroy', $invoice))->assertRedirect();

    expect(Invoice::query()->find($invoice->id))->not->toBeNull();
});

test('destroy refuses a non-created invoice even when orderless', function () {
    $this->actingAs(User::factory()->create());
    $invoice = Invoice::factory()->create(['status' => InvoiceStatus::Paid]);

    $this->delete(route('invoices.destroy', $invoice))->assertRedirect();

    expect(Invoice::query()->find($invoice->id))->not->toBeNull();
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test --filter=DiscardEmptyInvoice`
Expected: FAIL (405 — no `destroy` method; resource route falls through).

- [ ] **Step 3: Write minimal implementation**

Insert after `duplicate()` in `app/Http/Controllers/InvoiceController.php`:

```php
    /**
     * Discard an empty invoice reservation.
     *
     * Only a Created invoice with no order, payments, or recipe can be
     * discarded. Anything else is refused: invoice deletes cascade to
     * orders, payments, and recipes.
     */
    public function destroy(Invoice $invoice): RedirectResponse
    {
        $this->authorize('delete', $invoice);

        if ($invoice->status !== InvoiceStatus::Created
            || $invoice->hasOrder()
            || $invoice->payments()->exists()
            || $invoice->flowerRecipe()->exists()
        ) {
            return back()->withErrors('Only empty, newly-created invoices can be discarded.');
        }

        $number = $invoice->display_invoice_number;
        $invoice->delete();

        return redirect()->route('invoices.index')
            ->with('success', "Empty invoice {$number} discarded.");
    }
```

`RedirectResponse`, `InvoiceStatus` already imported in this file. NOTE: policy-denial (403) cannot be Pest-tested — `User::isAdmin()` returns true under `testing`, so `Gate::before` grants everything. Do not attempt that test.

- [ ] **Step 4: Run test to verify it passes**

Run: `php artisan test --filter=DiscardEmptyInvoice`
Expected: PASS (3 tests).

- [ ] **Step 5: Quality gates + commit**

```bash
./vendor/bin/pint --test
./vendor/bin/phpstan analyse --memory-limit=1G
git add app/Http/Controllers/InvoiceController.php tests/Feature/Invoices/DiscardEmptyInvoiceTest.php
git commit -m "feat: guarded discard for empty invoice reservations"
```

---

### Task 2: Prune command + schedule

**Files:**
- Create: `app/Console/Commands/PruneEmptyInvoices.php`
- Modify: `app/Console/Kernel.php:16-20` (add daily line next to archive)
- Test: `tests/Feature/Invoices/PruneEmptyInvoicesTest.php`

**Interfaces:**
- Consumes: same guards as Task 1, expressed as query scopes.
- Produces: `invoices:prune-empty` artisan command used by Task 5 cleanup.

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

```php
<?php

declare(strict_types=1);

use App\Enums\InvoiceStatus;
use App\Models\Invoice;
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('prune deletes only stale orderless created invoices', function () {
    $stale = Invoice::factory()->create([
        'status' => InvoiceStatus::Created,
        'created_at' => now()->subDays(2),
    ]);
    $fresh = Invoice::factory()->create(['status' => InvoiceStatus::Created]);
    $ordered = Invoice::factory()->create(['status' => InvoiceStatus::Open]);
    Order::factory()->for($ordered, 'invoice')->create();

    $this->artisan('invoices:prune-empty', ['--hours' => 24])->assertSuccessful();

    expect(Invoice::query()->find($stale->id))->toBeNull()
        ->and(Invoice::query()->find($fresh->id))->not->toBeNull()
        ->and(Invoice::query()->find($ordered->id))->not->toBeNull();
});

test('prune dry-run deletes nothing', function () {
    $stale = Invoice::factory()->create([
        'status' => InvoiceStatus::Created,
        'created_at' => now()->subDays(2),
    ]);

    $this->artisan('invoices:prune-empty', ['--hours' => 24, '--dry-run' => true])
        ->assertSuccessful();

    expect(Invoice::query()->find($stale->id))->not->toBeNull();
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test --filter=PruneEmptyInvoices`
Expected: FAIL (command not found).

- [ ] **Step 3: Write minimal implementation**

`app/Console/Commands/PruneEmptyInvoices.php` (mirrors `ArchiveOldActivities` style; NO confirm prompt — must be scheduler-safe):

```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Enums\InvoiceStatus;
use App\Models\Invoice;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

class PruneEmptyInvoices extends Command
{
    protected $signature = 'invoices:prune-empty '
        .'{--hours=24 : Only discard reservations older than this many hours} '
        .'{--dry-run : List candidates without deleting}';

    protected $description = 'Delete abandoned empty invoices (created, orderless, paymentless, recipeless)';

    public function handle(): int
    {
        $hours = max(0, (int) $this->option('hours'));
        $cutoff = now()->subHours($hours);

        $ids = Invoice::query()
            ->where('status', InvoiceStatus::Created->value)
            ->where('created_at', '<', $cutoff)
            ->whereDoesntHave('order')
            ->whereDoesntHave('payments')
            ->whereDoesntHave('flowerRecipe')
            ->pluck('id')
            ->all();

        if ($ids === []) {
            $this->info('No abandoned empty invoices found.');

            return Command::SUCCESS;
        }

        if ((bool) $this->option('dry-run')) {
            $this->info('Would discard '.count($ids).' invoice(s): '.implode(', ', $ids));

            return Command::SUCCESS;
        }

        $deleted = Invoice::query()->whereIn('id', $ids)->delete();
        Log::info('Pruned abandoned empty invoices', ['count' => $deleted, 'ids' => $ids]);
        $this->info("Discarded {$deleted} abandoned empty invoice(s).");

        return Command::SUCCESS;
    }
}
```

`app/Console/Kernel.php` schedule(), next to archive line:

```php
$schedule->command('invoices:prune-empty')->daily();
```

- [ ] **Step 4: Run test to verify it passes**

Run: `php artisan test --filter=PruneEmptyInvoices`
Expected: PASS (2 tests).

- [ ] **Step 5: Quality gates + commit**

```bash
./vendor/bin/pint --test
./vendor/bin/phpstan analyse --memory-limit=1G
git add app/Console/Commands/PruneEmptyInvoices.php app/Console/Kernel.php tests/Feature/Invoices/PruneEmptyInvoicesTest.php
git commit -m "feat: prune command for abandoned empty invoices"
```

---

### Task 3: Discard UI + create-mode cancel + duplicate double-submit guard

**Files:**
- Modify: `resources/views/partials/invoice-action-items.blade.php:109-115` (`@else` branch)
- Modify: `resources/views/invoices/show.blade.php` (~line 225 duplicate form area)
- Modify: `app/Http/Controllers/OrderController.php:294-321` (`showOrderCreationForm` return array)
- Modify: `resources/views/orders/create.blade.php` (cancel link)
- Modify: `resources/views/orders/partials/step-navigation-buttons.blade.php:72-79` (cancel render condition)
- Modify: `resources/views/partials/invoice-list.blade.php:332-356` + `resources/views/partials/invoice-actions.blade.php:53-71` (JS)

**Interfaces:**
- Consumes: Task 1 `destroy` endpoint.
- Produces: `confirmDiscardInvoice` JS helper; `pendingInvoiceId` view var (Task 4 cancel flow needs nothing more).

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

```php
<?php

declare(strict_types=1);

use App\Enums\InvoiceStatus;
use App\Models\Invoice;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Permission;

uses(RefreshDatabase::class);

test('create form exposes cancel and discard target for a fresh invoice', function () {
    Permission::findOrCreate('view invoices');
    Permission::findOrCreate('create invoices');
    Permission::findOrCreate('create orders');
    Permission::findOrCreate('view orders');
    $user = User::factory()->create();
    $user->givePermissionTo(['view invoices', 'create invoices', 'create orders', 'view orders']);
    $invoice = Invoice::factory()->create(['status' => InvoiceStatus::Created]);

    $this->actingAs($user)
        ->get(route('orders.create', ['secure_id' => encrypt($invoice->id)]))
        ->assertStatus(200)
        ->assertSee('data-quit-guard', false)
        ->assertSee('data-discard-url', false);
});
```

Save as `tests/Feature/Orders/OrderFormQuitGuardTest.php` (Task 4 adds more tests to this file; this step creates it).

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test --filter=OrderFormQuitGuard`
Expected: FAIL (no `data-quit-guard` in response).

- [ ] **Step 3: Write minimal implementation**

3a. `OrderController::showOrderCreationForm` — add to view array:

```php
'pendingInvoiceId' => $invoice->id,
'pendingInvoiceNumber' => $invoice->display_invoice_number,
'cancelUrl' => route('invoices.index'),
```

3b. `step-navigation-buttons.blade.php` — change `@if(isset($cancelUrl))` block: drop `x-show="isEdit"` so create mode renders it too; add `data-quit-guard` attr:

```blade
@if(isset($cancelUrl))
<a href="{{ $cancelUrl }}"
   data-quit-guard
   :class="{ 'pointer-events-none opacity-50': isSubmitting }"
   class="...same classes...">
    Cancel
</a>
@endif
```

3c. `orders/create.blade.php` — inside existing `<script>`, after `__prefillData` block:

```blade
window.__quitGuard = @json([
    'mode' => 'create',
    'invoiceNumber' => $pendingInvoiceNumber,
    'discardUrl' => route('invoices.destroy', $pendingInvoiceId),
    'cancelUrl' => $cancelUrl,
]);
```

Back link (`url()->previous()`, ~line 43): add `data-quit-guard`.

3d. Discard action, `invoice-action-items.blade.php` `@else` branch after Add Order `<li>` (blade-side check stays cheap — status only; payments/recipe enforced by endpoint):

```blade
@if ($invoice->status === \App\Enums\InvoiceStatus::Created)
    @can('delete', $invoice)
        <li>
            <button type="button"
                    onclick="confirmDiscardInvoice(event, {{ $invoice->id }}, '{{ $invoice->display_invoice_number }}')"
                    class="flex items-center px-4 py-2.5 text-sm text-rose-600 dark:text-rose-400 hover:bg-zinc-50 dark:hover:bg-zinc-900 transition-colors w-full text-left font-medium cursor-pointer">
                <svg class="w-4 h-4 mr-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg> Discard Empty Invoice
            </button>
        </li>
    @endcan
@endif
```

Same block in `invoices/show.blade.php` next to duplicate form (guard `@if($invoice->order === null && $invoice->status === \App\Enums\InvoiceStatus::Created)` + `@can('delete', $invoice)`), as a DELETE form styled like sibling buttons.

3e. JS helper — append next to `confirmDuplicateInvoice` in BOTH `invoice-list.blade.php` and `invoice-actions.blade.php`:

```js
window.confirmDiscardInvoice = function(event, invoiceId, invoiceNumber) {
    event.preventDefault();
    event.stopPropagation();
    window.showSwal({
        title: 'Discard empty invoice?',
        text: 'Invoice ' + invoiceNumber + ' has no order. Discarding deletes it permanently. This cannot be undone.',
        icon: 'warning',
        showCancelButton: true,
        confirmButtonText: 'Discard',
        cancelButtonText: 'Keep',
        confirmButtonColor: '#e11d48',
        reverseButtons: true
    }).then((result) => {
        if (result.isConfirmed) {
            const form = document.createElement('form');
            form.method = 'POST';
            form.action = `/invoices/${invoiceId}`;
            form.innerHTML = '<input type="hidden" name="_token" value="{{ csrf_token() }}"><input type="hidden" name="_method" value="DELETE">';
            document.body.appendChild(form);
            form.submit();
        }
    });
};
```

3f. Duplicate double-submit guard — in BOTH files, first lines of `confirmDuplicateInvoice`:

```js
if (event.currentTarget && event.currentTarget.dataset.submitted === '1') return;
if (event.currentTarget) event.currentTarget.dataset.submitted = '1';
```

Generate buttons (`invoices/index`, `invoices/show`, `valentines/index`) already disable while in flight — verified, no change.

- [ ] **Step 4: Run test to verify it passes**

Run: `php artisan test --filter=OrderFormQuitGuard`
Expected: PASS. Manual: duplicate → create form shows Cancel; Discard on orderless row deletes; Discard hidden on ordered rows.

- [ ] **Step 5: Quality gates + commit**

```bash
./vendor/bin/pint --test
php artisan test --filter="DiscardEmptyInvoice|OrderFormQuitGuard"
git add app/Http/Controllers/OrderController.php resources/views/partials/invoice-action-items.blade.php resources/views/invoices/show.blade.php resources/views/orders/create.blade.php resources/views/orders/partials/step-navigation-buttons.blade.php resources/views/partials/invoice-list.blade.php resources/views/partials/invoice-actions.blade.php tests/Feature/Orders/OrderFormQuitGuardTest.php
git commit -m "feat: discard UI, create-mode cancel, duplicate double-submit guard"
```

---

### Task 4: Quit-guard JS (dirty-check + consequence confirm + beforeunload)

**Files:**
- Modify: `resources/js/orders/form.js` (`init()` ~line 65, `submitForm()` ~line 1901, append methods near `buildSubmitData` ~line 2042)
- Modify: `resources/views/orders/edit.blade.php` (config + attrs)
- Test: append to `tests/Feature/Orders/OrderFormQuitGuardTest.php`; manual browser checklist (no JS runner in repo)

**Interfaces:**
- Consumes: `window.__quitGuard` from Task 3 (create) + new edit config below; `window.showSwal` promise API; `CSRF_TOKEN` global.
- Produces: disarmed submit (no `beforeunload` on successful save).

- [ ] **Step 1: Write the failing test (server-rendered wiring)**

Append to `tests/Feature/Orders/OrderFormQuitGuardTest.php`:

```php
test('edit form exposes quit-guard config', function () {
    $user = User::factory()->create();
    $invoice = Invoice::factory()->create(['status' => InvoiceStatus::Open]);
    $order = Order::factory()->for($invoice, 'invoice')->create();

    $this->actingAs($user)
        ->get(route('orders.edit', $order))
        ->assertStatus(200)
        ->assertSee('data-quit-guard', false)
        ->assertSee('__quitGuard', false);
});
```

Add imports: `App\Models\Order`.

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test --filter=OrderFormQuitGuard`
Expected: FAIL (edit blade has no `__quitGuard`).

- [ ] **Step 3: Write minimal implementation**

3a. `orders/edit.blade.php` script block — after `__storeAddress` line:

```blade
window.__quitGuard = @json([
    'mode' => 'edit',
    'invoiceNumber' => $order->invoice->number ?? '',
    'discardUrl' => null,
    'cancelUrl' => route('invoices.index'),
]);
```

Back link (~line 39) + existing cancel link: add `data-quit-guard` attr.

3b. `form.js` — in `init()`, after `this.completeInitialization();` line, add:

```js
this.__quitDisarmed = false;
this.__registerQuitGuard();
```

At end of `completeInitialization()` (covers async defaults like wired payment date), add:

```js
this.__pristineSnapshot = JSON.stringify(this.formData);
```

3c. New methods (place after `buildSubmitData`):

```js
__formDirty() {
    if (!this.__pristineSnapshot) return false;
    try {
        return JSON.stringify(this.formData) !== this.__pristineSnapshot;
    } catch (e) {
        return true;
    }
},
__registerQuitGuard() {
    const cfg = window.__quitGuard || null;
    if (!cfg) return;
    const links = document.querySelectorAll('[data-quit-guard]');
    links.forEach((link) => {
        link.addEventListener('click', (e) => this.__confirmQuit(e, cfg, link.getAttribute('href')));
    });
    window.addEventListener('beforeunload', (e) => {
        if (this.__quitDisarmed || !this.__formDirty()) return;
        e.preventDefault();
    });
},
async __confirmQuit(e, cfg, href) {
    if (this.__quitDisarmed) return; // submit in flight / succeeded
    const dirty = this.__formDirty();
    if (cfg.mode === 'edit' && !dirty) return; // pristine edit: plain navigate
    e.preventDefault();
    const isCreate = cfg.mode === 'create';
    const text = isCreate
        ? `Unsaved entries will be lost. Empty invoice ${cfg.invoiceNumber} stays behind — discard it from the invoice list or it is auto-removed within 24 hours. Quit anyway?`
        : 'Unsaved changes will be lost. Quit anyway?';
    const result = await window.showSwal({
        title: 'Quit without saving?',
        text: text,
        icon: 'warning',
        showCancelButton: true,
        confirmButtonText: isCreate ? 'Quit (keep empty invoice)' : 'Quit without saving',
        cancelButtonText: 'Stay',
        reverseButtons: true
    });
    if (result && result.isConfirmed && href) window.location.href = href;
},
```

Create-cancel uses the plain Cancel link (Task 3) → this confirm explains consequence → user then uses Discard action or prune handles it. Deliberate: cancel never auto-deletes (destructive surprise); messaging states exactly what happens, per requirement.

3d. Submit disarm — in `submitForm()`, immediately before the `try {` that wraps the axios call (~line 1954), add `this.__quitDisarmed = true;`; in the `catch` block (~line 1984) first line add `this.__quitDisarmed = false;`. Validation-failure early returns happen before this point, so guard stays armed when save never fires.

3e. Rebuild frontend: `npm run build`.

- [ ] **Step 4: Run tests + manual checklist**

Run: `php artisan test --filter=OrderFormQuitGuard`
Expected: PASS.
Manual (no JS runner): create form → type → back link → confirm shows invoice number + 24h note; Stay keeps data; Quit navigates. Pristine edit → back navigates silently. Dirty edit → confirm. Submit → no `beforeunload` prompt. Refresh mid-form with edits → browser prompt.

- [ ] **Step 5: Quality gates + commit**

```bash
./vendor/bin/pint --test
npm run build
git add resources/js/orders/form.js resources/views/orders/edit.blade.php tests/Feature/Orders/OrderFormQuitGuardTest.php public/build/manifest.json public/build/assets -f 2>/dev/null; git add resources/js/orders/form.js resources/views/orders/edit.blade.php tests/Feature/Orders/OrderFormQuitGuardTest.php
git commit -m "feat: quit-guard with consequence messaging on order forms"
```

NOTE: check `git status` for actual built asset paths before committing; only commit what the repo already tracks (if `public/build` is gitignored, skip assets).

---

### Task 5: One-time cleanup + full verification

**Files:** none (operations + verification).

- [ ] **Step 1: Dry-run prune locally**

Run: `php artisan invoices:prune-empty --dry-run --hours=0`
Expected: lists current orphan ids, deletes nothing. Sanity-check each id is truly orderless in DB before proceeding.

- [ ] **Step 2: Full gates**

```bash
./vendor/bin/pint --test
./vendor/bin/phpstan analyse --memory-limit=1G
php artisan test
npm run build
```

Expected: all green. `DuplicateInvoiceDatesTest` must still pass (reserve step untouched).

- [ ] **Step 3: Deploy + prod checks**

After deploy: run dry-run on prod, then real prune (off-hours; `--hours=0` also catches in-flight forms — prefer default 24h unless reviewed). Verify scheduler cron runs `schedule:run` (required for daily prune; UI discard works regardless). Confirm cron with host.

- [ ] **Step 4: Commit spec + plan docs**

```bash
git add docs/superpowers/specs/2026-09-09-orphan-invoice-compensation-design.md docs/superpowers/plans/2026-09-09-orphan-invoice-compensation.md
git commit -m "docs: orphan invoice compensation spec and plan"
```

---

## Self-Review

1. **Spec coverage:** guarded destroy ✓ (T1), prune ✓ (T2), discard UI ✓ (T3), double-submit ✓ (T3f), quit-guard + consequence messaging create/edit ✓ (T4), one-time cleanup ✓ (T5), invoice-first preserved ✓ (no store-path changes), both flows ✓ (all pieces invoice-level, not duplicate-specific).
2. **Placeholder scan:** all steps carry exact paths, code, commands, expected outputs. Blade line refs verified by read. `public/build` commit guarded by gitignore note.
3. **Type consistency:** `destroy` returns `RedirectResponse` (imported); `InvoiceStatus` enum compare `!==` (backed enum, strict OK); prune uses `->value`; JS `cfg` shape identical in create/edit configs; `__quitGuard` key naming consistent across blades/tests.
