## Context

See proposal.md - Why. This replaces the never-merged `add-lunch-payment-status` (a live `is_paid` flag keyed by calendar month, joined into `GET /admin/lunch-orders/report`). That design is fully reverted; nothing from it carries over.

Relevant current state (`packages/w3suga/lunch-ordering`):
- `LunchOrderController::ordersReport()` computes, per employee, `number_orders` and `total_amount` (sum of snapshotted `LunchOrder.price`) over an admin-supplied `from`/`to` range with optional `company_id`/`department_id`/`q` filters - this is a live, read-only view, never persisted.
- Invoices are scoped by `company_id` only (no `department_id`) - a narrower scope than the live report.
- No existing table represents a "billing run" or "invoice" concept.
- `LunchOrder::filterByCompanies()` and the `whereHas('account.positions', ...)`/`whereHas('account', ...)` filters in `ordersReport()` are the established pattern for scoping by company/department.

## Goals / Non-Goals

**Goals:**
- Give leaders a durable, frozen, per-employee billing record for a chosen date range that they can post/share and check off against.
- Prevent the same orders from being silently double-billed across two invoices for the same population.

**Non-Goals:**
- Editing an already-*paid* employee line's amount - a refresh (Decision 7) only ever touches unpaid lines; correcting a paid line requires unmarking it paid first, or delete + regenerate.
- Any UI/export of the invoice itself (posting to a group chat) - this change only provides the data.
- Partial payment amounts - paid state is boolean per employee per invoice, matching the `add-lunch-item-price` report's existing amount fields (which are also not partial-payment-aware).
- Folding `LunchSetting.fine_amount` (unused-order penalty) into the invoice total - same non-goal as the live report.

## Decisions

**1. Two tables: `lunch_payment_invoices` (header) + `lunch_payment_invoice_items` (one row per employee).**
An invoice is a batch with its own identity (date range, scope, who generated it, when) plus a variable-length list of employee lines, each independently markable as paid. This mirrors how the rest of the package models one-to-many admin actions (e.g. a `LunchBookingMenu` and its `LunchOrder` rows), and keeps "frozen amount" and "paid state" as plain columns on the item row rather than a separate table to keep in sync.

**2. Snapshot is computed once, at generation time, from the same query shape as `ordersReport()` (minus its `number_orders`/`total` bookkeeping fields, which don't matter for billing) - not derived by referencing `ordersReport()` live at read time.**
Storing the frozen `total_amount` (and `number_orders`, kept for context on the invoice) directly on `lunch_payment_invoice_items` is what makes "frozen" true: reading the invoice later never re-runs the orders query. Alternative considered: store just the `from`/`to`/scope on the invoice and recompute lazily on first read, caching the result - rejected because "first read" timing is unpredictable (could happen minutes or weeks after generation, capturing whatever orders exist *then*, not at the moment the admin clicked generate).

**3. Overlap check treats `company_id = NULL` ("all companies") as containing every other scope, not an exact match.**
**Revised** after backend review caught a double-billing hole in the original exact-match design: an "all companies" invoice and a `company_id = 5` invoice for the same range did not conflict (different tuples), yet company 5's employees appeared - and got billed - on both. The corrected rule (`LunchPaymentInvoice::scopeOverlapping()`) is a containment check, not an equality check: `company_id = NULL` (on either side) means "all companies" and conflicts with everything; two invoices with different, specific `company_id`s never conflict. There is no `department_id` scope - invoices are company-level only.

**3a. Generation is serialized behind a single MySQL advisory lock (`GET_LOCK('lunch_payment_invoice_generation', 10)`), not row-level locking on the overlap query.**
The overlap predicate spans two columns with inequality comparisons (`from <= :to AND to >= :from`) plus the scope-containment `OR` logic above - not a shape MySQL's gap-locking reliably covers for a check-then-insert race, and there's no way to express "no overlapping range" as a UNIQUE constraint. Since generating an invoice is a rare, human-paced admin action (at most a few times a month), serializing all generation requests behind one global lock is simple, correct, and has no meaningful throughput cost. Alternative considered: a `Cache::lock()` keyed by resolved company/department - rejected because the same hierarchy problem (decision 3) would have to be re-solved as a lock-key derivation instead of a query, for no real benefit at this volume.

**4. `to` must be `<= today`; no equivalent floor on `from`.**
Confirmed with the user: generating an invoice for a not-yet-elapsed period risks freezing incomplete data that will immediately be wrong as more orders come in before the period actually ends. `from` has no special validation beyond `from <= to` - an admin can retroactively invoice an old, never-billed period.

**5. Deletion is a hard delete (with cascading item rows), allowed unconditionally, at any time.**
Confirmed with the user: no restriction based on whether items are marked paid. This is a deliberate simplicity choice over an audit trail; see Risks for the trade-off.

**6. Reuse existing permissions: `lunch_orders_index` to view invoices, `lunch_orders_update` to generate, mark-paid, delete, and refresh.**
Matches the established pattern in this package (`markUnused()`, and the reverted `add-lunch-payment-status`) of gating admin-only mutations on report-adjacent data with `lunch_orders_update` rather than introducing a new permission.

**7. Added after initial delivery: `PUT /admin/lunch-payment-invoices/{id}/refresh`, always allowed; it deletes only unpaid items and re-snapshots those, leaving paid items untouched.**
Frontend flagged that "frozen forever" (Decision 2) is too rigid for the common case of correcting an invoice generated slightly too early (a late order still needs to be added) - `delete()` + regenerate loses the invoice's id and, more importantly, would force a fresh round of re-marking payments already collected under the old id. `refresh()` reuses the same id/from/to/scope and calls the same snapshot logic as generation (via a shared `insertSnapshotItems()` helper), which already excludes any account with a paid item on this invoice. There is no reject-if-any-paid gate: an employee once marked paid on this invoice keeps that frozen line regardless of what refresh does to everyone else, so a partial payment round in progress doesn't block correcting the rest of the batch. Route path/verb matches the existing `PUT /{id}/leave-requests/refresh` convention already used elsewhere in this codebase (`LeaveGroupController::refreshLeaveRequests`).

## Risks / Trade-offs

- **[Global generation lock serializes an admin-only action]** Two admins generating unrelated invoices (different companies, no real conflict) briefly wait on each other → Mitigation: acceptable given generation is rare and the lock is held only for the duration of one check-then-insert transaction, not the full request lifecycle.
- **[No audit trail on delete]** Deleting an invoice that already had employees marked paid loses that history irretrievably → Mitigation: explicitly accepted by the user as the simpler option; revisit only if payment history disputes become a real problem.
- **[Frozen amounts can look "wrong" next to the live report]** After generating an invoice, `GET /admin/lunch-orders/report` for the same range will keep reflecting any new/edited orders, while the invoice stays frozen → Mitigation: this is the explicit point of freezing (Decision 2); document for admins that the live report and a generated invoice can diverge, and that's expected once orders change after the fact.

## Migration Plan

1. Add `lunch_payment_invoices` (`name`, `from`, `to`, `total_amount_sum`, `number_orders_sum`, `company_id` nullable, `created_by_id`, timestamps).
2. Add `lunch_payment_invoice_items` (`invoice_id` FK cascade-delete, `account_id`, `total_amount`, `number_orders`, `has_unpriced_item`, `is_paid` default false, `paid_by_id` nullable, `paid_at` nullable, timestamps).
3. No backfill - this is a net-new, opt-in action; no prior invoices exist.
4. `down()` drops both tables; no other schema depends on them.
