From 1c8d5fee69c131e5be243d14afb15c6e122864ff Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 17 Sep 2026 23:19:06 +0000 Subject: [PATCH] banking: pay by bank wherever a payment is taken, and a balance behind 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 --- docs/ACH.md | 7 +- docs/BANK-TRANSFERS.md | 51 ++++- src/app/api/cron/monitor-payments/route.ts | 10 +- src/app/api/invoices/[id]/ach/route.ts | 42 ++++ src/app/api/payments/[id]/ach/route.ts | 41 ++++ src/app/invoices/[id]/pay/page.tsx | 34 ++- src/app/pay/[id]/page.tsx | 55 ++++- src/components/AchPayForm.tsx | 213 ++++++++++++++++++ src/lib/banking/payin-route.ts | 171 ++++++++++++++ src/lib/banking/payin.test.ts | 123 ++++++++++ src/lib/banking/payin.ts | 176 +++++++++++++++ src/lib/banking/service.test.ts | 104 +++++++++ src/lib/banking/service.ts | 198 +++++++++++++++- src/lib/banking/store.ts | 67 ++++++ src/lib/webhooks/service.ts | 4 +- .../migrations/20260918090000_bank_payins.sql | 38 ++++ 16 files changed, 1324 insertions(+), 10 deletions(-) create mode 100644 src/app/api/invoices/[id]/ach/route.ts create mode 100644 src/app/api/payments/[id]/ach/route.ts create mode 100644 src/components/AchPayForm.tsx create mode 100644 src/lib/banking/payin-route.ts create mode 100644 src/lib/banking/payin.test.ts create mode 100644 src/lib/banking/payin.ts create mode 100644 supabase/migrations/20260918090000_bank_payins.sql diff --git a/docs/ACH.md b/docs/ACH.md index d6d4beff..3c2d305d 100644 --- a/docs/ACH.md +++ b/docs/ACH.md @@ -1,4 +1,9 @@ -# ACH pay-in +# ACH pay-in (Stripe rail, superseded) + +> Stripe access was lost on 2026-09-06, so this rail cannot be used. ACH as a +> way to pay now runs on Column through `src/lib/banking`; see +> `docs/BANK-TRANSFERS.md`, "Paying by bank". This page is kept for the hold +> reasoning, which the Column pay-in reuses. Buyers pay from a US bank account instead of a card. Stripe's `us_bank_account` method on Checkout, on the existing Connect destination-charge flow, with a hold diff --git a/docs/BANK-TRANSFERS.md b/docs/BANK-TRANSFERS.md index 6795941c..0148a444 100644 --- a/docs/BANK-TRANSFERS.md +++ b/docs/BANK-TRANSFERS.md @@ -113,6 +113,52 @@ Direction is always from CoinPay's point of view: `debit` pulls from the user's bank into us, `credit` pays out to them. `/banking` is the merchant page over these routes. +## Paying by bank, wherever a payment is taken + +ACH is offered as a way to pay on the payment page and the invoice page, next +to crypto, card and PayPal. The buyer enters the name on the account, routing +number, account number and type; the routing number is checked against the ABA +checksum, the fraud layer must say `allow`, and the charge must be in USD. + +A pay-in is a bank transfer of kind `payin` tied to `payment_id` or +`invoice_id`, with the platform fee recorded at the merchant's tier. The +payer's account becomes a counterparty with role `payer`: it is never listed +on the merchant's page and can never be a payout destination. + +**A submitted debit is not a paid invoice.** Nothing happens to the payment or +invoice until the transfer is `completed` (settled and past the hold). Then the +payment is `confirmed` or the invoice `paid` with `settlement_method: 'ach'`, +and the merchant webhook fires (`payment.confirmed` / `invoice.paid`). A return +that lands after completion reverses it: the payment becomes `failed`, the +invoice goes back to `sent`, and the merchant is told again +(`payment.failed` / `invoice.payment_returned`) with the return code. Both +writes are conditional on the current status, so a repeated cron tick cannot +confirm or notify twice. See `src/lib/banking/payin.ts`. + +| Route | Purpose | +|---|---| +| `GET/POST /api/payments/:id/ach` | Is bank payment offered; start one; poll it | +| `GET/POST /api/invoices/:id/ach` | The same for an invoice | + +## Balance and payouts + +`balanceFromLedger` in `service.ts` is what a merchant may pay out: completed +pay-ins and funding count in (net of fee), payouts count out from the moment +they are originated, and a pay-in returned after completion counts out again. +A payout above the balance is refused with 409. Two concurrent payouts can +both pass the check; the ledger then goes negative and the next is refused, +which is the accepted bound until a reservation exists. + +**Where the money sits.** Every debit lands in the account +`COLUMN_BANK_ACCOUNT_ID` names and every payout leaves it, so that account +holds merchants' money between the two. `plans/fiat-onramp-strategy.md` is +explicit that holding merchant funds is 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 of which is built. +Until then this is the exposure, and it is the reason the rail is not switched +on by a config value alone. + ## Configuration | Variable | Purpose | @@ -133,8 +179,9 @@ so the handling code is tested from the start. ## Status -Domain, registry, stub, Column adapter, the caller, routes, sweep, page and -tests are in. What is not: a Column production account. Column onboards the +Domain, registry, stub, Column adapter, the caller, routes, sweep, page, +pay-by-bank on the payment and invoice pages, the balance ledger and tests +are in. What is not: a Column production account. Column onboards the originating entity (KYB) and issues the bank account that `COLUMN_BANK_ACCOUNT_ID` names; until both env vars are set, `getActiveBankProvider()` returns null, `/api/banking` reports diff --git a/src/app/api/cron/monitor-payments/route.ts b/src/app/api/cron/monitor-payments/route.ts index 5f29aae8..58ca895f 100644 --- a/src/app/api/cron/monitor-payments/route.ts +++ b/src/app/api/cron/monitor-payments/route.ts @@ -26,6 +26,7 @@ import { releaseExpiredAchHolds } from '@/lib/payments/ach-hold'; import { sweepBankTransfers } from '@/lib/banking/service'; import { SupabaseBankStore } from '@/lib/banking/store'; import { bankTransfersEnabled } from '@/lib/banking/providers'; +import { applyPayinTransition } from '@/lib/banking/payin'; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; @@ -154,8 +155,13 @@ export async function GET(request: NextRequest) { // such, when no originator is configured. const bankTransferSweep = bankTransfersEnabled() ? await sweepBankTransfers({ store: new SupabaseBankStore(supabase as never) }, now, { - onTransition: (before, after) => { - console.log('[banking] transfer', after.id, `${before.status} -> ${after.status}`, after.return_code ?? ''); + onTransition: async (before, after) => { + console.log('[banking] transfer', after.id, after.kind, `${before.status} -> ${after.status}`, after.return_code ?? ''); + // A completed pay-in marks its payment or invoice paid and tells + // the merchant; a return after completion reverses it. Nothing + // happens at settlement, which is the point of the hold. + const effect = await applyPayinTransition(supabase as never, before, after); + if (effect) console.log('[banking] payin', after.id, effect, after.payment_id ?? after.invoice_id); }, }) : { skipped: true }; diff --git a/src/app/api/invoices/[id]/ach/route.ts b/src/app/api/invoices/[id]/ach/route.ts new file mode 100644 index 00000000..97100c2e --- /dev/null +++ b/src/app/api/invoices/[id]/ach/route.ts @@ -0,0 +1,42 @@ +import { NextRequest } from 'next/server'; +import { getSupabaseAdmin } from '@/lib/supabase/server'; +import { handlePayinCreate, handlePayinStatus, type PayinTarget } from '@/lib/banking/payin-route'; + +export const dynamic = 'force-dynamic'; + +/** + * /api/invoices/[id]/ach — pay an invoice from a US bank account. + * + * Public, like /api/invoices/[id]/pay: an invoice link is the credential. + * Payable while the invoice is sent or overdue, the same set the pay page + * accepts, and never once it is paid. + */ +async function loadInvoice(id: string): Promise { + const { data } = await getSupabaseAdmin() + .from('invoices') + .select('id, business_id, amount, currency, status, invoice_number, businesses (merchant_id)') + .eq('id', id) + .maybeSingle(); + if (!data || !data.business_id) return null; + const business = data.businesses as unknown as { merchant_id: string } | null; + if (!business?.merchant_id) return null; + return { + invoiceId: data.id, + businessId: data.business_id, + merchantId: business.merchant_id, + amount: data.amount, + currency: data.currency || 'USD', + payable: data.status === 'sent' || data.status === 'overdue', + description: data.invoice_number ? `Invoice ${data.invoice_number}` : null, + }; +} + +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return handlePayinStatus(id, loadInvoice); +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return handlePayinCreate(req, id, loadInvoice, 'invoices/ach'); +} diff --git a/src/app/api/payments/[id]/ach/route.ts b/src/app/api/payments/[id]/ach/route.ts new file mode 100644 index 00000000..6f044f03 --- /dev/null +++ b/src/app/api/payments/[id]/ach/route.ts @@ -0,0 +1,41 @@ +import { NextRequest } from 'next/server'; +import { getSupabaseAdmin } from '@/lib/supabase/server'; +import { handlePayinCreate, handlePayinStatus, type PayinTarget } from '@/lib/banking/payin-route'; + +export const dynamic = 'force-dynamic'; + +/** + * /api/payments/[id]/ach — pay a payment from a US bank account. + * + * Public, like the payment page itself: the id is the credential, exactly as + * it is for the crypto address and the Stripe checkout link on the same page. + */ +async function loadPayment(id: string): Promise { + const { data } = await getSupabaseAdmin() + .from('payments') + .select('id, business_id, amount, currency, status, description, businesses (merchant_id)') + .eq('id', id) + .maybeSingle(); + if (!data || !data.business_id) return null; + const business = data.businesses as unknown as { merchant_id: string } | null; + if (!business?.merchant_id) return null; + return { + paymentId: data.id, + businessId: data.business_id, + merchantId: business.merchant_id, + amount: data.amount, + currency: data.currency || 'USD', + payable: data.status === 'pending', + description: data.description, + }; +} + +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return handlePayinStatus(id, loadPayment); +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return handlePayinCreate(req, id, loadPayment, 'payments/ach'); +} diff --git a/src/app/invoices/[id]/pay/page.tsx b/src/app/invoices/[id]/pay/page.tsx index 307fba94..bff336c6 100644 --- a/src/app/invoices/[id]/pay/page.tsx +++ b/src/app/invoices/[id]/pay/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useParams } from 'next/navigation'; import Link from 'next/link'; +import AchPayForm, { useAchAvailability } from '@/components/AchPayForm'; // 'crypto' | 'card' | 'paypal' | a manual method_id (e.g. 'zelle'). type PaymentTab = string; @@ -38,6 +39,12 @@ export default function InvoicePayPage() { const [invoice, setInvoice] = useState(null); const [loading, setLoading] = useState(true); + // Whether this invoice may be paid from a US bank account (server-decided). + // Asked only once a payable invoice is on screen, after the page's own loads. + const ach = useAchAvailability( + `/api/invoices/${invoiceId}/ach`, + !loading && !!invoice && ['sent', 'overdue'].includes(invoice.status), + ); const [error, setError] = useState(''); const [copiedField, setCopiedField] = useState(null); const [activeTab, setActiveTab] = useState('crypto'); @@ -89,6 +96,7 @@ export default function InvoicePayPage() { if (data.invoice.stripe_checkout_url) setActiveTab('card'); else if (data.invoice.paypal_enabled) setActiveTab('paypal'); else if (data.invoice.manual_methods?.length) setActiveTab(data.invoice.manual_methods[0].method_id); + else setActiveTab('bank'); } if (['paid', 'cancelled'].includes(data.invoice.status)) { if (pollRef.current) clearInterval(pollRef.current); @@ -204,7 +212,8 @@ export default function InvoicePayPage() { const isPaid = invoice.status === 'paid'; const isOverdue = invoice.status === 'overdue'; const isPending = ['sent', 'overdue'].includes(invoice.status); - const methodCount = [hasCryptoOption, hasCardOption, hasPaypalOption].filter(Boolean).length + manualMethods.length; + const hasBankOption = !!ach.status?.available; + const methodCount = [hasCryptoOption, hasCardOption, hasPaypalOption].filter(Boolean).length + manualMethods.length + (hasBankOption ? 1 : 0); const showTabs = methodCount > 1 && isPending; return ( @@ -323,6 +332,19 @@ export default function InvoicePayPage() { {m.display_name} ))} + {hasBankOption && ( + + )} )} @@ -386,6 +408,16 @@ export default function InvoicePayPage() { )} + {/* === BANK TAB === */} + {activeTab === 'bank' && hasBankOption && isPending && ( +
+ +
+ )} + {/* === PAYPAL TAB === */} {activeTab === 'paypal' && hasPaypalOption && isPending && (
diff --git a/src/app/pay/[id]/page.tsx b/src/app/pay/[id]/page.tsx index 71dea727..d04ca886 100644 --- a/src/app/pay/[id]/page.tsx +++ b/src/app/pay/[id]/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useParams } from 'next/navigation'; import Link from 'next/link'; +import AchPayForm, { useAchAvailability } from '@/components/AchPayForm'; const PAYMENT_EXPIRY_MINUTES = 15; const POLL_INTERVAL_MS = 5000; // Poll every 5 seconds @@ -39,7 +40,7 @@ interface Business { name: string; } -type PaymentTab = 'crypto' | 'card'; +type PaymentTab = 'crypto' | 'card' | 'bank'; // Get blockchain explorer URL for a transaction const getExplorerUrl = (blockchain: string, txHash: string): string => { @@ -106,6 +107,9 @@ export default function PublicPaymentPage() { const [copiedField, setCopiedField] = useState(null); const [timeRemaining, setTimeRemaining] = useState(0); const [paymentStatus, setPaymentStatus] = useState('pending'); + // Whether this payment may be paid from a US bank account (server-decided). + // Asked only once a pending payment is on screen, after the page's own loads. + const ach = useAchAvailability(`/api/payments/${paymentId}/ach`, !loading && paymentStatus === 'pending'); const [qrLoaded, setQrLoaded] = useState(false); const [qrError, setQrError] = useState(false); const [activeTab, setActiveTab] = useState('crypto'); @@ -116,6 +120,7 @@ export default function PublicPaymentPage() { const hasCardOption = !!(payment?.metadata?.stripe_checkout_url); const hasCryptoOption = !!(payment?.payment_address); + const hasBankOption = !!ach.status?.available; const copyToClipboard = async (text: string, field: string) => { try { @@ -257,6 +262,8 @@ export default function PublicPaymentPage() { const hasCrypto = !!data.payment.payment_address; if (!hasCrypto && hasStripe) { setActiveTab('card'); + } else if (!hasCrypto && !hasStripe) { + setActiveTab('bank'); } // Calculate initial time remaining @@ -435,7 +442,7 @@ export default function PublicPaymentPage() { const isTimerUrgent = timeRemaining > 0 && timeRemaining < 300; // < 5 minutes // Whether to show tabs - const showTabs = hasCryptoOption && hasCardOption && isPaymentPending; + const showTabs = isPaymentPending && [hasCryptoOption, hasCardOption, hasBankOption].filter(Boolean).length > 1; return (
@@ -528,6 +535,7 @@ export default function PublicPaymentPage() { {/* Payment Method Tabs */} {showTabs && (
+ {hasCryptoOption && ( + )} + {hasCardOption && ( + )} + {hasBankOption && ( + + )}
)} @@ -611,6 +640,28 @@ export default function PublicPaymentPage() { )}
+ {/* === BANK TAB === */} + {activeTab === 'bank' && hasBankOption && isPaymentPending && ( +
+
+

+ ${payment.amount ? parseFloat(payment.amount).toFixed(2) : 'N/A'} +

+

USD via ACH bank transfer

+
+ + {payment.description && ( +
+ +

{payment.description}

+
+ )} +
+ )} + {/* === CARD TAB === */} {activeTab === 'card' && hasCardOption && isPaymentPending && (
diff --git a/src/components/AchPayForm.tsx b/src/components/AchPayForm.tsx new file mode 100644 index 00000000..afb8ec53 --- /dev/null +++ b/src/components/AchPayForm.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +/** + * Pay by bank: the buyer-facing ACH form, shared by the payment page and the + * invoice page. + * + * It asks the route whether bank payment is offered here, shows the form + * while it is, and once a debit is in flight shows its progress instead. The + * form disappears after submission: a second submission would be a second + * debit, and the server would refuse it, but a page that never offers it is + * the safer shape. + */ + +type PayinTransfer = { + id: string; + status: 'initiated' | 'pending' | 'settled' | 'completed' | 'returned' | 'failed' | 'canceled'; + amountMinor: number; + currency: string; + createdAt: string; + settledAt: string | null; + holdUntil: string | null; + completedAt: string | null; + returnedAt: string | null; + returnCode: string | null; + error: string | null; +}; + +type Status = { + available: boolean; + payable: boolean; + holdDays: number; + transfer: PayinTransfer | null; +}; + +/** + * Ask the route whether bank payment is offered. `enabled` gates the request: + * the pages turn it on only once they hold a payable payment or invoice, so a + * confirmed or expired one never asks, and the page's own initial requests go + * out first. + */ +export function useAchAvailability(endpoint: string, enabled = true) { + const [status, setStatus] = useState(null); + const refresh = useCallback(async () => { + try { + const res = await fetch(endpoint, { cache: 'no-store' }); + if (!res.ok) { + setStatus({ available: false, payable: false, holdDays: 5, transfer: null }); + return; + } + setStatus(await res.json()); + } catch { + setStatus({ available: false, payable: false, holdDays: 5, transfer: null }); + } + }, [endpoint]); + useEffect(() => { + if (enabled) void refresh(); + }, [refresh, enabled]); + return { status, refresh }; +} + +const PROGRESS: Record = { + initiated: 'Your bank payment has been submitted.', + pending: 'Your bank payment is on its way through the ACH network.', + settled: 'Your bank has sent the funds. The payment is being held briefly before it is confirmed.', + completed: 'Your bank payment is confirmed.', + returned: 'Your bank returned this payment.', + failed: 'Your bank payment could not be started.', + canceled: 'This bank payment was canceled.', +}; + +export default function AchPayForm({ + endpoint, + amountLabel, + onSubmitted, +}: { + /** The /ach route for this payment or invoice. */ + endpoint: string; + /** Formatted amount, shown on the button. */ + amountLabel: string; + /** Called when a debit is on record, so the page can start polling. */ + onSubmitted?: (transfer: PayinTransfer) => void; +}) { + const { status, refresh } = useAchAvailability(endpoint, true); + const [holderName, setHolderName] = useState(''); + const [routingNumber, setRoutingNumber] = useState(''); + const [accountNumber, setAccountNumber] = useState(''); + const [accountType, setAccountType] = useState<'checking' | 'savings'>('checking'); + const [email, setEmail] = useState(''); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setSending(true); + setError(null); + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ holderName, routingNumber, accountNumber, accountType, email: email || undefined }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + setError(typeof body?.error === 'string' ? body.error : 'Could not start the bank payment.'); + return; + } + setAccountNumber(''); + await refresh(); + if (body?.transfer) onSubmitted?.(body.transfer); + } catch { + setError('Could not start the bank payment.'); + } finally { + setSending(false); + } + } + + if (!status) return

Checking bank payment…

; + if (!status.available) { + return

Bank payment is not available for this order.

; + } + + const t = status.transfer; + if (t && t.status !== 'failed' && t.status !== 'canceled') { + return ( +
+
+ {PROGRESS[t.status]} + {t.status === 'returned' && t.returnCode ? ` (${t.returnCode})` : ''} +
+ {(t.status === 'initiated' || t.status === 'pending' || t.status === 'settled') && ( +

+ ACH takes a few business days. The merchant is told once the funds have settled and cleared a{' '} + {status.holdDays}-day hold. You can close this page. +

+ )} +
+ ); + } + + return ( +
+ {t?.status === 'failed' && ( +
+ {PROGRESS.failed} {t.error ? `(${t.error})` : ''} You can try again with a different account. +
+ )} + setHolderName(e.target.value)} + placeholder="Name on the account" + required + className="w-full rounded-xl border border-gray-600 bg-gray-900 px-4 py-3 text-white placeholder:text-gray-500" + /> +
+ setRoutingNumber(e.target.value.replace(/\D/g, '').slice(0, 9))} + placeholder="Routing number" + inputMode="numeric" + required + className="flex-1 rounded-xl border border-gray-600 bg-gray-900 px-4 py-3 text-white placeholder:text-gray-500" + /> + +
+ setAccountNumber(e.target.value.replace(/\D/g, '').slice(0, 17))} + placeholder="Account number" + inputMode="numeric" + autoComplete="off" + required + className="w-full rounded-xl border border-gray-600 bg-gray-900 px-4 py-3 text-white placeholder:text-gray-500" + /> + setEmail(e.target.value)} + placeholder="Email for your receipt (optional)" + type="email" + className="w-full rounded-xl border border-gray-600 bg-gray-900 px-4 py-3 text-white placeholder:text-gray-500" + /> + {error &&

{error}

} + +

