banking: pay by bank wherever a payment is taken, and a balance behind payouts - #358
Merged
Merged
Conversation
…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>
ThreatCrush Security Scan331 finding(s) HIGH/CRITICAL: 33 | MEDIUM: 40 | LOW: 258
…and 281 more. Full results in the Security tab. Snippets are redacted; ThreatCrush never prints matched credential material. |
ralyodio
marked this pull request as ready for review
September 18, 2026 14:09
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
payin(buyer paying a payment or invoice),funding(merchant pulling from their own bank) orpayout(merchant paying out). The payer's account is a counterparty with rolepayer: never listed on the merchant's page, never a payout destination.originatePayinin 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.tsdecides what the lifecycle means for the payment or invoice. Nothing at settlement. On completion the payment isconfirmedor the invoicepaidwithsettlement_method: 'ach', and the merchant webhook fires. A return after completion reverses it (paymentfailed, invoice back tosent) 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.…/:id/ach) share one handler: rail configured, USD only, still payable, fraud layer saidallow, rate limited on thepayment_createbucket.balanceFromLedgercounts 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_returnedadded to the webhook event union.bank_payinsapplied to prod (additive columns onbank_transfersandbank_counterparties).Tests
tsc --noEmitexits 0.The exposure this does not remove
Every debit lands in the account
COLUMN_BANK_ACCOUNT_IDnames 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