T 199
uid: T-199 title: WOPC payment lifecycle — one honest status state-machine + payout-link on the WOPC + sign-to-pay origination status: doing area: accounting-wopc created: 2026-08-01 updated: 2026-08-04 owner: girafeev1 assignee: Records (Infrastructure) related: T-178, T-184, T-185, T-167, T-024, T-191, T-193
✅ Read AGENTS.md · Records (Infrastructure) · https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD · board next-free was T-199. DESIGN SIGNED OFF 2026-08-01 — all seven decisions resolved by the owner (see "Resolved decisions"). Reframes T-185 into a full WOPC payment lifecycle; folds in T-184 / T-167. Phase 1 (foundation) in progress (2026-08-01) — the honest lifecycle enum +
deriveWopcLifecycleare in, and the Records tag derives from them (see Build log). Phases 2 (payout tx on the WOPC → accurate "Reimbursed", + the derived-journal rewire) and 3 (sign-to-pay, money) follow.
Why (the structural problem)¶
A WOPC's "state" today is not one field — it is scattered, and one flavour of WOPC can't express "paid" at all:
- Lifecycle —
WOPC.status:'active' | 'pending_transaction'.'active'is set when a bank tx is linked (createWOPCborn-with-tx, orupdateWOPCMatched);'pending_transaction'otherwise. - Signed — a separate field,
WOPC.signature.at. - Voided — a separate field,
WOPC.void.at(re-signing clears it). - Deleted — a separate field,
WOPC.deleted.at. - Legacy vestige — a top-level
statusenum ('matched'|'completed'|'failed'|'voided'), mostly dead.
So "signed", "voided", "deleted" and "paid" are four orthogonal facts stored four different ways, and only
the last rides status — under the misleading name active. Worse: a reimbursement WOPC never
receives a transaction.id (its payout link lives on the receipt, per the 2026-07-28 "option 2"
decision), so updateWOPCMatched never runs for it and its status is stuck at 'pending_transaction'
forever, even after every receipt is paid. The enum literally cannot say "a reimbursement that's been
paid" — which is why a paid reimbursement WOPC leaks back into the fee unpaid-picker (the bug T-185 was
opened for).
Owner (2026-07-31): "a WOPC that says 'active' with a transaction.id = a WOPC that's already been paid is kind of ridiculous. Why can't the WOPC say 'settled' with the transaction.id instead", and "I disagree with 'paidAt' because the date of the tx linked by its transaction.id is already strong enough evidence". The fix is not a new marker field — it is an honest lifecycle status the reimbursement path can set, plus moving the payout link so the WOPC owns it.
The model¶
Part 1 — the payout link moves onto the WOPC¶
Write the payout transaction.id on the WOPC (exactly as a fee WOPC already does); the receipts keep
pointing up via their existing metadata.wopc.ref; the WOPC's status then reflects the state of its
receipts.
- One payout per WOPC (D2). A WOPC is never split — one WOPC = one payout transaction. Settlement is per-WOPC (match the WOPC to its one payout), not per-receipt. Need to split a batch ⇒ issue two WOPCs.
- Consistent with "no fact stored twice." The link moves (receipt → WOPC), it is not duplicated, and the transaction still carries no reverse-pointer. This refines the 2026-07-28 "option 2" (link on the receipt) — the link now lives on the WOPC — it does not reintroduce a reverse-pointer on the tx.
- The real work — derived accounting rewires.
generateReimbursementEntriesServer(lib/accounting/derivedJournals.server.ts) derivesDR 2110 / CR bankfrom the receipt'ssettledBankTxIdtoday. Moving the link means it must derive from the WOPC'stransaction.id. This is where a double-post hides; the half needs care and heavy tests. This is the bulk of Part 1.
Part 2 — one honest lifecycle status¶
Collapse the scattered state into a single enum. Final values (D1 · D4):
draft · standBy · awaitingSignature · processing · paid · error · voided · deleted
stateDiagram-v2
[*] --> paid: matched to an EXISTING bank tx (confirmation — D3a)
[*] --> standBy: created stand-alone via Reimburse modal (origination — D3b)
standBy --> draft: Save as draft (unsent, editable)
draft --> standBy: finalise
draft --> awaitingSignature: send for signature
standBy --> awaitingSignature: Request signature
standBy --> processing: no-signature WOPC — Process Payment
awaitingSignature --> processing: signed & sealed, auto-fire payout (D6)
processing --> paid: payout succeeded
processing --> error: failed, record { stage, reason } (D5)
error --> processing: retry (Process Payment button)
draft --> deleted
standBy --> deleted
awaitingSignature --> voided
processing --> voided
paid --> [*]: TERMINAL — a paid WOPC can NEVER be voided (D7)
draft— created but unsent and editable. A "Save as draft" button on the Reimburse modal parks a WOPC here (new — D3).standBy— finalised, ready, not yet sent. A WOPC that needs no signature also waits here until paid. This is the default on creation via the Reimburse modal.awaitingSignature— sent, waiting for the director to sign.processing— "Processing payment"; entered automatically when a WOPC that originates its payout is signed & sealed (D6). The in-flight money state, and a lock.paid— terminal success (the finish line — owner chosepaidoversettled, D1). Reached by a successful payout, or immediately when a WOPC merely confirms an existing bank tx (D3a).error— the app couldn't act as planned.status = errorplus companionerror.{ stage, reason }—stage= the status the WOPC failed at (e.g.processing),reason= why, if determinable (D5).voided— a sealed WOPC cancelled (VOID-stamped PDF kept).deleted— an unsent WOPC removed. Both fold intostatus(D4), retiring today'sWOPC.void.at/WOPC.deleted.atside-fields.
Origination vs confirmation — the branch (D3).
- (a) Confirmation — a WOPC merely matched to an already-existing bank transaction → paid
directly. Nothing to process; the money already moved.
- (b) Origination — a WOPC created as a stand-alone document → runs the pipeline. For a reimbursement
(owner's flow, D3b-i): select receipts (per user) → the Reimburse <User> modal confirms payee
info → WOPC created and marked standBy immediately → operator chooses Save as draft (draft),
leave on standBy, or Request signature (awaitingSignature) → signed & sealed → processing
until the payout clears → paid. (Stand-alone contractor-fee origination — "Pay with WOPC" — follows
the analogous path; detailed with T-184 in Phase 3.)
Signature/void stay as data. WOPC.signature.{at,by,image} and the void PDF artefacts still exist;
status becomes the projection of the lifecycle over them, not a replacement for the facts.
Part 3 — sign-to-pay origination, the retry button, the idempotency interlock¶
Signing auto-fires the payout the instant the seal lands (D6 — no confirm beat), and a "Process Payment" button in the WOPC-tab kebab re-fires a failed one. Because there is no human beat, the idempotency interlock is not optional — it is the whole safety of the feature, and it is exactly what T-185's can't-pay-twice guarantee is for:
- Every payout fires under an idempotency key derived from the WOPC — a payout that succeeded but reported failure cannot double-pay on retry.
processingis a lock — a second trigger (auto or button) refuses to stomp an in-flight payout.- The button checks
status === paidand the idempotency record before re-firing. - No-signature reimbursements (unsigned closing for non-directors) never reach
awaitingSignature→ their payout fires fromstandByvia the same button / auto path.
Resolved decisions (signed off 2026-08-01)¶
- D1 — status names. camelCase set
standBy · awaitingSignature · processing · error · voided, with the paid terminal namedpaid(notsettled), plusdraft(D3) anddeleted(D4). - D2 — one payout per reimbursement WOPC. Yes — "WOPC isn't meant to be split."
- D3 — origination vs confirmation. (a) matched-to-existing-tx →
paid; (b) created stand-alone → the pipeline above, with a new Save as draft button on the Reimburse modal. - D4 — void/deleted fold into
status. Yes. - D5 —
errorcarries{ stage, reason }. Yes — writeerrorto status;stage= which status it failed at;reason= the failure reason if determinable. - D6 — auto-fire on sign. Yes, fully automatic on sign & seal — which makes the Phase-3 idempotency interlock mandatory.
- D7 — void after paid. No — a
paidWOPC can never be voided.paidis strictly terminal.
Phased plan (each phase is its own gate; build in order)¶
- Phase 1 — the honest status + migration (NO money). Introduce the enum
(
draft·standBy·awaitingSignature·processing·paid·error·voided·deleted); foldvoid/deletedin; migrate existing docs (active → paidwhere a tx is linked, elsestandBy;pending_transaction → standBy;WOPC.void.at → voided;WOPC.deleted.at → deleted); point the already-shippedisWopcPaidseam atstatus === paid. Confirmation-flow matches writepaiddirectly. Gate: structure-gate sign-off (existing field, new semantics + a migration) + tsc/tests. - Phase 2 — payout link on the WOPC + journal rewire (accounting, NO money). Write the payout
transaction.idon the reimbursement WOPC (one per WOPC); rewiregenerateReimbursementEntriesServerto derive theDR 2110 / CR bankleg from it; migrate already-settled receipts. Gate: accounting sign-off + tsc/tests + a reconciliation check that journal counts are unchanged. Supersedes the option-2 link location (decisions.md 2026-07-28) when it lands. - Phase 3 — sign-to-pay origination + Save-as-draft + Process Payment button + idempotency (MOVES
MONEY). The Reimburse-modal draft/standBy/awaitingSignature choice; auto-fire the payout on seal;
processinglock;error{stage,reason}; the kebab retry button; the idempotency key. Absorbs T-184 (origination) and T-167 (sign-triggered release). Gate: the highest bar — idempotency proven, partial-failure paths tested.
Risks¶
- Accounting double-post (Phase 2) — the 2110 legs are derived; move the source of truth carefully or a settlement books twice.
- Money double-pay (Phase 3) — auto-fire (D6, no human beat) + a retry button is the canonical double-pay trap; the idempotency interlock is non-negotiable (this is T-185's whole job).
paidis terminal (D7) — this closes the void-after-paid hole cleanly, but it also means there is no clawback path: a wrongly-paid WOPC cannot be un-paid. If a refund is ever needed it is a new transaction/document, never a void of the paid WOPC. Accepted.- Migration — mapping today's two-value status + the side-fields onto the eight-value enum across every reader/writer; a missed reader silently mis-reads state.
- Partial-failure granularity — "seal stored but payout failed" vs "payout sent but confirmation lost"
are different recoveries;
error{stage,reason}+ idempotency must disambiguate.
Relationship to existing tasks¶
- Absorbs T-185 — Phase 1 (honest status) + Phase 3 (idempotency interlock). The shipped Layer-1
isWopcPaidseam is the single insertion point. - Absorbs T-184 ("Pay with…" origination) and T-167 (sign-triggered payout release) into Phase 3.
- Refines the 2026-07-28 "option 2" link location and relates to T-191/T-193 (WOPC references-not-copies — the tx link on the WOPC follows that contract).
- Builds on T-178 (the reimbursement pipeline).
Owner's words (source, per AGENTS.md)¶
- 2026-07-31: "a WOPC that says 'active' with a transaction.id = a WOPC that's already been paid is kind of ridiculous. Why can't the WOPC say 'settled' with the transaction.id instead"; "I disagree with 'paidAt' because the date of the tx linked by its transaction.id is already strong enough evidence".
- 2026-08-01 (the proposal): move
transaction.idonto the WOPC + keep the WOPC-ref on the receipts, so the WOPC status reflects the receipts. StatusesstandBy(created/unsent; allow no-signature WOPCs to standBy) →awaitingSignature(sent) →Processing("Processing payment", automatic on sign & seal) → paid, withError(write to status;stage= where it failed,reason= why) for failures. A "Save as draft" button on the Reimburse modal for an unsent draft. A "Process Payment" button in the WOPC-tab kebab to retry a failed payment. - 2026-08-01 (decisions): D1 "agree… use 'paid'"; D2 "WOPC isn't meant to be split"; D3 confirm→paid, stand-alone→pipeline with the draft option; D4 "fold to status"; D5 "agree… write error to status, stage… and reason"; D6 "Auto-fire on sign"; D7 "No, a settled WOPC can never be voided".
Source: Records (Infrastructure) · https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
Build log (append-only, newest last)¶
-
Phase 1 (foundation) started (2026-08-01) —
lib/wopc/lifecycle.ts: theWopcLifecycleenum (draft·standBy·awaitingSignature·processing·paid·error·voided·deleted) + a singlederiveWopcLifecycle()from today's signals (status / tx / signature / void / deleted + the latest signing request). The Records WOPC-tab tag now derives from it (retiring its inline ladder).paidrequires a real payout tx; a signed-but-untransacted WOPC readsprocessing(not "Reimbursed"). Safe: no data migration, no money, no accounting change — pure derivation + centralisation.hasPayoutis the seam Phase 2 fills (the payout tx on the reimbursement WOPC → accurate "Reimbursed");draft/errorare only ever SET by the Phase 3 sign-to-pay flow. tsc + 7 new lifecycle tests green. -
Phase 2 (accurate "Reimbursed") — derived, no ledger change (2026-08-01). Filled the
hasPayoutseam: the Records WOPC list (pages/api/records/wopcs.ts) now does one batched read over its reimbursement WOPCs' backlinked receipts and marks a WOPC paid when any receipt carries a settled bank-tx id (metadata.settlement.bankTxId). That flows throughderiveWopcLifecycle→paid→ "Reimbursed"; a signed-but-unpaid reimbursement stays "Processing". Deliberately the SAFE half of Phase 2: it makes "Reimbursed" accurate by REFLECTING the receipts' payout state (derived, receipts stay the single home of the link — option 2), WITHOUT the structural move of the tx onto the WOPC or the derived-journal rewire, which is the ledger-risky part. That structural move (design Part 1) is deferred to when Phase 3 (sign-to-pay origination) actually needs the WOPC to OWN/originate the payout — it isn't needed to make "Reimbursed" correct. tsc + lifecycle tests green. -
Phase 3 rebuild — approach + Increments 1 & 2 (2026-08-04). Clean rebuild after a substitute agent's
claude/t-199-phase-3(unmerged), which released real payouts on unsigned WOPCs, computed a dead idempotency key, locked non-atomically, had a no-op "retry", and markedpaidwith no tx. Mapping the infra first showed most of Phase 3 already exists and is already safe:recordSignaturealready auto-fires the payout on sign (D6) — fee WOPCs viareleaseQueuedPaymentsForWopc, reimbursements via the settle-on-sign block — gated exactly-once by thesent→signedCAS; and double-pay is already guarded by the Airwallexrequest_id(v4 UUID, survives crashes) + the queue-status CAS (awaiting_signature→releasing). Decision: derive-first, no new WOPC fields (same pattern as Phases 1–2; the opposite of the branch's store-into-WOPC.statusconflation) — so no structure gate is triggered. - Increment 1 — honest
error, DERIVED (NO money, NO schema).deriveWopcLifecyclegains apayoutFailedsignal, ranked belowpaid(a landed tx outranks a stale failure) and aboveprocessing(a failed sign-to-pay readserror, not "Processing" forever).pages/api/records/wopcs.tsdoes one query overaccounting/wopcPaymentQueue/entries where status == 'release_failed'and marks those refs; the Records tag already renderserror→ red "Error". tsc clean; 9 lifecycle tests (2 new). - Increment 2 — safe "Process Payment" retry (MOVES MONEY, idempotent).
retryReleaseForWopc(lib/airwallex/paymentQueue.server.ts, +96/−0 — the provenreleaseQueuedPaymentsForWopcis untouched) re-selectsrelease_failedentries, re-claims each via the CAS (release_failed→releasing), and re-fires the storedtransferRequest— samerequest_id, so a transfer that actually went through returns the original and never double-sends. New endpointPOST /api/records/wopcs/process-paymentrefuses anything that isn't a signed WOPC with a genuinely failed payout, writes nothing toWOPC.status, and never reports false success. Kebab "Process Payment (retry)" surfaces only onerrorrows, behind a money-move confirm. Every one of the discarded branch's five defects is closed (signature bypass, dead key, TOCTOU lock, no-op retry, paid-without-tx). tsc clean. - Scope note:
error/retry cover the FEE (Airwallex queue) path — reimbursements settle receipts, they don't queue an Airwallex transfer, so they have norelease_failedentry (a settle failure surfaces to the operator at issue time instead).draft(the one state that genuinely needs a stored field) is still deferred to its own structure-gate. Not deployed — awaits the owner's deploy command. (Deployed 2026-08-04 with #979 on the owner's command — production READY,pms.theestablishers.com.) - Phase 3 · Increment 3 — the 2110 payout link, rebuilt clean (2026-08-04). The T-193
link-don't-duplicate contract is BUILT (structure gate passed as its own question; approvals + the
approved
gl['2110'].wopcRefshape recorded in T-193's log — the WOPC side needed no schema, it is Phase 2's existingtransaction.id). Auto-match now links a reimbursement payout instead of deferring (T-192 guard lifted): 2110 + ref →updateWOPCMatched→ settled receipts' missingsettlement.bankTxIdfilled — so the lifecycle'shasPayoutsignal turns true and the Records tag reads Reimbursed the moment the payout syncs, with no manual step. Manual/sign settles write the same pair via the settle core. Funding-account question resolved (owner 2026-08-04, Option A): DERIVED, never stored — an Airwallex-originated payout can only draw from the org wallet in the transfer currency (the API has no source-account field:AirwallexCreateTransferRequestnames only destination +source_currency, and the queue's storedtransferRequestcarries the currency for display); a manually-paid payout's account isbankTx.bankAccountId, captured at match/settle (what the settle step ③ already renders). No new field; a pre-settlement "intended source" instruction field was considered and rejected as duplicating a derivable fact. Sign-to-pay origination for reimbursements (queue at issue) remains with T-184's Pay-with flow;draftstill deferred to its own gate.