Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/ACH.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
51 changes: 49 additions & 2 deletions docs/BANK-TRANSFERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/app/api/cron/monitor-payments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!;
Expand Down Expand Up @@ -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 };
Expand Down
42 changes: 42 additions & 0 deletions src/app/api/invoices/[id]/ach/route.ts
Original file line number Diff line number Diff line change
@@ -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<PayinTarget | null> {
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');
}
41 changes: 41 additions & 0 deletions src/app/api/payments/[id]/ach/route.ts
Original file line number Diff line number Diff line change
@@ -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<PayinTarget | null> {
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');
}
34 changes: 33 additions & 1 deletion src/app/invoices/[id]/pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,6 +39,12 @@ export default function InvoicePayPage() {

const [invoice, setInvoice] = useState<InvoicePayData | null>(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<string | null>(null);
const [activeTab, setActiveTab] = useState<PaymentTab>('crypto');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -323,6 +332,19 @@ export default function InvoicePayPage() {
{m.display_name}
</button>
))}
{hasBankOption && (
<button
onClick={() => setActiveTab('bank')}
className={`flex-1 py-3 px-3 text-sm font-medium transition-colors ${
activeTab === 'bank'
? 'text-emerald-300 border-b-2 border-emerald-400 bg-emerald-500/10'
: 'text-gray-400 hover:text-gray-300 hover:bg-gray-700/50'
}`}
data-testid="tab-bank"
>
Bank (ACH)
</button>
)}
</div>
)}

Expand Down Expand Up @@ -386,6 +408,16 @@ export default function InvoicePayPage() {
</div>
)}

{/* === BANK TAB === */}
{activeTab === 'bank' && hasBankOption && isPending && (
<div className="space-y-4" data-testid="bank-payment-section">
<AchPayForm
endpoint={`/api/invoices/${invoiceId}/ach`}
amountLabel={new Intl.NumberFormat('en-US', { style: 'currency', currency: invoice.currency }).format(parseFloat(invoice.amount))}
/>
</div>
)}

{/* === PAYPAL TAB === */}
{activeTab === 'paypal' && hasPaypalOption && isPending && (
<div className="space-y-4" data-testid="paypal-payment-section">
Expand Down
55 changes: 53 additions & 2 deletions src/app/pay/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -106,6 +107,9 @@ export default function PublicPaymentPage() {
const [copiedField, setCopiedField] = useState<string | null>(null);
const [timeRemaining, setTimeRemaining] = useState<number>(0);
const [paymentStatus, setPaymentStatus] = useState<string>('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<PaymentTab>('crypto');
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900 to-gray-900 py-8 px-4">
Expand Down Expand Up @@ -528,6 +535,7 @@ export default function PublicPaymentPage() {
{/* Payment Method Tabs */}
{showTabs && (
<div className="flex border-b border-gray-700" data-testid="payment-tabs">
{hasCryptoOption && (
<button
onClick={() => setActiveTab('crypto')}
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
Expand All @@ -544,6 +552,8 @@ export default function PublicPaymentPage() {
Pay with Crypto
</span>
</button>
)}
{hasCardOption && (
<button
onClick={() => setActiveTab('card')}
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
Expand All @@ -560,6 +570,25 @@ export default function PublicPaymentPage() {
Pay with Card
</span>
</button>
)}
{hasBankOption && (
<button
onClick={() => setActiveTab('bank')}
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
activeTab === 'bank'
? 'text-emerald-400 border-b-2 border-emerald-400 bg-emerald-500/10'
: 'text-gray-400 hover:text-gray-300 hover:bg-gray-700/50'
}`}
data-testid="tab-bank"
>
<span className="flex items-center justify-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10l9-6 9 6M5 10v8m4-8v8m6-8v8m4-8v8M3 21h18" />
</svg>
Pay by Bank
</span>
</button>
)}
</div>
)}

Expand Down Expand Up @@ -611,6 +640,28 @@ export default function PublicPaymentPage() {
)}

<div className="p-6 space-y-6">
{/* === BANK TAB === */}
{activeTab === 'bank' && hasBankOption && isPaymentPending && (
<div className="space-y-6" data-testid="bank-payment-section">
<div className="text-center">
<p className="text-3xl font-bold text-white">
${payment.amount ? parseFloat(payment.amount).toFixed(2) : 'N/A'}
</p>
<p className="text-sm text-gray-400 mt-1">USD via ACH bank transfer</p>
</div>
<AchPayForm
endpoint={`/api/payments/${paymentId}/ach`}
amountLabel={`$${payment.amount ? parseFloat(payment.amount).toFixed(2) : ''}`}
/>
{payment.description && (
<div className="bg-gray-900/50 rounded-xl p-4">
<label className="block text-sm font-medium text-gray-400 mb-1">Description</label>
<p className="text-white">{payment.description}</p>
</div>
)}
</div>
)}

{/* === CARD TAB === */}
{activeTab === 'card' && hasCardOption && isPaymentPending && (
<div className="space-y-6" data-testid="card-payment-section">
Expand Down
Loading
Loading