# POS P3 — Centralized Payments, Invoice Collection, Reversals & Refunds

**Branch:** `techtitans/integration` · **Status:** P3A/P3B/P3C complete (card gateway refund documented unavailable)
**Started:** 2026-11-13 · Backend: Laravel 13 · Frontend: React 19 + Vite

Built on the committed P1 (premium POS UI) and P2 (retail checkout). P3 turns the
existing `/pos` workspace into the **single centralized payment system**: discover
outstanding invoices across customers, collect single/multi-invoice/partial
payments through the **existing per-order ledger**, view central payment history,
and perform authorized **offline reversals**. **No second ledger; no standalone
payment page.** Any feature that cannot be done safely is left clearly disabled.

---

## PHASE 1 — ARCHITECTURE AUDIT (complete)

Method: six read-only subagents mapped the ledger/allocation model, invoices &
outstanding discovery, reversals/gateway refunds, IPTV entitlement, the frontend
payment surfaces, and permissions/scoping. Findings are file:line-precise in the
session transcript; summarised here.

### 1.1 Headline

The money-of-record is the **per-order balance** derived by `PaymentLedger`
(`amountPaid = SUM(captured_amount_minor) − SUM(refunded_amount_minor)` over a
single `order_id`; `outstanding = grand_total − paid`). Everything P3 needs is a
thin layer over this: a **grouping label** for multi-invoice collection, a
**compensating row** for reversal, and **read/query** surfaces for discovery and
history. The authoritative per-order sum never changes.

### 1.2 Reused services (no reinvention)

| Service | Role in P3 |
|---|---|
| `app/Payments/PaymentLedger` | per-order paid/outstanding/`payment_status` — **unchanged** |
| `app/Payments/OfflinePaymentService::record` | the write primitive for every tender (locks order, overpayment guard, idempotency) — reused per collection leg |
| `app/Orders/OrderAccess` (`scope`/`constrain`/`canAccess`) | tenant + location scoping for discovery, collection, reversal, history |
| `app/Restaurant/RequestIdempotency` | atomic + replay-safe multi-invoice collection |
| `app/Customers/CustomerFinancialService` | the proven paid-subquery + `GREATEST(0, grand−paid)` outstanding formula to clone for discovery |
| `app/Invoices/InvoiceService` + `app/Documents/ReceiptService` + existing invoice/receipt routes | invoice + receipt PDF (branding, tax, immutable frozen bytes) — **no new PDF code** |
| `app/Audit/*` (`AuditRecorder`, `AuditEvent`, `Actor`, `ActionKey`) + `PaymentEvent` | audit + append-only payment timeline |
| `app/Auth/ManagerApproval/*` + `EnsureManagerApproval` | optional over-limit reversal approval |
| `app/Iptv/IptvSubscriptionService` | IPTV `billing_status` re-derivation; grant stays decoupled from payment |

### 1.3 Feature matrix (existing / partial / missing)

