Bundul
Internal
Browse docs
Waiting for review

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/ and docs/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+3d in our DB and email the notice; nothing goes to Passport (trueup.service.ts:752).
  • Daily cron executeScheduledWithdrawals fires on day 3 → executeMoneyMovement → Passport createOneTimeTransaction as an immediate FORWARD ACH.
  • Cancel just flips our record; Passport never knew.
  • Settlement/return via webhook keyed on externalId BUNDUL-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, scheduleDate for future-dating). externalId BUNDUL-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"). externalId BUNDUL-TRUEUP-SWEEP-<id>. Fires only after hop 1 settles.

Status → tracking (what admin sees)

Hop 1: SCHEDULED/PENDINGscheduled (waiting to land, cancellable) · PROCESSINGexecuting (leaving the bank) · COMPLETEDcollected (money pulled into the customer's Passport account; collectedAt; customer confirmation email fires here) → fires hop 2. Hop 2 (BOOK): COMPLETEDsettled (settledAt; funds in Bundul account). Failure → record stays collected + ops "sweep stuck" alert (funds safe, re-run the sweep). FAILED/VOIDED (hop 1) → failed; CANCELLEDdeclined. 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

  1. Passport client (passport.service.ts) — cancelOneTimeTransaction(externalId, reason, comment), getTransaction(externalId); thread scheduleDate into the withdrawal builder (user-passport.service.ts).
  2. Send-to-customer (trueup.service.ts:752) — create the future-dated Passport txn now (scheduleDate = next valid ACH day ≥ +3), store passportTransactionId + scheduledFor = Passport's confirmed date, status scheduled, notice email with that date. Remove the money call from the day-3 cron. allowDuplicate:false so a retry can't double-create.
  3. Cancel (:551) — Passport cancel-by-externalId FIRST; on 204 flip our record to declined. On reject (already PROCESSING), surface "too late — collection is processing" and leave for reconcile. Disable the button once status leaves SCHEDULED/PENDING.
  4. Cron → reconcile (:1082) — no longer pulls money (Passport does on scheduleDate); instead GETs status for scheduled/ executing records and syncs our state, as a webhook backstop.
  5. 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.