+ By continuing you authorize a one-time ACH debit of {amountLabel} from this account. Your account number is + sent to our bank partner and is not stored by CoinPay. +

+
+ ); +} diff --git a/src/lib/banking/payin-route.ts b/src/lib/banking/payin-route.ts new file mode 100644 index 00000000..55325988 --- /dev/null +++ b/src/lib/banking/payin-route.ts @@ -0,0 +1,171 @@ +/** + * The public "pay by bank" handler shared by payments and invoices. + * + * Both surfaces need the same thing: say whether ACH is offered here, accept a + * buyer's bank details once, and report the pay-in's progress while the page + * polls. What differs is where the amount, currency and business come from, + * so the caller passes a loader and this file does the rest. + * + * Who is offered ACH: the rail is configured, the charge is in USD, the + * payment or invoice is still payable, and the fraud layer said `allow`. A + * `verify` decision means 3-D Secure on the card rail; there is no equivalent + * for a bank debit, so a flagged buyer is not handed the one rail where the + * merchant carries the loss. + */ + +import 'server-only'; +import { NextRequest, NextResponse } from 'next/server'; +import { getSupabaseAdmin } from '@/lib/supabase/server'; +import { screenCheckout } from '@/lib/fraud/screen'; +import { getClientIp, getRateLimitKey } from '@/lib/web-wallet/client-ip'; +import { checkRateLimit } from '@/lib/web-wallet/rate-limit'; +import { isBusinessPaidTier } from '@/lib/entitlements/service'; +import { getFeePercentage } from '@/lib/payments/fees'; +import { bankTransfersEnabled, getActiveBankProvider } from './providers'; +import { SupabaseBankStore } from './store'; +import { BankTransferError, holdDays, originatePayin, payerTransferView } from './service'; + +export interface PayinTarget { + /** Exactly one of these. */ + paymentId?: string; + invoiceId?: string; + businessId: string; + merchantId: string; + /** Major units as stored ("12.50"), converted to minor here. */ + amount: string | number; + currency: string; + /** Whether the buyer may still pay. */ + payable: boolean; + description?: string | null; +} + +export type PayinTargetLoader = (id: string) => Promise; + +const NO_STORE = { headers: { 'Cache-Control': 'no-store' } }; + +function toMinor(amount: string | number): number { + return Math.round(Number(amount) * 100); +} + +/** GET: is bank payment offered here, and where is the current attempt. */ +export async function handlePayinStatus(id: string, load: PayinTargetLoader): Promise { + const target = await load(id); + if (!target) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + const provider = getActiveBankProvider(); + const store = new SupabaseBankStore(getSupabaseAdmin()); + const attempts = await store.listPayinsFor({ paymentId: target.paymentId, invoiceId: target.invoiceId }); + const current = attempts.find((row) => row.status !== 'failed' && row.status !== 'canceled') ?? attempts[0] ?? null; + + const available = + provider !== null && target.currency.toUpperCase() === 'USD' && provider.currencies.includes('USD'); + + return NextResponse.json( + { + available, + payable: target.payable, + holdDays: holdDays(), + transfer: current ? payerTransferView(current) : null, + }, + NO_STORE, + ); +} + +/** POST: originate the debit from the buyer's bank. */ +export async function handlePayinCreate( + req: NextRequest, + id: string, + load: PayinTargetLoader, + context: string, +): Promise { + if (!bankTransfersEnabled()) { + return NextResponse.json({ error: 'Bank payments are not enabled' }, { status: 404 }); + } + + // The same bucket the other public payment routes use: a form that creates + // a bank debit must not be a free way to hammer the originator. + const limit = checkRateLimit(getRateLimitKey(req, 'ach-payin'), 'payment_create'); + if (!limit.allowed) { + return NextResponse.json({ error: 'Too many attempts. Try again shortly.' }, { status: 429 }); + } + + const target = await load(id); + if (!target) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + if (!target.payable) return NextResponse.json({ error: 'This is no longer payable' }, { status: 409 }); + if (target.currency.toUpperCase() !== 'USD') { + return NextResponse.json({ error: 'Bank payment is available for USD only' }, { status: 400 }); + } + + let body: Record; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Expected a JSON body' }, { status: 400 }); + } + const str = (v: unknown) => (typeof v === 'string' ? v.trim() : ''); + const accountType = str(body.accountType) || 'checking'; + if (accountType !== 'checking' && accountType !== 'savings') { + return NextResponse.json({ error: 'accountType must be checking or savings' }, { status: 400 }); + } + const email = str(body.email) || null; + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return NextResponse.json({ error: 'email is not valid' }, { status: 400 }); + } + + const supabase = getSupabaseAdmin(); + const screening = await screenCheckout(supabase, { + businessId: target.businessId, + email, + ip: getClientIp(req), + amount: toMinor(target.amount), + currency: 'usd', + description: target.description ?? undefined, + }); + if (screening.decision !== 'allow') { + console.warn('[banking] payin refused by screening', { + businessId: target.businessId, + decision: screening.decision, + score: screening.score, + }); + return NextResponse.json( + { error: screening.decision === 'block' ? screening.buyerMessage : 'Bank payment is not available for this order. Please pay another way.' }, + { status: 403 }, + ); + } + + const amountMinor = toMinor(target.amount); + const feeMinor = Math.round(amountMinor * getFeePercentage(await isBusinessPaidTier(supabase, target.businessId))); + + try { + const row = await originatePayin( + { + paymentId: target.paymentId ?? null, + invoiceId: target.invoiceId ?? null, + merchantId: target.merchantId, + businessId: target.businessId, + amountMinor, + currency: 'USD', + feeMinor, + description: target.description ?? null, + payer: { + holderName: str(body.holderName), + routingNumber: str(body.routingNumber), + accountNumber: str(body.accountNumber), + accountType, + email, + }, + }, + { store: new SupabaseBankStore(supabase) }, + ); + return NextResponse.json( + { transfer: payerTransferView(row), holdDays: holdDays() }, + { status: row.status === 'failed' ? 200 : 201, ...NO_STORE }, + ); + } catch (err) { + if (err instanceof BankTransferError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + console.error(`[${context}] payin failed`, err); + return NextResponse.json({ error: 'Could not start the bank payment' }, { status: 502 }); + } +} diff --git a/src/lib/banking/payin.test.ts b/src/lib/banking/payin.test.ts new file mode 100644 index 00000000..bd18c067 --- /dev/null +++ b/src/lib/banking/payin.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { payinEffectFor, applyPayinTransition } from './payin'; +import type { BankTransferRow } from './store'; + +vi.mock('@/lib/webhooks/service', () => ({ + sendPaymentWebhook: vi.fn().mockResolvedValue({ success: true }), +})); + +import { sendPaymentWebhook } from '@/lib/webhooks/service'; + +const base: BankTransferRow = { + id: 'btx_1', + merchant_id: 'm', + business_id: 'b', + provider: 'column', + provider_transfer_id: 'acht_1', + direction: 'debit', + kind: 'payin', + payment_id: 'pay_1', + invoice_id: null, + payer_email: null, + amount_minor: 10_000, + fee_minor: 100, + net_minor: 9_900, + currency: 'USD', + status: 'settled', + provider_status: 'SETTLED', + return_code: null, + counterparty_id: 'cpty_1', + bank_counterparty_id: 'bcp_1', + description: null, + idempotency_key: 'payin:payment:pay_1:1', + created_at: '2026-09-10T00:00:00.000Z', + settled_at: '2026-09-12T00:00:00.000Z', + hold_until: '2026-09-17T00:00:00.000Z', + completed_at: null, + returned_at: null, + last_polled_at: null, + last_error: null, + updated_at: '2026-09-12T00:00:00.000Z', +}; + +describe('payinEffectFor', () => { + it('confirms only on completion, never at settlement', () => { + expect(payinEffectFor({ ...base, status: 'pending' }, base)).toBeNull(); + expect(payinEffectFor(base, { ...base, status: 'completed', completed_at: 'x' })).toBe('confirm'); + }); + + it('reverses a return that lands after completion, and ignores one before it', () => { + const completed = { ...base, status: 'completed' as const, completed_at: '2026-09-17T00:00:00.000Z' }; + expect(payinEffectFor(completed, { ...completed, status: 'returned', return_code: 'R10' })).toBe('reverse'); + // Returned before ever completing: the payment was never confirmed, nothing to reverse. + expect(payinEffectFor(base, { ...base, status: 'returned' })).toBeNull(); + }); + + it('is idempotent across repeated ticks and ignores other kinds', () => { + const completed = { ...base, status: 'completed' as const, completed_at: 'x' }; + expect(payinEffectFor(completed, completed)).toBeNull(); + expect(payinEffectFor({ ...base, kind: 'funding' }, { ...base, kind: 'funding', status: 'completed', completed_at: 'x' })).toBeNull(); + }); +}); + +describe('applyPayinTransition', () => { + function fakeSupabase(row: Record | null, updatedRows: unknown[] = [{ id: 'x' }]) { + const calls: { table: string; op: string; args: unknown[] }[] = []; + const chain = (table: string) => { + const self: Record = {}; + const record = (op: string) => (...args: unknown[]) => { + calls.push({ table, op, args }); + return self; + }; + for (const op of ['select', 'eq', 'neq', 'update']) self[op] = record(op); + self.maybeSingle = async () => ({ data: row }); + // `.select('id')` after an update resolves the update; make the chain thenable. + self.then = (resolve: (v: unknown) => void) => resolve({ data: updatedRows }); + return self; + }; + return { client: { from: (table: string) => chain(table) }, calls }; + } + + beforeEach(() => { + vi.mocked(sendPaymentWebhook).mockClear(); + }); + + it('marks the payment confirmed and tells the merchant on completion', async () => { + const { client, calls } = fakeSupabase({ id: 'pay_1', business_id: 'b', amount: '100.00', currency: 'USD', metadata: {}, status: 'pending' }); + const after = { ...base, status: 'completed' as const, completed_at: '2026-09-17T00:00:00.000Z' }; + const effect = await applyPayinTransition(client as never, base, after); + expect(effect).toBe('confirm'); + const update = calls.find((c) => c.table === 'payments' && c.op === 'update'); + expect(update?.args[0]).toMatchObject({ status: 'confirmed' }); + // Conditional on the current status, so a repeated tick cannot confirm twice. + expect(calls.some((c) => c.op === 'eq' && c.args[0] === 'status' && c.args[1] === 'pending')).toBe(true); + expect(sendPaymentWebhook).toHaveBeenCalledWith(expect.anything(), 'b', 'pay_1', 'payment.confirmed', expect.objectContaining({ status: 'confirmed' })); + }); + + it('does not notify when the conditional update matched nothing', async () => { + const { client } = fakeSupabase({ id: 'pay_1', business_id: 'b', amount: '100.00', currency: 'USD', metadata: {}, status: 'confirmed' }, []); + const after = { ...base, status: 'completed' as const, completed_at: 'x' }; + await applyPayinTransition(client as never, base, after); + expect(sendPaymentWebhook).not.toHaveBeenCalled(); + }); + + it('fails the payment and tells the merchant on a return after completion', async () => { + const { client, calls } = fakeSupabase({ id: 'pay_1', business_id: 'b', amount: '100.00', currency: 'USD', metadata: {}, status: 'confirmed' }); + const completed = { ...base, status: 'completed' as const, completed_at: 'x' }; + const returned = { ...completed, status: 'returned' as const, return_code: 'R10', returned_at: 'y' }; + expect(await applyPayinTransition(client as never, completed, returned)).toBe('reverse'); + const update = calls.find((c) => c.table === 'payments' && c.op === 'update'); + expect(update?.args[0]).toMatchObject({ status: 'failed' }); + expect(sendPaymentWebhook).toHaveBeenCalledWith(expect.anything(), 'b', 'pay_1', 'payment.failed', expect.objectContaining({ error: expect.stringContaining('R10') })); + }); + + it('marks an invoice paid with settlement_method ach', async () => { + const { client, calls } = fakeSupabase({ id: 'inv_1', business_id: 'b', invoice_number: 'INV-1', amount: '100.00', currency: 'USD', metadata: {}, status: 'sent' }); + const invoiceRow = { ...base, payment_id: null, invoice_id: 'inv_1' }; + const after = { ...invoiceRow, status: 'completed' as const, completed_at: 'x' }; + await applyPayinTransition(client as never, invoiceRow, after); + const update = calls.find((c) => c.table === 'invoices' && c.op === 'update'); + expect(update?.args[0]).toMatchObject({ status: 'paid', settlement_method: 'ach' }); + expect(sendPaymentWebhook).toHaveBeenCalledWith(expect.anything(), 'b', 'inv_1', 'invoice.paid', expect.objectContaining({ payment_rail: 'ach' })); + }); +}); diff --git a/src/lib/banking/payin.ts b/src/lib/banking/payin.ts new file mode 100644 index 00000000..f532e132 --- /dev/null +++ b/src/lib/banking/payin.ts @@ -0,0 +1,176 @@ +/** + * What a pay-in's lifecycle means for the payment or invoice it pays. + * + * A submitted ACH debit is not a paid invoice. The payment or invoice is + * marked paid only when the transfer reaches `completed`: settled and past the + * hold. A return after that reverses it, and the merchant is told both times. + * The decision is a pure function so the two moments are pinned by tests; the + * writes are the same ones the Stripe and PayPal rails make. + */ + +import type { SupabaseClient } from '@supabase/supabase-js'; +import { sendPaymentWebhook } from '@/lib/webhooks/service'; +import type { BankTransferRow } from './store'; + +export type PayinEffect = 'confirm' | 'reverse' | null; + +/** + * `confirm` when the transfer just completed; `reverse` when a transfer that + * had completed is now returned. Everything else, including settlement (money + * moved but still returnable), changes nothing for the merchant. + */ +export function payinEffectFor(before: BankTransferRow, after: BankTransferRow): PayinEffect { + if (after.kind !== 'payin') return null; + if (after.status === 'completed' && before.status !== 'completed') return 'confirm'; + if (after.status === 'returned' && before.status !== 'returned' && after.completed_at) return 'reverse'; + return null; +} + +/** + * Apply the effect to the payment or invoice, and notify the merchant. + * + * Both writes are conditional on the row's current status, so a repeated + * transition (two overlapping cron ticks) cannot confirm twice or notify twice. + * The webhook is sent after the write and never rolls it back: the money + * moved either way. + */ +export async function applyPayinTransition( + supabase: SupabaseClient, + before: BankTransferRow, + after: BankTransferRow, +): Promise { + const effect = payinEffectFor(before, after); + if (!effect) return null; + const now = new Date().toISOString(); + + if (after.payment_id) { + const { data: payment } = await supabase + .from('payments') + .select('id, business_id, amount, currency, metadata, status') + .eq('id', after.payment_id) + .maybeSingle(); + if (!payment) return effect; + + if (effect === 'confirm') { + const { data: updated } = await supabase + .from('payments') + .update({ + status: 'confirmed', + confirmed_at: now, + updated_at: now, + metadata: { ...(payment.metadata ?? {}), payment_rail: 'ach', bank_transfer_id: after.id, ach_confirmed_at: now }, + }) + .eq('id', payment.id) + .eq('status', 'pending') + .select('id'); + if (updated && updated.length > 0 && payment.business_id) { + void sendPaymentWebhook(supabase, payment.business_id, payment.id, 'payment.confirmed', { + status: 'confirmed', + amount_usd: payment.amount, + amount_crypto: null, + currency: after.currency.toLowerCase(), + payment_address: null, + tx_hash: after.provider_transfer_id, + confirmations: 1, + metadata: { ...(payment.metadata ?? {}), payment_rail: 'ach', bank_transfer_id: after.id }, + }).catch((err) => console.error('[banking] payin webhook failed', err)); + } + } else { + const { data: updated } = await supabase + .from('payments') + .update({ + status: 'failed', + updated_at: now, + metadata: { + ...(payment.metadata ?? {}), + payment_rail: 'ach', + bank_transfer_id: after.id, + ach_returned_at: after.returned_at ?? now, + ach_return_code: after.return_code, + }, + }) + .eq('id', payment.id) + .eq('status', 'confirmed') + .select('id'); + if (updated && updated.length > 0 && payment.business_id) { + void sendPaymentWebhook(supabase, payment.business_id, payment.id, 'payment.failed', { + status: 'failed', + amount_usd: payment.amount, + currency: after.currency.toLowerCase(), + payment_address: null, + tx_hash: after.provider_transfer_id, + error: `ACH debit returned${after.return_code ? ` (${after.return_code})` : ''} after the payment was confirmed`, + metadata: { ...(payment.metadata ?? {}), payment_rail: 'ach', ach_return_code: after.return_code }, + }).catch((err) => console.error('[banking] payin webhook failed', err)); + } + } + return effect; + } + + if (after.invoice_id) { + const { data: invoice } = await supabase + .from('invoices') + .select('id, business_id, invoice_number, amount, currency, metadata, status') + .eq('id', after.invoice_id) + .maybeSingle(); + if (!invoice) return effect; + + if (effect === 'confirm') { + const { data: updated } = await supabase + .from('invoices') + .update({ + status: 'paid', + paid_at: now, + tx_hash: after.provider_transfer_id, + settlement_method: 'ach', + updated_at: now, + metadata: { ...(invoice.metadata ?? {}), payment_rail: 'ach', bank_transfer_id: after.id, ach_confirmed_at: now }, + }) + .eq('id', invoice.id) + .neq('status', 'paid') + .select('id'); + if (updated && updated.length > 0 && invoice.business_id) { + void sendPaymentWebhook(supabase, invoice.business_id, invoice.id, 'invoice.paid', { + status: 'paid', + amount_usd: invoice.amount, + currency: invoice.currency || after.currency, + invoice_number: invoice.invoice_number, + payment_rail: 'ach', + bank_transfer_id: after.id, + }).catch((err) => console.error('[banking] payin webhook failed', err)); + } + } else { + // The invoice goes back to owed. 'sent' is the status the pay page + // accepts, and the return is recorded on the invoice for the merchant. + const { data: updated } = await supabase + .from('invoices') + .update({ + status: 'sent', + paid_at: null, + updated_at: now, + metadata: { + ...(invoice.metadata ?? {}), + payment_rail: 'ach', + bank_transfer_id: after.id, + ach_returned_at: after.returned_at ?? now, + ach_return_code: after.return_code, + }, + }) + .eq('id', invoice.id) + .eq('status', 'paid') + .select('id'); + if (updated && updated.length > 0 && invoice.business_id) { + void sendPaymentWebhook(supabase, invoice.business_id, invoice.id, 'invoice.payment_returned', { + status: 'sent', + amount_usd: invoice.amount, + currency: invoice.currency || after.currency, + invoice_number: invoice.invoice_number, + payment_rail: 'ach', + return_code: after.return_code, + }).catch((err) => console.error('[banking] payin webhook failed', err)); + } + } + } + + return effect; +} diff --git a/src/lib/banking/service.test.ts b/src/lib/banking/service.test.ts index fbce6ff1..4989a110 100644 --- a/src/lib/banking/service.test.ts +++ b/src/lib/banking/service.test.ts @@ -10,6 +10,9 @@ import { transitionFor, BankTransferError, ORPHAN_AFTER_MS, + originatePayin, + balanceFromLedger, + availableBalanceMinor, } from './service'; import type { BankTransfer, BankTransferProvider } from './types'; @@ -244,6 +247,12 @@ describe('transitionFor', () => { last_polled_at: null, last_error: null, updated_at: '2026-09-10T00:00:00.000Z', + kind: 'funding' as const, + payment_id: null, + invoice_id: null, + payer_email: null, + fee_minor: 0, + net_minor: 100, }; const remote = (status: BankTransfer['status'], extra: Partial = {}): BankTransfer => ({ id: 'stub_txf_1', @@ -371,7 +380,13 @@ describe('sweepBankTransfers', () => { business_id: null, provider: 'stub', direction: 'debit', + kind: 'funding', + payment_id: null, + invoice_id: null, + payer_email: null, amount_minor: 777, + fee_minor: 0, + net_minor: 777, currency: 'USD', counterparty_id: cp.provider_counterparty_id, bank_counterparty_id: cp.id, @@ -424,3 +439,92 @@ describe('sweepBankTransfers', () => { expect(store.transfers.get(b.id)!.last_error).toBeNull(); }); }); + +describe('originatePayin', () => { + const payer = { holderName: 'Grace Hopper', routingNumber: '021000021', accountNumber: '9988776655', accountType: 'checking' as const, email: 'grace@example.com' }; + const input = { paymentId: 'pay_1', merchantId: MERCHANT, businessId: 'biz_1', amountMinor: 10_000, currency: 'USD', feeMinor: 100, payer }; + + it('creates a payer counterparty that never appears in the merchant list', async () => { + const row = await originatePayin(input, deps()); + expect(row.kind).toBe('payin'); + expect(row.payment_id).toBe('pay_1'); + expect(row.net_minor).toBe(9_900); + expect(row.idempotency_key).toBe('payin:payment:pay_1:1'); + expect(JSON.stringify([...store.counterparties.values()])).not.toContain('9988776655'); + expect(await store.listCounterparties(MERCHANT)).toEqual([]); + }); + + it('returns the live attempt instead of debiting twice', async () => { + const first = await originatePayin(input, deps()); + const spy = vi.spyOn(provider, 'createTransfer'); + const second = await originatePayin({ ...input, payer: { ...payer, accountNumber: '1111' } }, deps()); + expect(second.id).toBe(first.id); + expect(spy).not.toHaveBeenCalled(); + }); + + it('allows a fresh attempt after a failed one, under a new key', async () => { + provider.createTransfer = async () => { + throw new Error('rejected'); + }; + const failed = await originatePayin(input, deps()); + expect(failed.status).toBe('failed'); + provider.createTransfer = StubBankProvider.prototype.createTransfer.bind(provider); + const retry = await originatePayin(input, deps()); + expect(retry.id).not.toBe(failed.id); + expect(retry.idempotency_key).toBe('payin:payment:pay_1:2'); + }); + + it('refuses to pay out to a payer account', async () => { + await originatePayin(input, deps()); + const payerRow = [...store.counterparties.values()].find((c) => c.role === 'payer')!; + await expect( + originateTransfer({ merchantId: MERCHANT, bankCounterpartyId: payerRow.id, direction: 'credit', amountMinor: 1, currency: 'USD', idempotencyKey: 'k' }, deps()), + ).rejects.toMatchObject({ status: 404 }); + }); + + it('validates fee and amount', async () => { + await expect(originatePayin({ ...input, feeMinor: 10_000 }, deps())).rejects.toMatchObject({ status: 400 }); + await expect(originatePayin({ ...input, amountMinor: 0 }, deps())).rejects.toMatchObject({ status: 400 }); + await expect(originatePayin({ ...input, paymentId: null, invoiceId: null }, deps())).rejects.toMatchObject({ status: 400 }); + }); +}); + +describe('balance and payouts', () => { + const ledgerRow = (over: Partial[0][number]>) => + ({ kind: 'payin', status: 'completed', amount_minor: 1000, net_minor: 990, completed_at: 'x', ...over }) as Parameters[0][number]; + + it('counts completed payins net of fee, subtracts payouts, and reverses returned payins', () => { + expect(balanceFromLedger([ledgerRow({})])).toBe(990); + expect(balanceFromLedger([ledgerRow({ status: 'settled' })])).toBe(0); + expect(balanceFromLedger([ledgerRow({}), ledgerRow({ kind: 'payout', status: 'initiated', amount_minor: 500 })])).toBe(490); + expect(balanceFromLedger([ledgerRow({}), ledgerRow({ kind: 'payout', status: 'failed', amount_minor: 500 })])).toBe(990); + expect(balanceFromLedger([ledgerRow({ status: 'returned' })])).toBe(-990); + expect(balanceFromLedger([ledgerRow({ status: 'returned', completed_at: null })])).toBe(0); + expect(balanceFromLedger([ledgerRow({ kind: 'funding', net_minor: 1000 })])).toBe(1000); + }); + + it('refuses a payout above the available balance, and allows one within it', async () => { + const cp = await linked(); + await expect( + originateTransfer({ merchantId: MERCHANT, bankCounterpartyId: cp.id, direction: 'credit', amountMinor: 1, currency: 'USD', idempotencyKey: 'p1' }, deps()), + ).rejects.toMatchObject({ status: 409, message: expect.stringContaining('Insufficient') }); + + // A payin completes: settle, then pass the hold. + const payin = await originatePayin( + { paymentId: 'pay_9', merchantId: MERCHANT, businessId: 'biz_1', amountMinor: 10_000, currency: 'USD', feeMinor: 100, payer: { holderName: 'G H', routingNumber: '021000021', accountNumber: '12345678', accountType: 'checking' } }, + deps(), + ); + provider.advance(payin.provider_transfer_id!, 'settled'); + await sweepBankTransfers(deps(), clock); + clock = new Date(clock.getTime() + 6 * 24 * 60 * 60 * 1000); + await sweepBankTransfers(deps(), clock); + expect(await availableBalanceMinor(MERCHANT, store)).toBe(9_900); + + const payout = await originateTransfer({ merchantId: MERCHANT, bankCounterpartyId: cp.id, direction: 'credit', amountMinor: 9_900, currency: 'USD', idempotencyKey: 'p2' }, deps()); + expect(payout.kind).toBe('payout'); + expect(await availableBalanceMinor(MERCHANT, store)).toBe(0); + await expect( + originateTransfer({ merchantId: MERCHANT, bankCounterpartyId: cp.id, direction: 'credit', amountMinor: 1, currency: 'USD', idempotencyKey: 'p3' }, deps()), + ).rejects.toMatchObject({ status: 409 }); + }); +}); diff --git a/src/lib/banking/service.ts b/src/lib/banking/service.ts index 4bb53f58..4286eaef 100644 --- a/src/lib/banking/service.ts +++ b/src/lib/banking/service.ts @@ -21,7 +21,7 @@ */ import { getBankProviders, getActiveBankProvider } from './providers'; -import type { BankStore, BankCounterpartyRow, BankTransferRow, TransferPatch } from './store'; +import type { BankStore, BankCounterpartyRow, BankTransferRow, TransferKind, TransferPatch } from './store'; import { DuplicateIdempotencyKeyError } from './store'; import { BankAccountType, @@ -116,9 +116,37 @@ export async function linkBankAccount( account_type: counterparty.accountType, routing_number: counterparty.routingNumber, account_last4: counterparty.accountLast4, + role: 'merchant', + payer_email: null, }); } +/** + * What a merchant may pay out right now, in minor units. + * + * Payins and funding count once they have completed (settled and past the + * hold), payouts count from the moment they are originated so two payouts + * cannot both spend the same dollar, and a payin that was returned after it + * completed counts against the balance because that money already went out. + */ +export function balanceFromLedger(rows: readonly BankTransferRow[]): number { + let balance = 0; + for (const row of rows) { + if (row.kind === 'payout') { + if (row.status !== 'failed' && row.status !== 'canceled') balance -= row.amount_minor; + continue; + } + // payin or funding + if (row.status === 'completed') balance += row.net_minor; + if (row.status === 'returned' && row.completed_at) balance -= row.net_minor; + } + return balance; +} + +export async function availableBalanceMinor(merchantId: string, store: BankStore): Promise { + return balanceFromLedger(await store.listLedger(merchantId)); +} + export interface OriginateTransferInput { merchantId: string; businessId?: string | null; @@ -170,6 +198,11 @@ export async function originateTransfer( throw new BankTransferError('Bank account belongs to a different business', 403); } + if (counterparty.role !== 'merchant') { + // A payer's account is used once, to pay. It is never a payout destination. + throw new BankTransferError('Unknown bank account', 404); + } + const currency = input.currency.toUpperCase(); const request = { direction: input.direction, @@ -185,6 +218,22 @@ export async function originateTransfer( throw new BankTransferError(`${provider.label} cannot move ${currency}`, 400); } + const kind: TransferKind = input.direction === 'credit' ? 'payout' : 'funding'; + if (kind === 'payout') { + // A payout leaves the originating account, which holds every merchant's + // money. Without this check a merchant could pay out what another merchant + // was owed. Two concurrent payouts can still both pass it; the ledger then + // goes negative and the next one is refused, which is the accepted bound + // until a reservation exists. + const available = await availableBalanceMinor(input.merchantId, deps.store); + if (input.amountMinor > available) { + throw new BankTransferError( + `Insufficient balance: ${(available / 100).toFixed(2)} ${currency} available`, + 409, + ); + } + } + let row: BankTransferRow; try { row = await deps.store.insertTransfer({ @@ -192,7 +241,13 @@ export async function originateTransfer( business_id: input.businessId ?? counterparty.business_id ?? null, provider: provider.id, direction: input.direction, + kind, + payment_id: null, + invoice_id: null, + payer_email: null, amount_minor: input.amountMinor, + fee_minor: 0, + net_minor: input.amountMinor, currency, counterparty_id: counterparty.provider_counterparty_id, bank_counterparty_id: counterparty.id, @@ -213,6 +268,125 @@ export async function originateTransfer( return submitToProvider(row, provider, request, deps); } +export interface OriginatePayinInput { + /** Exactly one of paymentId or invoiceId. */ + paymentId?: string | null; + invoiceId?: string | null; + merchantId: string; + businessId: string; + amountMinor: number; + currency: string; + /** Platform fee in minor units, already computed at the merchant's tier. */ + feeMinor: number; + description?: string | null; + payer: { + holderName: string; + routingNumber: string; + accountNumber: string; + accountType: BankAccountType; + email?: string | null; + }; +} + +/** + * A buyer pays a payment or an invoice from their bank account. + * + * The payer's account becomes a counterparty with role 'payer': never listed + * on the merchant's page and never a payout destination. The debit is a + * transfer of kind 'payin' tied to the payment or invoice. One attempt may be + * in flight per payment; a failed attempt may be followed by another, which + * is what the attempt number in the idempotency key allows. + * + * Nothing here touches the payment or invoice row. That happens in ./payin.ts + * when the sweep reports the transfer complete, because a submitted ACH debit + * is not a paid invoice. + */ +export async function originatePayin(input: OriginatePayinInput, deps: BankingDeps): Promise { + const provider = resolveProvider(deps); + const ref = input.paymentId ? { paymentId: input.paymentId } : { invoiceId: input.invoiceId }; + if (!ref.paymentId && !ref.invoiceId) throw new BankTransferError('paymentId or invoiceId is required', 400); + + const previous = await deps.store.listPayinsFor(ref); + const live = previous.find((row) => row.status !== 'failed' && row.status !== 'canceled'); + if (live) return live; // already paying by bank; the page polls this row + + const currency = input.currency.toUpperCase(); + if (!provider.currencies.includes(currency)) { + throw new BankTransferError(`${provider.label} cannot move ${currency}`, 400); + } + if (!Number.isInteger(input.amountMinor) || input.amountMinor <= 0) { + throw new BankTransferError('amountMinor must be a positive integer', 400); + } + if (!Number.isInteger(input.feeMinor) || input.feeMinor < 0 || input.feeMinor >= input.amountMinor) { + throw new BankTransferError('feeMinor is out of range', 400); + } + + const payerRequest = { + holderName: input.payer.holderName, + routingNumber: input.payer.routingNumber.replace(/\s+/g, ''), + accountNumber: input.payer.accountNumber.replace(/\s+/g, ''), + accountType: input.payer.accountType, + }; + const invalid = validateCounterpartyRequest(payerRequest); + if (invalid) throw new BankTransferError(invalid, 400); + + const counterparty = await provider.createCounterparty(payerRequest); + const stored = await deps.store.insertCounterparty({ + merchant_id: input.merchantId, + business_id: input.businessId, + provider: provider.id, + provider_counterparty_id: counterparty.id, + holder_name: counterparty.holderName, + account_type: counterparty.accountType, + routing_number: counterparty.routingNumber, + account_last4: counterparty.accountLast4, + role: 'payer', + payer_email: input.payer.email?.trim() || null, + }); + + const attempt = previous.length + 1; + const key = `payin:${ref.paymentId ? 'payment' : 'invoice'}:${ref.paymentId ?? ref.invoiceId}:${attempt}`; + const request = { + direction: 'debit' as const, + amountMinor: input.amountMinor, + currency, + counterpartyId: counterparty.id, + idempotencyKey: key, + description: input.description?.trim() || undefined, + }; + + let row: BankTransferRow; + try { + row = await deps.store.insertTransfer({ + merchant_id: input.merchantId, + business_id: input.businessId, + provider: provider.id, + direction: 'debit', + kind: 'payin', + payment_id: ref.paymentId ?? null, + invoice_id: ref.invoiceId ?? null, + payer_email: input.payer.email?.trim() || null, + amount_minor: input.amountMinor, + fee_minor: input.feeMinor, + net_minor: input.amountMinor - input.feeMinor, + currency, + counterparty_id: counterparty.id, + bank_counterparty_id: stored.id, + description: request.description ?? null, + idempotency_key: key, + }); + } catch (err) { + if (err instanceof DuplicateIdempotencyKeyError) { + // Two submissions of the same form raced. The first owns the debit. + const winner = await deps.store.findTransferByIdempotencyKey(key); + if (winner) return winner; + } + throw err; + } + + return submitToProvider(row, provider, request, deps); +} + async function submitToProvider( row: BankTransferRow, provider: BankTransferProvider, @@ -418,7 +592,12 @@ export function publicTransfer(row: BankTransferRow) { bankCounterpartyId: row.bank_counterparty_id, provider: row.provider, direction: row.direction, + kind: row.kind, + paymentId: row.payment_id, + invoiceId: row.invoice_id, amountMinor: row.amount_minor, + feeMinor: row.fee_minor, + netMinor: row.net_minor, currency: row.currency, status: row.status, providerStatus: row.provider_status, @@ -433,3 +612,20 @@ export function publicTransfer(row: BankTransferRow) { error: row.status === 'failed' ? row.last_error : null, }; } + +/** What a buyer may see of their own pay-in: status and timing, nothing of anyone else's. */ +export function payerTransferView(row: BankTransferRow) { + return { + id: row.id, + status: row.status, + amountMinor: row.amount_minor, + currency: row.currency, + createdAt: row.created_at, + settledAt: row.settled_at, + holdUntil: row.hold_until, + completedAt: row.completed_at, + returnedAt: row.returned_at, + returnCode: row.return_code, + error: row.status === 'failed' ? row.last_error : null, + }; +} diff --git a/src/lib/banking/store.ts b/src/lib/banking/store.ts index 63f139b9..1a1eea5a 100644 --- a/src/lib/banking/store.ts +++ b/src/lib/banking/store.ts @@ -20,10 +20,21 @@ export interface BankCounterpartyRow { routing_number: string; account_last4: string; status: 'active' | 'removed'; + /** merchant = the merchant's own account (listed, payable to); payer = a buyer's, used once. */ + role: CounterpartyRole; + payer_email: string | null; created_at: string; updated_at: string; } +export type CounterpartyRole = 'merchant' | 'payer'; + +/** + * What a transfer is for. It is what makes a balance computable: payins and + * funding add to what a merchant may pay out, payouts subtract. + */ +export type TransferKind = 'payin' | 'funding' | 'payout'; + export interface BankTransferRow { id: string; merchant_id: string | null; @@ -31,7 +42,15 @@ export interface BankTransferRow { provider: string; provider_transfer_id: string | null; direction: TransferDirection; + kind: TransferKind; + payment_id: string | null; + invoice_id: string | null; + payer_email: string | null; amount_minor: number; + /** Platform fee, minor units. Zero on funding and payout. */ + fee_minor: number; + /** What the merchant keeps: amount minus fee on a payin, the amount otherwise. */ + net_minor: number; currency: string; status: TransferStatus; provider_status: string | null; @@ -57,7 +76,13 @@ export type NewTransferRow = Pick< | 'business_id' | 'provider' | 'direction' + | 'kind' + | 'payment_id' + | 'invoice_id' + | 'payer_email' | 'amount_minor' + | 'fee_minor' + | 'net_minor' | 'currency' | 'counterparty_id' | 'bank_counterparty_id' @@ -96,6 +121,11 @@ export interface BankStore { listInFlight(limit: number): Promise; /** Completed transfers that settled after `settledAfter` and were not polled since `polledBefore`. */ listReturnable(settledAfter: string, polledBefore: string, limit: number): Promise; + + /** Every transfer that counts towards a merchant's balance (all kinds, all statuses). */ + listLedger(merchantId: string): Promise; + /** Payin transfers for one payment or invoice, newest first. */ + listPayinsFor(ref: { paymentId?: string | null; invoiceId?: string | null }): Promise; } const IN_FLIGHT: readonly TransferStatus[] = ['initiated', 'pending', 'settled']; @@ -133,6 +163,7 @@ export class SupabaseBankStore implements BankStore { .select('*') .eq('merchant_id', merchantId) .eq('status', 'active') + .eq('role', 'merchant') .order('created_at', { ascending: false }); if (businessId) query = query.eq('business_id', businessId); const { data, error } = await query; @@ -236,6 +267,26 @@ export class SupabaseBankStore implements BankStore { if (error) throw new Error(`bank_transfers returnable read failed: ${error.message}`); return (data as BankTransferRow[]) ?? []; } + + async listLedger(merchantId: string): Promise { + const { data, error } = await this.supabase + .from('bank_transfers') + .select('*') + .eq('merchant_id', merchantId) + .limit(5000); + if (error) throw new Error(`bank_transfers ledger read failed: ${error.message}`); + return (data as BankTransferRow[]) ?? []; + } + + async listPayinsFor(ref: { paymentId?: string | null; invoiceId?: string | null }): Promise { + let query = this.supabase.from('bank_transfers').select('*').eq('kind', 'payin'); + if (ref.paymentId) query = query.eq('payment_id', ref.paymentId); + else if (ref.invoiceId) query = query.eq('invoice_id', ref.invoiceId); + else return []; + const { data, error } = await query.order('created_at', { ascending: false }); + if (error) throw new Error(`bank_transfers payin read failed: ${error.message}`); + return (data as BankTransferRow[]) ?? []; + } } /** @@ -278,6 +329,7 @@ export class MemoryBankStore implements BankStore { (row) => row.merchant_id === merchantId && row.status === 'active' && + row.role === 'merchant' && (!businessId || row.business_id === businessId), ); } @@ -366,4 +418,19 @@ export class MemoryBankStore implements BankStore { ) .slice(0, limit); } + + async listLedger(merchantId: string): Promise { + return [...this.transfers.values()].filter((row) => row.merchant_id === merchantId); + } + + async listPayinsFor(ref: { paymentId?: string | null; invoiceId?: string | null }): Promise { + return [...this.transfers.values()] + .filter( + (row) => + row.kind === 'payin' && + ((ref.paymentId && row.payment_id === ref.paymentId) || + (ref.invoiceId && row.invoice_id === ref.invoiceId)), + ) + .sort((a, b) => b.created_at.localeCompare(a.created_at)); + } } diff --git a/src/lib/webhooks/service.ts b/src/lib/webhooks/service.ts index d58e5b2b..82ebe2ee 100644 --- a/src/lib/webhooks/service.ts +++ b/src/lib/webhooks/service.ts @@ -98,7 +98,9 @@ export type WebhookEvent = | 'escrow.resolved' | 'escrow.refunded' | 'escrow.expired' - | 'invoice.paid'; + | 'invoice.paid' + // An ACH pay-in returned after the invoice was marked paid; the invoice is owed again. + | 'invoice.payment_returned'; /** * Webhook payload structure diff --git a/supabase/migrations/20260918090000_bank_payins.sql b/supabase/migrations/20260918090000_bank_payins.sql new file mode 100644 index 00000000..bb83b2ac --- /dev/null +++ b/supabase/migrations/20260918090000_bank_payins.sql @@ -0,0 +1,38 @@ +-- ACH as a way to pay, wherever a payment is taken. +-- +-- A bank transfer now says what it is for. A buyer paying an invoice or a +-- payment over ACH is a 'payin'; a merchant pulling their own money in is +-- 'funding'; a merchant paying out to their own bank is a 'payout'. The +-- distinction is what makes a balance computable: payins and funding add to +-- what a merchant may pay out, payouts subtract, and a return of a payin after +-- it completed subtracts again. +-- +-- The payer's bank account is a counterparty like any other, with role +-- 'payer' so it never appears in the merchant's own list of accounts and can +-- never be the destination of a payout. + +ALTER TABLE bank_transfers + ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'funding' + CHECK (kind IN ('payin', 'funding', 'payout')), + ADD COLUMN IF NOT EXISTS payment_id uuid REFERENCES payments(id), + ADD COLUMN IF NOT EXISTS invoice_id uuid REFERENCES invoices(id), + -- Platform fee and what the merchant keeps, both minor units. On a payin + -- net_minor is amount_minor - fee_minor; on funding and payout it equals + -- amount_minor and fee_minor is 0. + ADD COLUMN IF NOT EXISTS fee_minor bigint NOT NULL DEFAULT 0 CHECK (fee_minor >= 0), + ADD COLUMN IF NOT EXISTS net_minor bigint, + ADD COLUMN IF NOT EXISTS payer_email text; + +CREATE INDEX IF NOT EXISTS idx_bank_transfers_payment ON bank_transfers (payment_id) WHERE payment_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_bank_transfers_invoice ON bank_transfers (invoice_id) WHERE invoice_id IS NOT NULL; + +COMMENT ON COLUMN bank_transfers.kind IS + 'payin = a buyer paying a payment or invoice; funding = a merchant pulling from their own bank; payout = a merchant paying out to their own bank. Drives the balance.'; + +ALTER TABLE bank_counterparties + ADD COLUMN IF NOT EXISTS role text NOT NULL DEFAULT 'merchant' + CHECK (role IN ('merchant', 'payer')), + ADD COLUMN IF NOT EXISTS payer_email text; + +COMMENT ON COLUMN bank_counterparties.role IS + 'merchant = the merchant''s own bank account, listed on /banking and a valid payout destination. payer = a buyer''s account used once to pay; never listed, never paid out to.';