| Capability | Status | Plan (reuse-first) | Blocker |
|---|---|---|---|
| Per-order ledger, paid/outstanding | **Existing** | `PaymentLedger` unchanged | — |
| Single-invoice collection + **partial** payment | **Existing** | `OfflinePaymentService::record` (amount ≤ outstanding); load any order via `ordersApi.get` into the reused `OrderMoneyPanel`/`CheckoutDrawer` | — |
| Cash / Debit / Credit tenders | **Existing (P2)** | reuse | — |
| **Outstanding-invoice discovery** (across customers + per customer) | **Missing** | New service cloning `CustomerFinancialService` paid-subquery; `OrderAccess`-scoped; eligible states + `outstanding>0`; RETAIL vs IPTV by `order_source='SYSTEM'`; currency per row | — |
| Invoice/order-number **lookup** | **Missing** | small resolver → order id (for deep-links by number) | — |
| **Multi-invoice collection** (one customer, N invoices) | **Missing** (ledger is per-order) | N offline Payment rows (one per order) tagged `collection_id`/`collection_reference`; **atomic** via one tx + `RequestIdempotency`, per-order locks in sorted order, per-leg overpayment guard, deterministic child idempotency keys | smallest safe additive migration |
| **Split tenders** across a collection | **Existing + extend** | one method per atomic collection submit; multiple submits (shared `collection_reference`) for different methods → preserves earlier tenders | integrated-gateway **partial** card legs unavailable |
| **Central payment history** (cross-order) | **Missing** | new scoped history query + detail (allocations) | — |
| **Offline reversal** of a captured tender | **Missing** | **compensating row** (`mode=offline`, `status=refunded/partially_refunded`, `captured=0`, `refunded_amount_minor=X`, `original_payment_id`); `recompute()` restores outstanding (partial-safe); original never rewritten; over-/duplicate-reversal guarded | — |
| **Gateway refund / void** (operator) | **Partial → Unavailable in P3** | Bambora/Moneris adapters implement real partial refund/void **but are sandbox-gated and wired only to automatic system compensation — no operator path**. Per "don't fake gateway refunds", P3 ships **offline reversal only** and documents integrated refund/void as unavailable. | sandbox phase-gate; no operator route exists |
| **Invoice + receipt print** | **Existing** | reuse `InvoiceService::getOrIssue`/`viewDocument` + `ReceiptService::render` + existing routes; immutability preserved | — |
| **Pay-button deep-links** from modules | **Missing** | URL-addressable `?mode=payment&order=/customer=/invoice=`; wire Customer Sales/Financial, IPTV invoice, Orders | **Repairs: no order/invoice linkage** → not collectible (documented) |
| **IPTV entitlement integrity** | **Safe** | grant is **decoupled** from payment capture (no listener on payment); settle financially (re-derive renewal `billing_status`, never grant); reversal raises an **entitlement review** flag, never rolls back expiry / free / manual records | — |
| Permissions | **Partial** | reuse `payments.record_offline` (collect) + `refunds.process` (future gateway); **add** `payments.view_history` (read) + `payments.reverse_offline`; sync via `cacart:roles-sync` | — |

### 1.4 Financial invariants P3 must preserve

- **One ledger.** Per-order `SUM(captured)−SUM(refunded)` is the only money-of-
  record. `collection_id` is a grouping label (reporting roll-up), never a balance.
- **One customer, one currency per collection.** Per-leg overpayment ceiling =
  that order's current `outstanding` (re-read under `lockForUpdate`).
- **Atomic + idempotent.** A multi-invoice collection is all-or-nothing within one
  submit; a retry never double-collects (outer `RequestIdempotency` + per-leg
  deterministic `idempotency_key`). Deterministic lock ordering avoids deadlock.
- **Reversal safety.** Never delete the original; a compensating row restores
  outstanding; over-reversal and duplicate reversal are rejected; the order's
  `payment_status` re-derives non-terminally so the invoice becomes re-collectible;
  actor + reason + timestamp audited on every affected order.
- **Reverse ≠ Refund.** "Reverse Recorded Payment" (offline correction) is distinct
  from "Refund Customer" (gateway) — the latter is unavailable in P3.
- **Invoice immutability.** Posted invoice number + original PDF bytes are frozen;
  only payment-progress fields refresh in place. Never regenerate to change balance.
- **IPTV.** Settlement is purely financial (no auto-grant/extend); reversal flags
  entitlement for review and never rewrites expiry / free-extension / date-
  adjustment history.
- **Tenant + location isolation** via `BelongsToTenant` + `OrderAccess`; no invoice
  from an unrelated customer is ever shown; ids from URLs are navigation hints only
  — ownership/scope/status/balance are revalidated server-side.

### 1.5 New schema (additive only)

- `payments.collection_id` (uuid, nullable) + `payments.collection_reference`
  (string, nullable) + index — groups the N rows of one collection.
- `payments.original_payment_id` (uuid, nullable, FK payments) +
  `payments.reversal_reason` (string, nullable) — reversal linkage (compensating
  row). All nullable; existing single-order payments unaffected.

