Skip to content

banking: the caller for Column ACH, with bank accounts, routes, sweep and page - #357

Merged
ralyodio merged 1 commit into
masterfrom
worktree-column-ach-caller
Sep 17, 2026
Merged

ralyodio merged 1 commit into
masterfrom
worktree-column-ach-caller

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What was left off

PR #318 merged the Column ACH adapter on Sep 7, but nothing imported it: no route, cron or page called getActiveBankProvider(), the bank_transfers migration was never applied to prod, and Railway had no COLUMN_* variables. This PR is the caller.

What this adds

  • src/lib/banking/service.ts: linkBankAccount, originateTransfer, sweepBankTransfers. A transfer row is inserted as initiated with its idempotency key before the originator is called, so the unique index is the guard against a double origination under a race. A provider rejection marks the row failed with the reason. An interrupted origination (row with no provider id, older than two minutes) is re-submitted by the sweep under the same key, which Column honours by returning the original transfer.
  • The sweep runs from /api/cron/monitor-payments. Settlement starts a hold (BANK_TRANSFER_HOLD_DAYS, default 5); the transfer completes after it; completed transfers are re-checked daily for sixty days and a late return is recorded as a return with its code. A failed/canceled word from the provider never un-settles moved money.
  • Bank accounts: createCounterparty on the provider interface, the Column POST /counterparties call, and a bank_counterparties table holding the provider reference, routing number and last four. The account number is never stored. Routing numbers are checked against the ABA checksum first.
  • Routes under /api/banking: rail status; accounts list/link/remove; transfers list/originate/get. idempotencyKey is required (body or Idempotency-Key header).
  • /banking page, linked from both header menus. The idempotency key is minted when the form opens, so a double submit replays rather than re-originates.
  • Migrations applied to prod today: bank_transfers (from banking: provider-neutral bank transfers, with Column mapped #314), ach_payin_hold (from stripe: ACH pay-in, held for 24h before the merchant is told #313; the cron had been querying the missing column on every tick) and bank_counterparties.
  • Docs and .env.example updated.

Tests

  • 63 tests in src/lib/banking pass (21 new for the service, 3 new for the Column counterparty call). tsc --noEmit exits 0.
  • The service tests run against an in-memory store that enforces the same uniqueness as the database, covering: insert-before-originate ordering, key replay, cross-merchant key refusal, provider rejection, a staged race, the settle/hold/complete lifecycle, a late return after completion, the sixty-day cutoff, orphan re-submission under the same key, and per-row error isolation.

Not in this PR

  • A Column production account. Column onboards the originating entity (KYB) and issues the bank account COLUMN_BANK_ACCOUNT_ID names. Until both variables are on Railway, /api/banking reports enabled: false and no money can move.
  • A live sandbox run. The sandbox key from the banking: Column ACH adapter, written against a live sandbox #318 session was not available to this job.
  • Webhooks or email on a return. The sweep exposes an onTransition hook and the cron logs transitions; nothing downstream consumes bank transfers yet.

🤖 Generated with Claude Code

… and page

The Column adapter merged in #318 had no importer: no route, cron or page
called getActiveBankProvider(), the bank_transfers migration was never
applied to prod, and Railway carried no COLUMN_* variables. This adds the
caller and lands the schema.

- src/lib/banking/service.ts: linkBankAccount, originateTransfer and
  sweepBankTransfers. A transfer row is inserted as `initiated` with its
  idempotency key BEFORE the originator is called, so the unique index is
  what stops two racing requests originating twice. A provider rejection
  marks the row failed with the reason; an interrupted origination is
  re-submitted by the sweep under the same key, which Column honours by
  returning the original.
- The sweep starts a hold at settlement (BANK_TRANSFER_HOLD_DAYS, default
  5), completes after it, and re-checks completed transfers daily for sixty
  days so a late administrative return is recorded as a return. A failed or
  canceled word from the provider never un-settles moved money.
- Counterparties: createCounterparty on the provider interface, the Column
  POST /counterparties call (lowercase account_type, verified shape), and a
  bank_counterparties table holding the provider reference, routing number
  and last four. The account number is never stored. Routing numbers are
  checked against the ABA checksum before any provider call.
- Routes under /api/banking: rail status, accounts (list/link/remove),
  transfers (list/originate/get). idempotencyKey is required, or an
  Idempotency-Key header.
- /banking page, linked from both header menus. Direction is worded from
  CoinPay's point of view. The idempotency key is minted when the form
  opens, so a double submit replays rather than re-originates.
- The sweep runs from /api/cron/monitor-payments and reports `skipped` when
  no originator is configured.
- Migrations: bank_transfers (from #314, never applied), ach_payin_hold
  (from #313, never applied; the cron queried the missing column every
  tick) and bank_counterparties were applied to prod today.
- docs/BANK-TRANSFERS.md and .env.example updated; the doc's Status section
  had still said the adapter was the next step.

Still needed before money moves: a Column production entity and bank
account, then COLUMN_API_KEY and COLUMN_BANK_ACCOUNT_ID on Railway.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

331 finding(s)

HIGH/CRITICAL: 33 | MEDIUM: 40 | LOW: 258

Severity Rule Location
HIGH secret-private-key .env.example:236
HIGH secret-generic-api-key docs/API.md:430
HIGH secret-generic-api-key docs/API.md:585
HIGH secret-generic-credential docs/FIX_VERIFY_SIGNATURE.md:156
HIGH secret-generic-credential docs/integration-examples/nodejs-bot.md:225
HIGH secret-generic-api-key docs/sdk/getting-started.md:36
HIGH secret-generic-api-key docs/sdk/getting-started.md:318
HIGH secret-generic-credential packages/extension/scripts/make-screenshots.mjs:283
HIGH secret-generic-api-key packages/sdk/README.md:99
HIGH secret-generic-credential packages/sdk/README.md:122
HIGH secret-generic-credential packages/sdk/README.md:848
HIGH sh-remote-script-execution public/install.sh:167
HIGH sh-remote-script-execution public/install.sh:407
HIGH sh-remote-script-execution public/install.sh:412
HIGH sh-remote-script-execution public/install.sh:416
HIGH sh-remote-script-execution public/install.sh:761
HIGH sh-remote-script-execution public/install.sh:762
HIGH sh-remote-script-execution public/install.sh:802
HIGH sh-remote-script-execution public/install.sh:803
HIGH sh-remote-script-execution public/install.sh:804
HIGH secret-generic-credential scripts/setup-droplet.sh:609
HIGH secret-generic-api-key src/app/docs/sdk/page.tsx:135
HIGH secret-generic-api-key src/app/docs/sdk/page.tsx:214
HIGH secret-generic-credential src/app/docs/sdk/page.tsx:1001
HIGH secret-generic-credential src/app/docs/sdk/page.tsx:1022
HIGH secret-generic-api-key src/app/docs/sdk/page.tsx:1401
HIGH secret-generic-credential src/app/docs/sdk/page.tsx:1482
HIGH secret-generic-credential src/app/docs/sdk/page.tsx:1491
HIGH secret-generic-api-key src/app/docs/sdk/page.tsx:1533
HIGH secret-generic-credential src/components/docs/AuthenticationDocs.tsx:37
HIGH secret-generic-credential src/components/docs/OAuthDocs.tsx:262
HIGH secret-generic-credential supabase/config.toml:255
HIGH secret-generic-credential supabase/config.toml:287
MEDIUM manifest-install-lifecycle-script package.json:28
MEDIUM js-dynamic-code-execution packages/extension/scripts/make-screenshots.mjs:256
MEDIUM js-dynamic-code-execution packages/extension/scripts/make-screenshots.mjs:265
MEDIUM js-shell-exec-interpolation packages/sdk/bin/coinpay.js:49
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-issuer.test.js:23
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-reputation.test.js:23
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:22
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:33
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:48
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:63
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:78
MEDIUM js-shell-exec-interpolation packages/sdk/test/wallet-backup.test.js:82
MEDIUM js-shell-exec-interpolation packages/sdk/test/wallet.test.js:249
MEDIUM js-shell-exec-interpolation packages/sdk/test/wallet.test.js:280
MEDIUM insecure-temp-file public/install.sh:108
MEDIUM sh-unquoted-expansion-destructive public/install.sh:718
MEDIUM js-unescaped-html-sink public/payments.js:93

…and 281 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review September 17, 2026 22:20
@ralyodio
ralyodio merged commit ebd88f8 into master Sep 17, 2026
9 checks passed
@ralyodio
ralyodio deleted the worktree-column-ach-caller branch September 17, 2026 22:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant