feat(rcart): Layer 3 — automated recurring payment execution via ERC-20 allowance
Recurring Executor (new module): - Server-managed relayer keypair derived deterministically via HKDF - Checks on-chain ERC-20 allowance before pulling funds - Executes transferFrom when subscriptions are due - Supports Base, Base Sepolia, and Ethereum mainnet New API Endpoints: - GET /api/payments/:id/subscription-info — returns relayer address and approve calldata for the client to authorize recurring pulls - POST /api/payments/:id/subscribe — registers a subscription after payer approves on-chain allowance, verifies allowance exists Scheduler Upgrade: - Attempts automated pull first for subscriptions with approved allowance - Falls back to email reminder if auto-pull fails or is not configured - Sends branded receipt email after successful automated payments - Extracted email templates into reusable helper functions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4304170a9a
commit
97a077b256
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* Recurring Payment Executor
|
||||
*
|
||||
* Handles automated ERC-20 subscription payments via two mechanisms:
|
||||
*
|
||||
* 1. **Approval-based pulls (ERC-20 transferFrom)**:
|
||||
* The payer approves a server-controlled relayer address to spend tokens.
|
||||
* The server calls transferFrom when payments are due. Works with any wallet.
|
||||
*
|
||||
* 2. **EIP-4337 UserOp execution (Safe smart accounts)**:
|
||||
* For Safe wallets with the server's session key as an authorized signer,
|
||||
* the server constructs and submits UserOps via the Pimlico bundler.
|
||||
*
|
||||
* The relayer keypair is derived deterministically from HKDF so it's
|
||||
* consistent across restarts without storing a private key in the DB.
|
||||
*/
|
||||
|
||||
import { createWalletClient, createPublicClient, http, type Chain } from 'viem';
|
||||
import { privateKeyToAccount } from 'viem/accounts';
|
||||
import { base, baseSepolia, mainnet } from 'viem/chains';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface SubscriptionApproval {
|
||||
paymentRequestId: string;
|
||||
payerAddress: string;
|
||||
recipientAddress: string;
|
||||
tokenAddress: string;
|
||||
amount: string; // amount per period in token units (e.g. "10" USDC)
|
||||
decimals: number;
|
||||
chainId: number;
|
||||
approvedAt: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CHAIN CONFIG
|
||||
// ============================================================================
|
||||
|
||||
const CHAINS: Record<number, Chain> = {
|
||||
8453: base,
|
||||
84532: baseSepolia,
|
||||
1: mainnet,
|
||||
};
|
||||
|
||||
const RPC_URLS: Record<number, string> = {
|
||||
8453: 'https://mainnet.base.org',
|
||||
84532: 'https://sepolia.base.org',
|
||||
1: 'https://eth.llamarpc.com',
|
||||
};
|
||||
|
||||
// ERC-20 function selectors
|
||||
const TRANSFER_FROM_SELECTOR = '0x23b872dd'; // transferFrom(address,address,uint256)
|
||||
const ALLOWANCE_SELECTOR = '0xdd62ed3e'; // allowance(address,address)
|
||||
|
||||
// ============================================================================
|
||||
// RELAYER
|
||||
// ============================================================================
|
||||
|
||||
let _relayerKey: `0x${string}` | null = null;
|
||||
let _relayerAddress: string | null = null;
|
||||
|
||||
/**
|
||||
* Derive the relayer keypair from the server's RELAYER_SEED env var.
|
||||
* Falls back to a deterministic derivation from PIMLICO_API_KEY if no seed is set.
|
||||
*/
|
||||
async function getRelayerKey(): Promise<`0x${string}`> {
|
||||
if (_relayerKey) return _relayerKey;
|
||||
|
||||
const seed = process.env.RELAYER_SEED || process.env.PIMLICO_API_KEY;
|
||||
if (!seed) throw new Error('RELAYER_SEED or PIMLICO_API_KEY required for recurring payments');
|
||||
|
||||
// Derive a deterministic private key via HKDF-SHA256
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw', encoder.encode(seed), 'HKDF', false, ['deriveBits']
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: encoder.encode('rcart-relayer-key-v1'),
|
||||
info: encoder.encode('secp256k1-relayer'),
|
||||
},
|
||||
keyMaterial,
|
||||
256,
|
||||
);
|
||||
|
||||
const keyBytes = new Uint8Array(bits);
|
||||
_relayerKey = ('0x' + Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('')) as `0x${string}`;
|
||||
_relayerAddress = privateKeyToAccount(_relayerKey).address;
|
||||
|
||||
console.log(`[rcart] Relayer address: ${_relayerAddress}`);
|
||||
return _relayerKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relayer's Ethereum address.
|
||||
*/
|
||||
export async function getRelayerAddress(): Promise<string> {
|
||||
await getRelayerKey();
|
||||
return _relayerAddress!;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ALLOWANCE CHECK
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check the current ERC-20 allowance the payer has granted to the relayer.
|
||||
*/
|
||||
export async function checkAllowance(
|
||||
tokenAddress: string,
|
||||
payerAddress: string,
|
||||
chainId: number,
|
||||
): Promise<bigint> {
|
||||
const chain = CHAINS[chainId];
|
||||
if (!chain) throw new Error(`Unsupported chain: ${chainId}`);
|
||||
|
||||
const relayerAddr = await getRelayerAddress();
|
||||
|
||||
const client = createPublicClient({
|
||||
chain,
|
||||
transport: http(RPC_URLS[chainId]),
|
||||
});
|
||||
|
||||
// Encode allowance(payer, relayer) call
|
||||
const owner = payerAddress.slice(2).toLowerCase().padStart(64, '0');
|
||||
const spender = relayerAddr.slice(2).toLowerCase().padStart(64, '0');
|
||||
const data = `${ALLOWANCE_SELECTOR}${owner}${spender}` as `0x${string}`;
|
||||
|
||||
const result = await client.call({
|
||||
to: tokenAddress as `0x${string}`,
|
||||
data,
|
||||
});
|
||||
|
||||
if (!result.data || result.data === '0x') return 0n;
|
||||
return BigInt(result.data);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXECUTE TRANSFER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse a decimal amount string into a BigInt with the given decimals.
|
||||
*/
|
||||
function parseTokenAmount(amount: string, decimals: number): bigint {
|
||||
const parts = amount.split('.');
|
||||
const whole = parts[0] || '0';
|
||||
const frac = (parts[1] || '').slice(0, decimals).padEnd(decimals, '0');
|
||||
return BigInt(whole) * BigInt(10 ** decimals) + BigInt(frac);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a transferFrom call: pull tokens from payer to recipient.
|
||||
* Requires that the payer has approved the relayer for sufficient allowance.
|
||||
*/
|
||||
export async function executeTransferFrom(
|
||||
tokenAddress: string,
|
||||
payerAddress: string,
|
||||
recipientAddress: string,
|
||||
amount: string,
|
||||
decimals: number,
|
||||
chainId: number,
|
||||
): Promise<string> {
|
||||
const chain = CHAINS[chainId];
|
||||
if (!chain) throw new Error(`Unsupported chain: ${chainId}`);
|
||||
|
||||
const relayerKey = await getRelayerKey();
|
||||
const account = privateKeyToAccount(relayerKey);
|
||||
|
||||
// Check allowance first
|
||||
const allowance = await checkAllowance(tokenAddress, payerAddress, chainId);
|
||||
const rawAmount = parseTokenAmount(amount, decimals);
|
||||
if (allowance < rawAmount) {
|
||||
throw new Error(`Insufficient allowance: have ${allowance}, need ${rawAmount}`);
|
||||
}
|
||||
|
||||
const client = createWalletClient({
|
||||
account,
|
||||
chain,
|
||||
transport: http(RPC_URLS[chainId]),
|
||||
});
|
||||
|
||||
// Encode transferFrom(from, to, amount)
|
||||
const from = payerAddress.slice(2).toLowerCase().padStart(64, '0');
|
||||
const to = recipientAddress.slice(2).toLowerCase().padStart(64, '0');
|
||||
const amountHex = rawAmount.toString(16).padStart(64, '0');
|
||||
const data = `${TRANSFER_FROM_SELECTOR}${from}${to}${amountHex}` as `0x${string}`;
|
||||
|
||||
const txHash = await client.sendTransaction({
|
||||
account,
|
||||
to: tokenAddress as `0x${string}`,
|
||||
data,
|
||||
chain,
|
||||
});
|
||||
|
||||
return txHash;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// APPROVAL TX BUILDER (client-side helper)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build the calldata for an ERC-20 approve(relayer, amount) transaction.
|
||||
* Returns the data the client needs to sign and submit.
|
||||
*/
|
||||
export async function buildApprovalCalldata(
|
||||
amount: string,
|
||||
decimals: number,
|
||||
maxPayments: number,
|
||||
): Promise<{ spender: string; calldata: string; totalAllowance: string }> {
|
||||
const relayerAddr = await getRelayerAddress();
|
||||
const perPayment = parseTokenAmount(amount, decimals);
|
||||
// Approve enough for all scheduled payments (or a generous amount for unlimited)
|
||||
const totalPayments = maxPayments > 0 ? BigInt(maxPayments) : 120n; // ~10 years monthly
|
||||
const totalAllowance = perPayment * totalPayments;
|
||||
|
||||
const spender = relayerAddr.slice(2).toLowerCase().padStart(64, '0');
|
||||
const allowanceHex = totalAllowance.toString(16).padStart(64, '0');
|
||||
// approve(address spender, uint256 amount) — selector: 0x095ea7b3
|
||||
const calldata = `0x095ea7b3${spender}${allowanceHex}`;
|
||||
|
||||
return {
|
||||
spender: relayerAddr,
|
||||
calldata,
|
||||
totalAllowance: totalAllowance.toString(),
|
||||
};
|
||||
}
|
||||
|
|
@ -33,6 +33,9 @@ import { extractProductFromUrl } from './extract';
|
|||
import { createTransakWidgetUrl, extractRootDomain, getTransakApiKey } from '../../shared/transak';
|
||||
import QRCode from 'qrcode';
|
||||
import { createTransport, type Transporter } from "nodemailer";
|
||||
import {
|
||||
getRelayerAddress, checkAllowance, executeTransferFrom, buildApprovalCalldata,
|
||||
} from './lib/recurring-executor';
|
||||
|
||||
let _syncServer: SyncServer | null = null;
|
||||
|
||||
|
|
@ -98,7 +101,6 @@ async function checkDueSubscriptions() {
|
|||
if (!_syncServer) return;
|
||||
const now = Date.now();
|
||||
const transport = getSmtpTransport();
|
||||
if (!transport) return;
|
||||
|
||||
// Scan all payment request docs for due subscriptions
|
||||
const allDocIds = _syncServer.listDocs?.() || [];
|
||||
|
|
@ -113,53 +115,86 @@ async function checkDueSubscriptions() {
|
|||
if (p.status !== 'pending') continue;
|
||||
if (!p.interval || !p.nextDueAt) continue;
|
||||
if (p.nextDueAt > now) continue;
|
||||
if (!p.subscriberEmail) continue;
|
||||
|
||||
// Don't send more than once per interval — check if last payment was recent
|
||||
// Don't process more than once per interval — check if last payment was recent
|
||||
const lastPayment = p.paymentHistory?.length > 0
|
||||
? p.paymentHistory[p.paymentHistory.length - 1]
|
||||
: null;
|
||||
const gracePeriodMs = Math.min(INTERVAL_MS[p.interval] * 0.5, 3 * 24 * 60 * 60 * 1000);
|
||||
if (lastPayment && (now - lastPayment.paidAt) < gracePeriodMs) continue;
|
||||
|
||||
// Send reminder email
|
||||
const space = doc.meta.spaceSlug || 'demo';
|
||||
const host = 'rspace.online';
|
||||
const payUrl = `https://${space}.${host}/rcart/pay/${p.id}`;
|
||||
const senderName = p.creatorUsername || 'Someone';
|
||||
const displayAmount = (!p.amount || p.amount === '0') ? 'a payment' : `${p.amount} ${p.token}`;
|
||||
const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@rmail.online';
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
|
||||
<tr><td style="background:linear-gradient(135deg,#f59e0b,#ef4444);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
|
||||
<h1 style="color:#fff;font-size:24px;margin:0">Payment Reminder</h1>
|
||||
<p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px">Your ${p.interval} payment is due</p>
|
||||
</td></tr>
|
||||
<tr><td style="background:#1a1a24;padding:32px 24px">
|
||||
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
|
||||
Your recurring payment of <strong>${displayAmount}</strong> to <strong>${senderName}</strong>${p.description ? ` for "${p.description}"` : ''} is due.
|
||||
</p>
|
||||
<p style="color:#94a3b8;font-size:14px;margin:0 0 24px">Payment ${p.paymentCount + 1}${p.maxPayments > 0 ? ` of ${p.maxPayments}` : ''}</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr><td align="center">
|
||||
<a href="${payUrl}" style="display:inline-block;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:16px">
|
||||
Pay Now
|
||||
</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
|
||||
<p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace · <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>`;
|
||||
// ── Attempt automated pull if payer has approved allowance ──
|
||||
if (p.payerIdentity && p.token !== 'ETH') {
|
||||
const usdcAddress = USDC_ADDRESSES[p.chainId];
|
||||
if (usdcAddress) {
|
||||
try {
|
||||
const decimals = p.token === 'USDC' ? 6 : 18;
|
||||
const txHash = await executeTransferFrom(
|
||||
usdcAddress,
|
||||
p.payerIdentity,
|
||||
p.recipientAddress,
|
||||
p.amount || '0',
|
||||
decimals,
|
||||
p.chainId,
|
||||
);
|
||||
|
||||
// Record the automated payment
|
||||
_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'automated subscription payment', (d) => {
|
||||
d.payment.paidAt = now;
|
||||
d.payment.txHash = txHash;
|
||||
d.payment.paymentMethod = 'wallet';
|
||||
d.payment.paymentCount = (d.payment.paymentCount || 0) + 1;
|
||||
if (!d.payment.paymentHistory) d.payment.paymentHistory = [] as any;
|
||||
(d.payment.paymentHistory as any).push({
|
||||
txHash,
|
||||
transakOrderId: null,
|
||||
paymentMethod: 'wallet',
|
||||
payerIdentity: p.payerIdentity,
|
||||
payerEmail: p.subscriberEmail || null,
|
||||
amount: d.payment.amount,
|
||||
paidAt: now,
|
||||
});
|
||||
|
||||
if (d.payment.maxPayments > 0 && d.payment.paymentCount >= d.payment.maxPayments) {
|
||||
d.payment.status = 'filled';
|
||||
} else {
|
||||
d.payment.status = 'pending';
|
||||
d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval!);
|
||||
}
|
||||
d.payment.updatedAt = now;
|
||||
});
|
||||
|
||||
console.log(`[rcart] Auto-pulled subscription payment for ${p.id}: ${txHash}`);
|
||||
|
||||
// Send receipt email
|
||||
if (transport && p.subscriberEmail) {
|
||||
const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@rmail.online';
|
||||
transport.sendMail({
|
||||
from: `"${senderName} via rSpace" <${fromAddr}>`,
|
||||
to: p.subscriberEmail,
|
||||
subject: `Payment processed: ${displayAmount} to ${senderName}`,
|
||||
html: buildReceiptEmail(senderName, displayAmount, txHash, p.chainId, payUrl),
|
||||
}).catch(err => console.warn(`[rcart] Failed to send receipt for ${p.id}:`, err));
|
||||
}
|
||||
continue; // Payment executed — no reminder needed
|
||||
} catch (err) {
|
||||
console.warn(`[rcart] Auto-pull failed for ${p.id}:`, err);
|
||||
// Fall through to send reminder email instead
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Send reminder email (no auto-pull available or it failed) ──
|
||||
if (!transport || !p.subscriberEmail) continue;
|
||||
|
||||
const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@rmail.online';
|
||||
const html = buildReminderEmail(senderName, displayAmount, p.interval!, p.paymentCount, p.maxPayments, payUrl);
|
||||
|
||||
try {
|
||||
await transport.sendMail({
|
||||
|
|
@ -178,6 +213,66 @@ async function checkDueSubscriptions() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Email template helpers ──
|
||||
|
||||
function buildReminderEmail(sender: string, amount: string, interval: string, count: number, maxPayments: number, payUrl: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
|
||||
<tr><td style="background:linear-gradient(135deg,#f59e0b,#ef4444);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
|
||||
<h1 style="color:#fff;font-size:24px;margin:0">Payment Reminder</h1>
|
||||
<p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px">Your ${interval} payment is due</p>
|
||||
</td></tr>
|
||||
<tr><td style="background:#1a1a24;padding:32px 24px">
|
||||
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
|
||||
Your recurring payment of <strong>${amount}</strong> to <strong>${sender}</strong> is due.
|
||||
</p>
|
||||
<p style="color:#94a3b8;font-size:14px;margin:0 0 24px">Payment ${count + 1}${maxPayments > 0 ? ` of ${maxPayments}` : ''}</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr><td align="center">
|
||||
<a href="${payUrl}" style="display:inline-block;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:16px">Pay Now</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
|
||||
<p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace · <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
|
||||
</td></tr>
|
||||
</table></td></tr></table></body></html>`;
|
||||
}
|
||||
|
||||
const CHAIN_EXPLORER_URLS: Record<number, string> = { 8453: 'https://basescan.org/tx/', 84532: 'https://sepolia.basescan.org/tx/', 1: 'https://etherscan.io/tx/' };
|
||||
|
||||
function buildReceiptEmail(sender: string, amount: string, txHash: string, chainId: number, payUrl: string): string {
|
||||
const explorer = CHAIN_EXPLORER_URLS[chainId];
|
||||
const txLink = explorer ? `<a href="${explorer}${txHash}" style="color:#67e8f9;text-decoration:none">${txHash.slice(0, 10)}...${txHash.slice(-8)}</a>` : txHash.slice(0, 18) + '...';
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
|
||||
<tr><td style="background:linear-gradient(135deg,#06b6d4,#8b5cf6);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
|
||||
<div style="font-size:48px;margin-bottom:8px">✓</div>
|
||||
<h1 style="color:#fff;font-size:24px;margin:0">Subscription Payment Processed</h1>
|
||||
</td></tr>
|
||||
<tr><td style="background:#1a1a24;padding:32px 24px">
|
||||
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
|
||||
Your recurring payment of <strong>${amount}</strong> to <strong>${sender}</strong> has been automatically processed.
|
||||
</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:16px">
|
||||
<tr><td style="color:#94a3b8;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d">Transaction</td>
|
||||
<td style="color:#fff;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d;text-align:right">${txLink}</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
|
||||
<p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace · <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View subscription</a></p>
|
||||
</td></tr>
|
||||
</table></td></tr></table></body></html>`;
|
||||
}
|
||||
|
||||
// ── Automerge helpers ──
|
||||
|
||||
/** Lazily create (or retrieve) the catalog doc for a space. */
|
||||
|
|
@ -1655,6 +1750,133 @@ routes.post("/api/payments/:id/share-email", async (c) => {
|
|||
return c.json({ sent, total: validEmails.length });
|
||||
});
|
||||
|
||||
// ── Recurring payment endpoints ──
|
||||
|
||||
// GET /api/payments/:id/subscription-info — Get subscription approval details
|
||||
routes.get("/api/payments/:id/subscription-info", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const paymentId = c.req.param("id");
|
||||
const docId = paymentRequestDocId(space, paymentId);
|
||||
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
|
||||
if (!doc) return c.json({ error: "Payment request not found" }, 404);
|
||||
|
||||
const p = doc.payment;
|
||||
if (p.paymentType !== 'subscription' && p.paymentType !== 'payer_choice') {
|
||||
return c.json({ error: "Not a subscription payment" }, 400);
|
||||
}
|
||||
|
||||
const usdcAddress = USDC_ADDRESSES[p.chainId];
|
||||
if (!usdcAddress) return c.json({ error: "Token not supported on this chain" }, 400);
|
||||
|
||||
try {
|
||||
const relayerAddress = await getRelayerAddress();
|
||||
const decimals = p.token === 'USDC' ? 6 : 18;
|
||||
|
||||
// Build the approve calldata for the client
|
||||
const approval = await buildApprovalCalldata(
|
||||
p.amount || '0', decimals, p.maxPayments
|
||||
);
|
||||
|
||||
return c.json({
|
||||
relayerAddress,
|
||||
tokenAddress: usdcAddress,
|
||||
chainId: p.chainId,
|
||||
interval: p.interval,
|
||||
amountPerPayment: p.amount,
|
||||
token: p.token,
|
||||
approveCalldata: approval.calldata,
|
||||
totalAllowance: approval.totalAllowance,
|
||||
});
|
||||
} catch (e) {
|
||||
return c.json({ error: "Recurring payments not configured on this server" }, 503);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/payments/:id/subscribe — Register a subscription after payer approves allowance
|
||||
routes.post("/api/payments/:id/subscribe", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const paymentId = c.req.param("id");
|
||||
const docId = paymentRequestDocId(space, paymentId);
|
||||
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
|
||||
if (!doc) return c.json({ error: "Payment request not found" }, 404);
|
||||
|
||||
const p = doc.payment;
|
||||
const body = await c.req.json();
|
||||
const { payerAddress, email, txHash } = body;
|
||||
|
||||
if (!payerAddress) return c.json({ error: "Required: payerAddress" }, 400);
|
||||
|
||||
const usdcAddress = USDC_ADDRESSES[p.chainId];
|
||||
if (!usdcAddress) return c.json({ error: "Token not supported on this chain" }, 400);
|
||||
|
||||
// Verify the allowance exists on-chain
|
||||
try {
|
||||
const allowance = await checkAllowance(usdcAddress, payerAddress, p.chainId);
|
||||
const decimals = p.token === 'USDC' ? 6 : 18;
|
||||
const perPayment = parseTokenAmountServer(p.amount || '0', decimals);
|
||||
|
||||
if (allowance < perPayment) {
|
||||
return c.json({
|
||||
error: "Insufficient allowance. Please approve the relayer to spend your tokens first.",
|
||||
allowance: allowance.toString(),
|
||||
required: perPayment.toString(),
|
||||
}, 400);
|
||||
}
|
||||
} catch (e) {
|
||||
return c.json({ error: "Failed to verify allowance on-chain" }, 500);
|
||||
}
|
||||
|
||||
// Store subscription info
|
||||
const now = Date.now();
|
||||
_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'register subscription', (d) => {
|
||||
d.payment.subscriberEmail = email || null;
|
||||
// Set payer address for automated pulls
|
||||
d.payment.payerIdentity = payerAddress;
|
||||
// First due date is one interval from now
|
||||
if (d.payment.interval) {
|
||||
d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval);
|
||||
}
|
||||
d.payment.updatedAt = now;
|
||||
});
|
||||
|
||||
// If this was the first payment (approval tx included a transfer), record it
|
||||
if (txHash) {
|
||||
_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'record initial payment', (d) => {
|
||||
d.payment.status = 'pending'; // Keep accepting
|
||||
d.payment.paidAt = now;
|
||||
d.payment.paymentCount = (d.payment.paymentCount || 0) + 1;
|
||||
d.payment.txHash = txHash;
|
||||
if (!d.payment.paymentHistory) d.payment.paymentHistory = [] as any;
|
||||
(d.payment.paymentHistory as any).push({
|
||||
txHash,
|
||||
transakOrderId: null,
|
||||
paymentMethod: 'wallet',
|
||||
payerIdentity: payerAddress,
|
||||
payerEmail: email || null,
|
||||
amount: d.payment.amount,
|
||||
paidAt: now,
|
||||
});
|
||||
if (d.payment.interval) {
|
||||
d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
subscribed: true,
|
||||
nextDueAt: doc.payment.nextDueAt ? new Date(doc.payment.nextDueAt).toISOString() : null,
|
||||
interval: doc.payment.interval,
|
||||
});
|
||||
});
|
||||
|
||||
// Server-side token amount parser
|
||||
function parseTokenAmountServer(amount: string, decimals: number): bigint {
|
||||
const parts = amount.split('.');
|
||||
const whole = parts[0] || '0';
|
||||
const frac = (parts[1] || '').slice(0, decimals).padEnd(decimals, '0');
|
||||
return BigInt(whole) * BigInt(10 ** decimals) + BigInt(frac);
|
||||
}
|
||||
|
||||
// ── Payment success email ──
|
||||
|
||||
const CHAIN_NAMES: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
|
||||
|
|
|
|||
Loading…
Reference in New Issue