### 1.6 Permissions (added)

- `payments.view_history` — read the central payment ledger/history (location-
  sensitive, read-level). Gates discovery + history.
- `payments.reverse_offline` — reverse a captured offline tender (sensitive +
  location-sensitive). Reuse `refunds.process` for any future gateway refund.
  Both synced to existing tenants via `php artisan cacart:roles-sync`.

### 1.7 Implementation order

- **P3A** — permissions + discovery (outstanding list + lookup) + Get Payment
  workspace (single-invoice + partial) + module deep-links.
- **P3B** — multi-invoice + split collection (collection_id, allocation engine,
  atomic/idempotent, concurrency).
- **P3C** — central payment history + offline reversals + IPTV entitlement review +
  print wiring; gateway refund documented unavailable.

Each increment: backend + frontend + tests, committed separately.

---

## IMPLEMENTATION (complete)

All three increments shipped backend + frontend, committed separately. The money-of-
record stayed the per-order `PaymentLedger`; P3 added only the grouping label, the
compensating reversal row, and read/query surfaces promised in Phase 1.

### API surface (all under `/api/v1`, tenant + location scoped via `OrderAccess`)

| Method & path | Permission | Purpose |
|---|---|---|
| `GET  /pos/payments/outstanding` | `payments.view_history` | Discover collectible invoices (across customers, or `?customer_id=`/`?search=`/`?source=`). |
| `POST /pos/payments/collect` | `payments.record_offline` | Settle one customer's N invoices in one **atomic** collection (`allocations[]`, one `method`). Idempotent via `X-Request-Id`. |
| `GET  /pos/payments` | `payments.view_history` | Central cross-order history — filter by search/customer/method/status/source/date, paginated. |
| `POST /pos/payments/{payment}/reverse` | `payments.reverse_offline` | Reverse a captured **offline** tender — append-only compensating entry (`reason` required, optional partial `amount_minor`). |
| `GET  /orders/{order}/receipt?format=a4\|thermal` | `invoices.view` | Reused receipt PDF (print). `GET /orders/{order}/invoice` for the invoice PDF. |

Each identifier from a URL is a **navigation hint only**: the controller re-loads the
order and runs `OrderAccess::scope`/`canAccess` (404 non-disclosure) before any read or
write, re-reads the balance under `lockForUpdate`, and re-checks status — exactly as the
Phase-1 invariants require. No endpoint accepts a URL-supplied amount as authoritative.

### P3A — discovery + Get Payment (single + partial) · backend `59ccbc3`, frontend `56c77d3`

- `app/Pos/Payments/OutstandingInvoiceService::discover` — `DB::table('orders')`
  left-joined to the per-order captured/refunded sub-sum (mirrors
  `CustomerFinancialService`), explicit tenant filter + inline `OrderAccess` location
  scope, eligible status/payment-status sets, `outstanding > 0`, RETAIL vs IPTV by
  `order_source`. One currency per row.
- `OfflinePaymentService::record` guard relaxed so a **COMPLETED** order with a balance
  stays collectible (legitimate receivable); CANCELLED/REJECTED still blocked.
- Frontend `GetPaymentWorkspace` + URL-addressable `/pos?mode=payment&order=/customer=`;
  single-invoice collection reuses `CheckoutDrawer`. Deep-links added from the Customers
  module (overview + per-order).

### P3B — multi-invoice + split collection · backend `f1f7651`, frontend `df86a36`

- Additive migration `…130000_add_collection_grouping_to_payments` — nullable
  `collection_id` + `collection_reference` + index.
- `app/Pos/Payments/CollectionService::collect` — validates one customer / one currency /
  positive amounts, sorts order ids (`ksort`) for deterministic lock order, wraps all legs
  in one `DB::transaction`, and calls `OfflinePaymentService::record` per leg with a
  deterministic child idempotency key `<requestId>:<orderId>` and the shared
  `collection_id`/`collection_reference`. Any leg failing (e.g. overpayment) rolls back the
  whole collection. Audited `payment.collected`.
