Skip to content

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 + deriveWopcLifecycle are 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:

  • LifecycleWOPC.status: 'active' | 'pending_transaction'. 'active' is set when a bank tx is linked (createWOPC born-with-tx, or updateWOPCMatched); '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 status enum ('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

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) derives DR 2110 / CR bank from the receipt's settledBankTxId today. Moving the link means it must derive from the WOPC's transaction.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 chose paid over settled, 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 = error plus companion error.{ 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 into status (D4), retiring today's WOPC.void.at / WOPC.deleted.at side-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.
  • processing is a lock — a second trigger (auto or button) refuses to stomp an in-flight payout.
  • The button checks status === paid and the idempotency record before re-firing.
  • No-signature reimbursements (unsigned closing for non-directors) never reach awaitingSignature → their payout fires from standBy via 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 named paid (not settled), plus draft (D3) and deleted (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 — error carries { stage, reason }. Yes — write error to 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 paid WOPC can never be voided. paid is 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); fold void/deleted in; migrate existing docs (active → paid where a tx is linked, else standBy; pending_transaction → standBy; WOPC.void.at → voided; WOPC.deleted.at → deleted); point the already-shipped isWopcPaid seam at status === paid. Confirmation-flow matches write paid directly. 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.id on the reimbursement WOPC (one per WOPC); rewire generateReimbursementEntriesServer to derive the DR 2110 / CR bank leg 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; processing lock; 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).
  • paid is 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 isWopcPaid seam 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.id onto the WOPC + keep the WOPC-ref on the receipts, so the WOPC status reflects the receipts. Statuses standBy (created/unsent; allow no-signature WOPCs to standBy) → awaitingSignature (sent) → Processing ("Processing payment", automatic on sign & seal) → paid, with Error (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: the WopcLifecycle enum (draft·standBy·awaitingSignature·processing·paid·error·voided·deleted) + a single deriveWopcLifecycle() 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). paid requires a real payout tx; a signed-but-untransacted WOPC reads processing (not "Reimbursed"). Safe: no data migration, no money, no accounting change — pure derivation + centralisation. hasPayout is the seam Phase 2 fills (the payout tx on the reimbursement WOPC → accurate "Reimbursed"); draft / error are 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 hasPayout seam: 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 through deriveWopcLifecyclepaid → "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 marked paid with no tx. Mapping the infra first showed most of Phase 3 already exists and is already safe: recordSignature already auto-fires the payout on sign (D6) — fee WOPCs via releaseQueuedPaymentsForWopc, reimbursements via the settle-on-sign block — gated exactly-once by the sent→signed CAS; and double-pay is already guarded by the Airwallex request_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.status conflation) — so no structure gate is triggered.

  • Increment 1 — honest error, DERIVED (NO money, NO schema). deriveWopcLifecycle gains a payoutFailed signal, ranked below paid (a landed tx outranks a stale failure) and above processing (a failed sign-to-pay reads error, not "Processing" forever). pages/api/records/wopcs.ts does one query over accounting/wopcPaymentQueue/entries where status == 'release_failed' and marks those refs; the Records tag already renders error → 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 proven releaseQueuedPaymentsForWopc is untouched) re-selects release_failed entries, re-claims each via the CAS (release_failed→releasing), and re-fires the stored transferRequest — same request_id, so a transfer that actually went through returns the original and never double-sends. New endpoint POST /api/records/wopcs/process-payment refuses anything that isn't a signed WOPC with a genuinely failed payout, writes nothing to WOPC.status, and never reports false success. Kebab "Process Payment (retry)" surfaces only on error rows, 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 no release_failed entry (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'].wopcRef shape recorded in T-193's log — the WOPC side needed no schema, it is Phase 2's existing transaction.id). Auto-match now links a reimbursement payout instead of deferring (T-192 guard lifted): 2110 + ref → updateWOPCMatched → settled receipts' missing settlement.bankTxId filled — so the lifecycle's hasPayout signal 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: AirwallexCreateTransferRequest names only destination + source_currency, and the queue's stored transferRequest carries the currency for display); a manually-paid payout's account is bankTx.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; draft still deferred to its own gate.