Skip to content

banking: pay by bank wherever a payment is taken, and a balance behind payouts - #358

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

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

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What this adds

ACH becomes a way to pay wherever a payment is taken. The payment page and the invoice page offer a Pay by Bank tab next to crypto, card and PayPal. The buyer enters their US bank details once; from then on the page shows the debit's progress instead of the form.

  • Transfer kinds. A bank transfer is a payin (buyer paying a payment or invoice), funding (merchant pulling from their own bank) or payout (merchant paying out). The payer's account is a counterparty with role payer: never listed on the merchant's page, never a payout destination.
  • originatePayin in the banking service: one live attempt per payment or invoice, a failed attempt may be retried under a new key, the platform fee is recorded at the merchant's tier.
  • payin.ts decides what the lifecycle means for the payment or invoice. Nothing at settlement. On completion the payment is confirmed or the invoice paid with settlement_method: 'ach', and the merchant webhook fires. A return after completion reverses it (payment failed, invoice back to sent) with a second webhook carrying the return code. Writes are conditional on the current status, so a repeated cron tick cannot confirm or notify twice. Wired into the sweep in the payments cron.
  • Public routes for payments and invoices (…/:id/ach) share one handler: rail configured, USD only, still payable, fraud layer said allow, rate limited on the payment_create bucket.
  • A balance behind payouts. balanceFromLedger counts completed pay-ins and funding in (net of fee), payouts out from origination, and a pay-in returned after completion out again. A payout above the balance is refused with 409. Before this PR a merchant could pay out any amount from the originating account.
  • invoice.payment_returned added to the webhook event union.
  • Migration bank_payins applied to prod (additive columns on bank_transfers and bank_counterparties).
  • Docs: "Paying by bank" and "Balance and payouts" in the bank transfers doc; the Stripe ACH doc is marked as the superseded rail.

Tests

  • 77 in the banking lib (14 new: pay-in origination and retry, payer accounts refused as payout targets, ledger arithmetic, payout refused above balance, pay-in effects and conditional writes).
  • Pay page and invoice page suites green. The availability request is gated behind a pending payment or payable invoice, so the pages' own initial requests keep their order and confirmed or expired ones never ask.
  • tsc --noEmit exits 0.

The exposure this does not remove

Every debit lands in the account COLUMN_BANK_ACCOUNT_ID names and every payout leaves it, so that account holds merchants' money in between. The on-ramp strategy plan calls holding merchant funds money transmission. The way out is Column's platform model, where each merchant is a Column entity with its own account and a pay-in lands there directly. That needs Column to approve the platform structure and a per-merchant KYB flow; neither is built. Nothing here switches the rail on: it stays off until the two Column variables exist.

🤖 Generated with Claude Code

…d payouts

ACH is now a way to pay, not only a merchant's own transfer. The payment page
and the invoice page offer a "Pay by Bank" tab next to crypto, card and
PayPal; the buyer enters their US bank details once and the page shows the
debit's progress instead of the form from then on.

- A transfer now has a kind: payin (a buyer paying a payment or invoice),
  funding (a merchant pulling from their own bank) or payout (a merchant
  paying out to their own bank). The payer's account is a counterparty with
  role 'payer': never listed on the merchant's page, never a payout
  destination.
- originatePayin in the banking service: one live attempt per payment or
  invoice, a failed attempt may be followed by another under a new key, fee
  recorded at the merchant's tier, net_minor is what the merchant keeps.
- payin.ts decides what a pay-in's lifecycle means for the payment or
  invoice: nothing at settlement, confirmed/paid on completion with the
  merchant webhook, and a return after completion reverses it (payment
  failed, invoice back to sent) with a second webhook carrying the return
  code. Writes are conditional on the current status so a repeated cron tick
  cannot confirm or notify twice. Wired into the sweep in the payments cron.
- Public routes for payments and invoices share one handler: rail
  configured, USD only, still payable, fraud layer said allow, rate limited
  on the payment_create bucket.
- A balance ledger behind payouts. balanceFromLedger counts completed
  pay-ins and funding in (net of fee), payouts out from origination, and a
  pay-in returned after completion out again. A payout above the balance is
  refused with 409. Before this a merchant could pay out any amount from the
  originating account.
- invoice.payment_returned added to the webhook event union.
- Migration bank_payins applied to prod (kind, payment_id, invoice_id,
  fee_minor, net_minor, payer_email on bank_transfers; role, payer_email on
  bank_counterparties).
- The bank transfers doc gains "Paying by bank" and "Balance and payouts",
  including where the money sits between debit and payout and why that is
  the exposure until Column's platform model is set up. The Stripe ACH doc
  is marked superseded.

Tests: 77 in the banking lib (14 new); pay page and invoice page suites
green after gating the availability request behind a pending payment.

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 18, 2026 14:09
@ralyodio
ralyodio merged commit c366d7e into master Sep 18, 2026
9 checks passed
ralyodio added a commit that referenced this pull request Sep 18, 2026
…nt (#359)

The payment loader selected a `description` column that payments does not
have (it lives in metadata), so PostgREST returned an error, the loader
returned null and the route answered "Not found" for a real pending payment.
Verified live on prod right after #358 deployed.

- Read description from payments.metadata.
- Load the business with a second plain query in both routes rather than an
  embedded join, so neither depends on relationship resolution.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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