decisions
True-Up Withdrawal — Schedule on Passport (not our cron)
A frozen record of a decision at the time. Superseded by a new record rather than edited.
Written by the build · 2 September 2026
Frozen. A record of a decision at a point in time, not living documentation. Do not update it — supersede it with a new record instead. For how this works today, see
docs/explanation/anddocs/generated/.
True-Up Withdrawal — Schedule on Passport (not our cron)
Status: Implemented (2026-07-21, build-green). Needs sandbox verification of scheduleDate on a COLLECT ACH + one live end-to-end (schedule → cancel → let-settle) before deploy.
Owner: Farhan
Moves the 3-day wait for a true-up collection (withdrawal) off our DB/cron and onto Passport as a future-dated ACH. We mirror Passport's state for display, and the cancel button becomes a real Passport cancel. Deposits (we pay the customer) are out of scope — they stay immediate.
Today (for contrast)
- Send-to-customer withdrawal → we store
status:'scheduled'+scheduledFor = now+3din our DB and email the notice; nothing goes to Passport (trueup.service.ts:752). - Daily cron
executeScheduledWithdrawalsfires on day 3 →executeMoneyMovement→ PassportcreateOneTimeTransactionas an immediateFORWARDACH. - Cancel just flips our record; Passport never knew.
- Settlement/return via webhook keyed on
externalIdBUNDUL-TRUEUP-{id}.
Passport capability (confirmed against v3.0.0 docs)
| Need | Endpoint | Rules |
|---|---|---|
| Schedule | POST /v1/customer/id/{cid}/transaction + top-level scheduleDate (MM/DD/YYYY) |
COLLECT (source externalAccount → dest account). Starts SCHEDULED. Past date rejected (EC-VA-0005); future allowed. |
| Cancel | POST /v1/transaction/externalId/{externalId}/cancel body {reason, comment} → 204 |
Only while SCHEDULED/PENDING, before PROCESSING. Not revocable. |
| Retrieve | GET /v1/transaction/externalId/{externalId} → 200 |
Returns status, scheduleDate, statusReason, statusDate, expectedCompletionDate. |
We already stamp externalId: BUNDUL-TRUEUP-{recordId}, so cancel/fetch address by
externalId — no dependency on the numeric txn id. stop is CHECK-only (irrelevant).
Timing: standard Forward Collect ACH cut-off 3:30 PM PT, debits next business
day. So the scheduleDate must be a valid business day ≥ +3 calendar days, not blind
now+3.
Sandbox caveat: scheduleDate is exampled for sends but not explicitly in the COLLECT
field table (retrieve responses do show COLLECT txns carrying scheduleDate/SCHEDULED).
Verify once in sandbox before relying on it.
Two hops (confirmed vs Passport docs + prod behavior)
A single cross-customer ACH collect is rejected (EC-BL-0001 "account not found" — proven live). The supported pattern:
- Hop 1 — ACH collect external bank → the customer's OWN Passport account (customer-scoped
POST /v1/customer/id/{numeric customerPassportId}/transaction,scheduleDatefor future-dating). externalIdBUNDUL-TRUEUP-<id>. - Hop 2 — BOOK sweep customer's Passport account → Bundul true-up account (global
POST /v1/transaction,method:BOOK, cross-customer OK per docs "any two Passport Accounts"). externalIdBUNDUL-TRUEUP-SWEEP-<id>. Fires only after hop 1 settles.
Status → tracking (what admin sees)
Hop 1: SCHEDULED/PENDING → scheduled (waiting to land, cancellable) · PROCESSING → executing (leaving the bank) · COMPLETED → collected (money pulled into the customer's Passport account; collectedAt; customer confirmation email fires here) → fires hop 2.
Hop 2 (BOOK): COMPLETED → settled (settledAt; funds in Bundul account). Failure → record stays collected + ops "sweep stuck" alert (funds safe, re-run the sweep).
FAILED/VOIDED (hop 1) → failed; CANCELLED → declined. Record fields: passportTransactionId (hop 1), sweepTransactionId (hop 2), collectedAt, settledAt — all surfaced in listActivity/hydrateRecords.
Deposits are unchanged (single hop → settled).
Passport customer-id bug (fixed 2026-07-21)
createOneTimeTransaction was posting the Mongo userId as the customer path id → 400 "Invalid value for id". Fixed in all 3 callers (withdrawal, deposit, main-flow top-up) to use the numeric customerPassportId. See [[trueup-passport-scheduling]] memory.
Phases
- Passport client (passport.service.ts) —
cancelOneTimeTransaction(externalId, reason, comment),getTransaction(externalId); threadscheduleDateinto the withdrawal builder (user-passport.service.ts). - Send-to-customer (trueup.service.ts:752) —
create the future-dated Passport txn now (
scheduleDate= next valid ACH day ≥ +3), storepassportTransactionId+scheduledFor= Passport's confirmed date, statusscheduled, notice email with that date. Remove the money call from the day-3 cron.allowDuplicate:falseso a retry can't double-create. - Cancel (:551) — Passport cancel-by-externalId
FIRST; on
204flip our record todeclined. On reject (alreadyPROCESSING), surface "too late — collection is processing" and leave for reconcile. Disable the button once status leavesSCHEDULED/PENDING. - Cron → reconcile (:1082) — no longer
pulls money (Passport does on
scheduleDate); insteadGETs status forscheduled/executingrecords and syncs our state, as a webhook backstop. - Webhook status mapping (webhook.service.ts:1231) —
apply the table above; confirmation email only on
COMPLETED.
In-flight migration: existing scheduled records (no Passport txn) drain via the legacy
path during transition; only new sends use Passport. True-up is quarterly, so the window is
small.
This is a record of a decision at the time. It is not edited — write a new record that supersedes it.