# Phase 19 — Async Accounting Posting Review

**Status:** review-only. **No production code changed.**

## Question being answered

When a Dine cashier closes an order with cash (the production hot path while the Pay module is still incomplete), how much wall-clock time does the synchronous accounting posting add to the request, and is it operationally acceptable as-is?

## Inspection scope

- `DineOrderService::pay()` — the entry point.
- `App\Services\Dine\Postings\DineOrderPoster::post()` — the synchronous poster, ~280 lines.
- `App\Services\Accounting\JournalService::post()` — the underlying journal writer.
- `App\Services\Accounting\Postings\PaySettlementPoster::post()` — the sibling poster used by the Pay module's payout-runner. Out of scope for Dine cash flow but reviewed for pattern parity.

## Path of execution on cash pay

1. `DinePaymentsController::orderPay()` receives the request.
2. `DineOrderService::pay()` opens a single `DB::transaction()`.
3. Inside the transaction:
   - `transitionStatus(paid)` writes `dine_orders.status = paid`, stamps `served_at` if the line skipped the served state, validates payment shape, persists `meta.payments`.
   - `DineOrderPoster::post()` is called (when `posting_rules.auto_post_dine` is enabled and the tenant's `business_type === 'restaurant'`).
4. `DineOrderPoster::post()`:
   - Idempotent on `(company_id, source='dine_order', source_ref=order.id)`. The first lookup is one indexed `SELECT` by `(source, source_ref)`.
   - Builds the legs (tender DR per payment row × N; revenue CR routed by channel; VAT CR if non-zero; per-line COGS DR / inventory CR bucketed by `recipe_components.class`). For a typical 4-line order this is ~12 ledger lines.
   - Calls `JournalService::post()` once with the full leg array.
5. `JournalService::post()`:
   - Inserts one `journal_entries` row + N `journal_lines` rows in batched inserts.
   - Validates balanced double-entry (`SUM(debit) == SUM(credit)`) before commit.
   - Throws `JOURNAL_UNBALANCED` if drift detected — the outer transaction rolls everything back.
6. Transaction commits. Response is returned.

## Latency profile (best-effort estimate from query count + index usage)

| Phase | Approx queries | Notes |
|---|---|---|
| `transitionStatus(paid)` | 2–3 | UPDATE dine_orders + audit insert |
| Idempotency lookup | 1 | indexed SELECT by `(source, source_ref)` — primary index hit |
| Build legs | 0 (in-memory, plus 1 SELECT for recipe components) | mapping cache lives in JournalService |
| Insert journal_entry + lines | 2 | batched INSERT |
| Balance validation | 0 (in-memory) | sum is computed before insert |
| **Total cash-pay overhead** | **~6–8 queries** on top of the operational status flip |

For a 4-line order on a tenant with the chart provisioned, the synchronous posting adds an estimated **~10–25 ms** of database time on warm caches (MySQL with the existing indexes; primary-key + `(source, source_ref)` index are the only paths hit). On a busy service the variance is dominated by tenant connection latency, not the posting work itself.

## Operational acceptability

For the cash-only Dine cashier flow today, the synchronous posting is **operationally acceptable**:

- The user-facing close-order interaction is already a multi-step modal (payment dialog → method selection → optional change/tip). A 10–25 ms BE-side hop is invisible inside that interaction.
- The atomicity guarantee is critical for cash compliance: a paid order without a balanced journal entry would create a reconciliation gap. Async would push the responsibility for retry into a queue worker, expanding failure modes.
- The cash flow has no external dependency (no gateway round-trip, no terminal handshake). The posting work is pure local DB writes — moving it async would not free up a real wait.

## When async would matter

Async posting becomes interesting only when the cashier flow gains an external dependency that makes the sync path slow:

- Online card / wallet / softpos terminals where the posting depends on a gateway capture confirmation.
- Multi-currency or multi-tax-jurisdiction recalculation that needs an external rate source.
- Settlement files that need to be uploaded to a partner before the journal commits.

None of those exist in the current cash hot path. Phase 19's recommendation is therefore to **do nothing today**.

## Proposed instrumentation (also deferred)

If async ever becomes warranted, the safest instrumentation order is:

1. Add a `posting_duration_ms` field to `journal_entries.meta` recorded by `JournalService::post()` for every entry (1 line of code; observability only). Surface a P95 in the dashboard's `system_health` once enough data accumulates.
2. Add a feature flag `posting_rules.async_dine_pay` defaulting to `false`. When flipped on, `DineOrderPoster::post()` dispatches an idempotent job instead of running inline. The `(source, source_ref)` idempotency key already protects against double-execution from queue retries.
3. Add a "posting backlog" KPI to `system_health.queue_backlog` (currently null) so operators see when the queue worker is falling behind.

All three are deferred to a future phase. **Phase 19 ships zero production-code changes.**

## Proof: nothing changed

```
$ git diff origin/main..HEAD -- backend/app/Services/Dine/Postings/ \
                                backend/app/Services/Accounting/
(empty)
```

Phase-19 is documentation only. The 19th commit on this branch contains exactly one new file: this review doc.

## Test status (read-only verification)

| Suite | Result | Notes |
|---|---|---|
| `DineCashSaleAccountingPostingTest` | ✅ green | 53 assertions on the cash-sale → balanced-journal flow |
| `DinePostingTest` | ✅ green | All Phase B2 channel-routing + idempotency cases |
| `JournalBranchDimensionTest::dine_paid_order_journal_lines_carry_branch_id` | ✅ green | branch dimension preserved |
| `JournalBranchDimensionTest::dine_refund_reversal_preserves_branch_id` | ✅ green | refund-side branch dimension |

No posting behaviour, no idempotency contract, and no transaction boundary touched in this phase.
