# ADR-104: Cross-PWA Payment Handoff — localStorage Bridge into a Prefilled Custodian Transfer

## Status

Accepted, 2026-07-03.

## Status History

```yaml
status_history:
  - date: 2026-07-03
    status: Accepted
    changed_by: hkl
    reason: >
      First implementation live (Rent Vehicle → Fund Custodian). Approved rented-vehicle
      expenses hand off to a prefilled Cash-Hand-to-Hand giving transfer; pattern captured
      so future payable-producing PWAs reuse it instead of inventing new payment paths.
    changed_via: PWA-2 session (rentveh build)
```

## Context

Several PWAs produce **payable obligations** — records that end in someone handing money
to an outside party. The first case is the Rent Vehicle PWA (`/rentveh/`): an approved
expense yields a net payable to the vehicle owner. The actual payment must be recorded by
a **fund custodian**, whose wallet accounting, payee master (ADR-036), payment-mode
verification (Cash Hand-to-Hand composite, ADR-072 / input_and_image_standards §2) and
ledger all live in the Fund Custodian PWA.

Re-implementing payment capture inside each source PWA would duplicate wallet logic and
bypass CTH verification. Making the user re-type party, amount and narration in the
custodian app costs clicks and invites transcription errors. The goal: **one tap from the
approved record to a fully prefilled custodian transfer**, with the custodian only
verifying and completing the existing save flow.

## Decision

A source PWA hands a payment off to Fund Custodian as follows:

1. **Visibility** — the source PWA shows a *Record Payment* action on payable records
   only to TMs and **active custodians** (client-side check: `custodian.profiles`
   read with `Accept-Profile: custodian`, cached locally).
2. **Bridge (ADR-026)** — on tap, the source writes one localStorage payload and
   navigates same-tab:
   ```
   localStorage['rv-pay-handoff'] = {
     v: 1, source: '<pwa>', expense_id, ts: Date.now(),
     payee: { name, phone },          // party identity from the source's master data
     amount: <net payable>,
     mode: 'cash_hand_to_hand',       // default payment mode
     note: '<narration incl. source record id + calculation summary>'
   }
   location.href = '/finance/custodian/?pay=<pwa>'
   ```
3. **Custodian intake** — `_handleExternalPayHandoff()` at the end of custodian `init()`:
   - runs only when `?pay=` present; **consumes the payload once** and cleans the URL;
     ignores payloads older than **10 minutes** (stale-link safety);
   - opens the transfer screen: role **giver**, category **party**;
   - **payee resolution**: exact-name (`ilike`, no wildcards) active vendor match against
     `custodian.payees`; if absent, creates it via the existing `upsert_payee` RPC
     (`v_<slug>` id) with name + phone — no duplicate payees on repeat payments;
   - prefills amount (Indian-formatted), selects the requested payment mode chip
     (default Cash Hand-to-Hand → existing CTH capture applies on save), fills narration.
4. **Traceability** — the narration always contains the source record id
   (e.g. `… · RV_1234_AB3C`), linking the custodian ledger entry back to the source sheet.
5. The custodian's normal save path runs unchanged — all validation, CTH verification,
   wallet math and ledger writes stay in one place.

**Decision Maker:** hkl

## Alternatives Considered

- **Source PWA writes directly to `custodian.transfers`.** Rejected: bypasses CTH
  verification, wallet balance logic, and edit/audit flows; duplicates them per PWA;
  violates the single-owner principle for payment records.
- **Shared payment RPC called from the source PWA.** Rejected: the human verification
  steps (mode selection, CTH photo + signature) are UI, not SQL — an RPC cannot carry
  them; would still need custodian UI for corrections.
- **Deep-link with all data in query params.** Rejected: narration + payee details push
  URL length limits, leak into browser history/server logs, and can be replayed; the
  localStorage payload is consumed once and TTL-guarded.
- **Manual re-entry in custodian (status quo).** Rejected: ~8 extra inputs per payment,
  transcription-error risk on amount, no systematic back-reference to the source record.

## Consequences

**Positive:**
- Payment recording drops to: tap → verify → save → CTH capture.
- Party master stays deduplicated (exact match first, `upsert_payee` else).
- Every ledger entry references its source record id — auditable both ways.
- Pattern is reusable: any future PWA adds only the button + payload; custodian intake
  generalises by extending the `?pay=` source whitelist.

**Negative / Trade-offs:**
- Client-side-only role gate (consistent with platform trust model, ADR-011 context).
- No automatic "paid" write-back to the source record yet — the link is narration-only.
  A status write-back (custodian → source schema) would need a SECURITY DEFINER path
  (ADR-007) and is deferred until requested.
- localStorage bridge requires same-origin (holds for all hub PWAs, ADR-026).

**Risks and mitigations:**
- *Stale/replayed payload* → consumed once + 10-minute TTL + URL cleaned via
  `history.replaceState`.
- *Payee name drift creating duplicates* → exact-name match is case-insensitive; owner
  names come from the vehicle master (single source), not free text.
- *Custodian UI refactor breaks intake* → covered by a cross-PWA E2E test in
  `tests/rentveh.spec.js` asserting payee, amount, mode chip, narration and payload
  consumption.

## Related Decisions

- ADR-026 — Cross-PWA session/state handoff via same-origin localStorage (the bridge).
- ADR-036 — `custodian.payees` is the shared payee master (resolution target).
- ADR-033/044 — payment/approval logic lives in its owning PWA; sources do not duplicate it.
- ADR-071/072 — amount formatting and CTH proof standards applied by the custodian form.

## References

- Source implementation: `/var/www/360lm/rentveh/index.html` (`recordPayment()`),
  `/var/www/360lm/finance/custodian/index.html` (`_handleExternalPayHandoff()`, v32)
- E2E: `/var/www/360lm/tests/rentveh.spec.js` — "payment handoff to Fund Custodian"
- State notes: memory `dbt_rentveh.md`, `dbt_custodian.md`