- Split tenders = one method per atomic submit; a second submit reuses the
  `collection_reference`, preserving earlier tenders.
- Frontend: multi-invoice allocation UI (checkbox + per-invoice amount), method tabs, and
  an atomic "Collect" bar in `GetPaymentWorkspace`.

### P3C — history + offline reversals + IPTV review + receipts · backend `d05f283`, frontend `c8aa11e`

- Additive migration `…130001_add_reversal_linkage_to_payments` — nullable
  `original_payment_id` + `reversal_reason` + index.
- `app/Pos/Payments/PaymentReversalService::reverse` — only a captured/partially-refunded
  **offline** tender; `reason` required; locks original + order; `reversible = captured −
  already-reversed`; guards `amount ≤ 0 || amount > reversible` (422); writes the
  compensating row (`captured=0`, `refunded_amount_minor=X`, status
  `refunded`/`partially_refunded`, `original_payment_id`, `reversal_reason`); appends a
  `PaymentEvent` (`offline_reversed`); `PaymentLedger::recompute` re-derives a **non-terminal**
  `payment_status` so the invoice re-opens; audited `payment.offline.reversed`. Idempotent
  via `reverse:<paymentId>:<requestId>` — a retry returns the existing reversal, never a
  second one.
- IPTV safety — for an `order_source='SYSTEM'` order the reversal calls the new
  `IptvSubscriptionService::flagPaymentReversalReview`, which appends a non-destructive
  `status_change` account note flagging entitlement review. It **never** touches expiry,
  `billing_status`, grant, free-extension or date-adjustment history (verified by
  `ReversalTest::test_reversing_a_system_iptv_order_is_safe`).
- `app/Pos/Payments/PaymentHistoryService::history` — cross-order, tenant + location
  scoped, filterable + paginated; each row flags `is_reversal` and the amount already
  `reversed_minor` against it.
- Frontend `PaymentHistoryWorkspace` replaces the old in-sale placeholder: filterable,
  paginated central history; a reason-required **Reverse** dialog (labelled explicitly as a
  bookkeeping correction, **not** a gateway refund; IPTV rows warn about entitlement
  review); **Print receipt** via the reused server-authorized PDF route. A **"Collect in
  POS"** deep-link was added from the IPTV invoices tab into the central Get Payment.

### Gateway refund / void — UNAVAILABLE in P3 (documented limitation)

The Bambora and Moneris adapters **do** implement real partial refund and void, but they
are **sandbox phase-gated** and wired **only** to the automatic `OrderFinalizationCoordinator`
system-compensation path — there is **no operator-facing route, permission wiring, or UI**
to trigger a real customer refund. Per the hard constraint *"do not fake gateway refunds …
if actual gateway refund capability does not exist, show that it is unavailable"*, P3 ships
**offline reversal only** (a ledger correction) and surfaces gateway refund as unavailable.
The precise limitation: a captured **card** payment cannot be refunded to the card from the
POS in P3; only its offline-ledger representation can be reversed as a bookkeeping
correction. Enabling operator gateway refunds is a future increment (reuse the existing
`refunds.process` permission + the adapter `refund`/`void` methods behind a new operator
route once the sandbox gate is lifted). The reversal UI never claims money returned to a
card and never rolls back an externally-captured gateway payment via a DB rollback.

### Test results (all green)

| Suite | Tests | Notes |
|---|---|---|
| `tests/Feature/Pos/OutstandingInvoiceTest` | 5 | discovery, partial, paid-excluded, collect-on-COMPLETED, customer filter, tenant isolation, permission gate |
| `tests/Feature/Pos/CollectionTest` | 4 | multi-invoice mixed partial, atomic overpayment rollback, single-customer/currency, idempotency |
| `tests/Feature/Pos/ReversalTest` | 8 | full + partial reversal, over-/duplicate reject, permission gate, IPTV safety, history lists+flags, idempotent retry, reason required |
| Backend regression (`Payment\|Order\|Iptv\|Pos\|…`) | **681 passed** | no regressions from the `OfflinePaymentService` guard relax or the `IptvSubscriptionService` addition |
| Frontend `PosWorkspace.test` | 15 | incl. Get Payment worklist, multi-invoice allocation, and the P3C reversal flow |
| Frontend regression (`iptv`+`orders`+`customers`) | **122 passed** | — |
| Static | Pint ✓ · PHPStan (P3 files **0 errors**; full tree 21 ≤ pre-existing 22) · `tsc` ✓ · ESLint ✓ · `vite build` ✓ | — |

