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.';