# Design: Orphan Invoice Compensation (Duplicate + Generate Flows)

Date: 2026-09-09 | Status: approved for implementation planning | Scope: both flows

## Problem

Duplicating or generating an invoice reserves a `Created` invoice row before the
order form is saved (`DuplicateInvoiceAction`, `InvoiceService::generateInvoice`).
Abandoning the `orders.create` form (close tab, back button, no save) leaves a
permanent orderless `Created` invoice: list pollution (`Created` sorts first),
burned sequence numbers (gaps), stale `order_prefill:{id}` cache (self-expires).

## Constraint (hard)

Invoice-first flow is schema law: `orders.invoice_id` non-nullable + cascade.
An order cannot exist without its invoice. Creation must stay eager; fix = add
compensating exits, not defer creation.

## Changes

1. **Guarded destroy** — new `InvoiceController::destroy`. Authorize `delete`
   (`delete invoices` permission, `InvoicePolicy::delete` exists). Abort 422
   unless ALL hold: `status === Created`, `!hasOrder()`,
   `payments()->doesntExist()`, `flowerRecipe()->doesntExist()`. Route already
   exists via `Route::resource('invoices', ...)`. Cascade can never fire here.
2. **Discard UI** — row action on orderless `Created` invoices (invoice list
   partials) + cancel button on `orders.create`. Cancel quits AND discards the
   pending empty invoice via the guarded destroy (server re-checks guards; if
   an order landed meanwhile it refuses and stays on the form). Reuse
   SweetAlert confirm.
3. **Prune command** — `invoices:prune-empty {--hours=24} {--dry-run=}`: deletes `Created` +
   orderless + paymentless + recipeless + older than threshold, per-row
   transaction, logs count + ids. Daily schedule in `Console/Kernel`
   (same pattern as `activities:archive`). Covers close-tab abandonment.
4. **Double-submit guard** — disable duplicate/generate buttons on first click
   (`invoice-list`, `invoice-actions` partials). Double-click = 2 rows today.
6. **Quit-guard on order forms** — `orders.create` + `orders.edit`: intercept
   in-app navigation away (back link, step nav, cancel) with dirty-check; if
   form dirty, SweetAlert confirm stating the consequence. Create wording:
   unsaved entries lost AND empty invoice discarded permanently (cancel
   submits the discard). Back-link quit keeps the warn-only message: invoice
   stays behind, auto-removed within 24h or discardable from invoice list.
   Edit wording: unsaved changes lost (no invoice side effect — order already
   exists). `beforeunload` as backstop for tab close/reload (browser-native
   text limits apply).
5. **One-time cleanup** — dry-run preview then real run against existing orphans pre-deploy.

## Races / errors

- Destroy after order saved (two tabs) → 422 → redirect to invoice show.
- Prune vs open form → 24h threshold exceeds any realistic session; guards
  re-checked at delete time.
- Prod cron not running → prune dormant; UI discard still works. Verify cron
  post-deploy.

## Tests (Pest)

- Destroy 422 + row survives when order / payment / recipe present.
- Destroy removes orderless `Created`.
- Prune deletes only stale orderless; keeps fresh, ordered, paid rows.
- Rapid double duplicate POST → single row (after guard).
- Existing `DuplicateInvoiceDatesTest` unchanged (reserve step untouched).

## Gates

`pint`, `phpstan analyse` (L5), `php artisan test`. No migration. No new
dependencies. No changes to happy path (reserve → save → `Open`).

## Skipped (deliberate)

- `expires_at` column: migration + query churn, marginal gain at this volume.
- Hiding `Created` rows: masks the problem, confuses staff.
- Deferred creation at save: violates invoice-first law.