Dev DB migrated (`php artisan migrate --force`); `php artisan cacart:roles-sync` added the
2 new permission rows across both tenants (users must re-login to re-fetch `/auth/context`).

### Completion matrix

| # | Acceptance criterion | Status |
|---|---|---|
| 1 | View all outstanding invoices | ✅ `GET /pos/payments/outstanding` (across customers) |
| 2 | View a customer's outstanding invoices | ✅ `?customer_id=` + "This customer" scope |
| 3 | Pay a single invoice | ✅ Get Payment → reused `CheckoutDrawer` |
| 4 | Collect multiple invoices at once | ✅ atomic `CollectionService` (`collection_id`) |
| 5 | Partial payment | ✅ per-leg amount ≤ outstanding (ledger ceiling) |
| 6 | Multiple payment methods | ✅ cash/debit/credit; split = one method per atomic submit |
| 7 | Central payment history | ✅ `GET /pos/payments` + `PaymentHistoryWorkspace` |
| 8 | Authorized offline-payment reversal | ✅ compensating row + `payments.reverse_offline` |
| 9 | Supported card refunds/voids | ⛔ **Unavailable in P3** — documented above (no operator path; not faked) |
| 10 | Receipt + invoice printing | ✅ reused server-authorized `/receipt` + `/invoice` PDFs |
| 11 | Pay-button navigation from other modules | ✅ Customers + IPTV → `?mode=payment&order=`; Orders via `OrderMoneyPanel`; **Repairs N/A** (no order/invoice linkage) |

**Not rebuilt:** P1/P2, the per-order ledger, the offline-payment engine, the invoice/
receipt PDF pipeline, NH POS. **No** Pro-Forma user document, Branded Reprint, or Branded
Copy was reintroduced. **No** standalone Get Payment page or second accounting system.

### Commit hashes

| Increment | Backend | Frontend |
|---|---|---|
| Phase 1 audit | `3aa31be` | — |
| P3A discovery + Get Payment | `59ccbc3` | `56c77d3` |
| P3B multi-invoice collection | `f1f7651` | `df86a36` |
| P3C history + reversals + receipts | `d05f283` | `c8aa11e` |

All committed locally on `techtitans/integration`; **not** pushed or deployed.

### Live browser verification (Playwright, sandbox)

`frontend/e2e/pos-p3.spec.ts` logs into the local `techtitans` owner workspace and renders
both centralized workspaces against the running dev stack (backend `:8010`, frontend
`:5180`) at **1920 / 1440 / 1024 / 768 / 390**. Screenshots in
`docs/techtitans/p3-screens/` (`get-payment-*.png`, `payment-history-*.png`). No real card
is used — gateway refund stays unavailable by design.

Observed live (real tenant data):
- **Get Payment** — 4 outstanding **IPTV** invoices for one customer (Dinesh) with correct
  per-order balances incl. a **partial** (CA$17.60 of CA$22.60), source badges, "This
  customer" scope link, per-row **Collect**, and the "4 invoices outstanding · CA$61.45"
  roll-up.
- **Payment History** — **35 payments (page 1 of 2)** across RETAIL + IPTV sources, each row
  showing when / invoice / customer / method / amount / **captured** status with per-row
  **Receipt** and **Reverse** actions and working pagination — the single centralized ledger
  view, live.

All five viewports pass (`1 passed`); the layout reflows (rail collapses, table scrolls) at
tablet/mobile widths.
