rspace-online/modules/rpayments/mod.ts

1294 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* rPayments module — QR payment requests, subscriptions, and card on-ramp.
*
* Extracted from rCart so payments can be used by any rApp or standalone.
* Storage: Automerge documents via SyncServer (doc ID pattern `{space}:payments:{paymentId}`).
*/
import * as Automerge from "@automerge/automerge";
import { Hono } from "hono";
import QRCode from "qrcode";
import { createTransport, type Transporter } from "nodemailer";
import { renderShell, buildSpaceUrl } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth";
import { renderLanding } from "./landing";
import type { SyncServer } from "../../server/local-first/sync-server";
import {
paymentRequestSchema,
paymentRequestDocId,
type PaymentRequestDoc,
type PaymentRequestMeta,
} from "./schemas";
import {
createSecureWidgetUrl, extractRootDomain, getTransakApiKey, getTransakEnv,
} from "../../shared/transak";
import {
createMoonPayPaymentUrl, getMoonPayApiKey, getMoonPayEnv,
} from "../../shared/moonpay";
import {
getRelayerAddress, checkAllowance, executeTransferFrom, buildApprovalCalldata,
} from "./lib/recurring-executor";
/** Tokens pegged 1:1 to USD — fiat amount can be inferred from crypto amount. */
const USD_STABLECOINS = ["USDC", "USDT", "DAI", "cUSDC"];
/** USDC contract addresses per chain. */
const USDC_ADDRESSES: Record<number, string> = {
8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
84532: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
1: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
};
const CHAIN_NAMES: Record<number, string> = { 8453: "Base", 84532: "Base Sepolia", 1: "Ethereum" };
const CHAIN_EXPLORERS: Record<number, string> = {
8453: "https://basescan.org/tx/",
84532: "https://sepolia.basescan.org/tx/",
1: "https://etherscan.io/tx/",
};
let _syncServer: SyncServer | null = null;
// ── Cross-module hook: other modules (e.g. rCart) register callbacks that fire when a payment is marked paid ──
export interface PaymentPaidContext {
docId: string;
space: string;
payment: PaymentRequestMeta;
}
export type PaymentPaidHandler = (ctx: PaymentPaidContext) => void | Promise<void>;
const _paidHandlers: PaymentPaidHandler[] = [];
/** Register a handler that fires when any payment transitions to `paid`. */
export function onPaymentPaid(handler: PaymentPaidHandler): void {
_paidHandlers.push(handler);
}
function firePaymentPaid(ctx: PaymentPaidContext): void {
for (const h of _paidHandlers) {
Promise.resolve(h(ctx)).catch((err) => console.warn("[rpayments] paid handler failed:", err));
}
}
// ── SMTP transport (lazy init) ──
let _smtpTransport: Transporter | null = null;
function getSmtpTransport(): Transporter | null {
if (_smtpTransport) return _smtpTransport;
const host = process.env.SMTP_HOST || "mail.rmail.online";
const isInternal = host.includes("mailcow") || host.includes("postfix");
if (!process.env.SMTP_PASS && !isInternal) return null;
_smtpTransport = createTransport({
host,
port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587),
secure: !isInternal && Number(process.env.SMTP_PORT) === 465,
...(isInternal ? {} : {
auth: {
user: process.env.SMTP_USER || "noreply@rmail.online",
pass: process.env.SMTP_PASS!,
},
}),
tls: { rejectUnauthorized: false },
});
return _smtpTransport;
}
// ── EncryptID internal email lookup ──
const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
async function lookupEncryptIDEmail(userId: string): Promise<{ email: string | null; username: string | null }> {
try {
const res = await fetch(`${ENCRYPTID_INTERNAL}/api/internal/user-email/${encodeURIComponent(userId)}`);
if (!res.ok) return { email: null, username: null };
return await res.json();
} catch (e) {
console.warn("[rpayments] EncryptID email lookup failed:", e);
return { email: null, username: null };
}
}
// ── Subscription interval helpers ──
const INTERVAL_MS: Record<string, number> = {
weekly: 7 * 24 * 60 * 60 * 1000,
biweekly: 14 * 24 * 60 * 60 * 1000,
monthly: 30 * 24 * 60 * 60 * 1000,
quarterly: 91 * 24 * 60 * 60 * 1000,
yearly: 365 * 24 * 60 * 60 * 1000,
};
function computeNextDueDate(fromMs: number, interval: string): number {
return fromMs + (INTERVAL_MS[interval] || INTERVAL_MS.monthly);
}
// ── Subscription reminder scheduler ──
let _reminderTimer: ReturnType<typeof setInterval> | null = null;
function startSubscriptionScheduler() {
if (_reminderTimer) return;
_reminderTimer = setInterval(() => checkDueSubscriptions(), 60 * 60 * 1000);
setTimeout(() => checkDueSubscriptions(), 30_000);
console.log("[rpayments] Subscription reminder scheduler started (hourly)");
}
async function checkDueSubscriptions() {
if (!_syncServer) return;
const now = Date.now();
const transport = getSmtpTransport();
const allDocIds = _syncServer.listDocs?.() || [];
for (const docId of allDocIds) {
if (!docId.includes(":payments:")) continue;
try {
const doc = _syncServer.getDoc<PaymentRequestDoc>(docId);
if (!doc) continue;
const p = doc.payment;
if (p.status !== "pending") continue;
if (!p.interval || !p.nextDueAt) continue;
if (p.nextDueAt > now) continue;
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;
const space = doc.meta.spaceSlug || "demo";
const host = "rspace.online";
const payUrl = `https://${space}.${host}/rpayments/pay/${p.id}`;
const senderName = p.creatorUsername || "Someone";
const displayAmount = (!p.amount || p.amount === "0") ? "a payment" : `${p.amount} ${p.token}`;
// ── 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,
);
_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(`[rpayments] Auto-pulled subscription payment for ${p.id}: ${txHash}`);
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(`[rpayments] Failed to send receipt for ${p.id}:`, err));
}
continue;
} catch (err) {
console.warn(`[rpayments] Auto-pull failed for ${p.id}:`, err);
}
}
}
// ── 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({
from: `"${senderName} via rSpace" <${fromAddr}>`,
to: p.subscriberEmail,
subject: `Payment reminder: ${displayAmount} due to ${senderName}`,
html,
});
console.log(`[rpayments] Sent subscription reminder for ${p.id} to ${p.subscriberEmail}`);
} catch (err) {
console.warn(`[rpayments] Failed to send reminder for ${p.id}:`, err);
}
} catch {
// Skip docs that fail to parse
}
}
}
// ── 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 &middot; <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
</td></tr>
</table></td></tr></table></body></html>`;
}
function buildReceiptEmail(sender: string, amount: string, txHash: string, chainId: number, payUrl: string): string {
const explorer = CHAIN_EXPLORERS[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">&#10003;</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 &middot; <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View subscription</a></p>
</td></tr>
</table></td></tr></table></body></html>`;
}
async function sendPaymentSuccessEmail(
email: string,
p: PaymentRequestMeta,
host: string,
space: string,
) {
const transport = getSmtpTransport();
if (!transport) {
console.warn("[rpayments] SMTP not configured — skipping payment email");
return;
}
const chainName = CHAIN_NAMES[p.chainId] || `Chain ${p.chainId}`;
const explorer = CHAIN_EXPLORERS[p.chainId];
const txLink = explorer && p.txHash
? `<a href="${explorer}${p.txHash}" style="color:#67e8f9;text-decoration:none">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>`
: (p.txHash || "N/A");
const paidDate = p.paidAt ? new Date(p.paidAt).toUTCString() : new Date().toUTCString();
const rflowsUrl = `${buildSpaceUrl(space, "/rflows")}`;
const dashboardUrl = `${buildSpaceUrl(space, "/rpayments")}`;
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,#06b6d4,#8b5cf6);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
<div style="font-size:48px;margin-bottom:8px">&#10003;</div>
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:700">Payment Received</h1>
</td></tr>
<tr><td style="background:#1a1a24;padding:28px 24px">
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px">
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px;width:120px">Amount</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px;font-weight:600">${p.amount} ${p.token}${p.fiatAmount ? ` (\u2248 $${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}</td></tr>
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Network</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${chainName}</td></tr>
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Method</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${p.paymentMethod || 'N/A'}</td></tr>
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Transaction</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;font-size:14px">${txLink}</td></tr>
<tr><td style="padding:10px 0;color:#9ca3af;font-size:14px">Date</td>
<td style="padding:10px 0;color:#f0f0f5;font-size:14px">${paidDate}</td></tr>
</table>
<div style="background:#12121a;border:1px solid #2a2a3a;border-radius:8px;padding:20px;margin-bottom:24px">
<h2 style="margin:0 0 8px;color:#f0f0f5;font-size:16px;font-weight:600">What happens next</h2>
<p style="margin:0 0 16px;color:#9ca3af;font-size:14px;line-height:1.6">
Your contribution flows into a funding flow that distributes resources across the project.
Track how funds are allocated in real time.
</p>
<a href="${rflowsUrl}" style="display:inline-block;padding:10px 24px;background:linear-gradient(135deg,#06b6d4,#8b5cf6);color:#fff;font-size:14px;font-weight:600;text-decoration:none;border-radius:8px">View rFlows</a>
</div>
<div style="margin-bottom:8px">
<h2 style="margin:0 0 10px;color:#f0f0f5;font-size:16px;font-weight:600">Resources</h2>
<p style="margin:0 0 6px;font-size:14px;line-height:1.6">
<a href="https://psilo-cyber.net/ics" style="color:#67e8f9;text-decoration:none">Interplanetary Coordination System</a>
</p>
<p style="margin:0;color:#6b7280;font-size:13px;line-height:1.6">
Endosymbiotic Finance (coming soon)
</p>
</div>
</td></tr>
<tr><td style="background:#12121a;border-radius:0 0 12px 12px;padding:20px 24px;text-align:center;border-top:1px solid #2a2a3a">
<p style="margin:0;color:#6b7280;font-size:12px">
Sent by <a href="${dashboardUrl}" style="color:#67e8f9;text-decoration:none">rSpace</a>
</p>
</td></tr>
</table></td></tr></table></body></html>`;
const text = [
`Payment Received`,
``,
`Amount: ${p.amount} ${p.token}${p.fiatAmount ? ` (~$${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}`,
`Network: ${chainName}`,
`Method: ${p.paymentMethod || 'N/A'}`,
`Transaction: ${p.txHash || 'N/A'}`,
`Date: ${paidDate}`,
``,
`What happens next:`,
`Your contribution flows into a funding flow that distributes resources across the project.`,
`View rFlows: ${rflowsUrl}`,
``,
`Resources:`,
`Interplanetary Coordination System: https://psilo-cyber.net/ics`,
`Endosymbiotic Finance (coming soon)`,
``,
`Sent by rSpace — ${dashboardUrl}`,
].join('\n');
await transport.sendMail({
from: process.env.SMTP_FROM || 'rSpace <noreply@rmail.online>',
to: email,
subject: `Payment confirmed \u2014 ${p.amount} ${p.token}`,
html,
text,
});
}
async function sendPaymentReceivedEmail(
email: string,
p: PaymentRequestMeta,
host: string,
space: string,
payerEmail?: string,
) {
const transport = getSmtpTransport();
if (!transport) return;
const chainName = CHAIN_NAMES[p.chainId] || `Chain ${p.chainId}`;
const explorer = CHAIN_EXPLORERS[p.chainId];
const txLink = explorer && p.txHash
? `<a href="${explorer}${p.txHash}" style="color:#67e8f9;text-decoration:none">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>`
: (p.txHash || 'N/A');
const paidDate = p.paidAt ? new Date(p.paidAt).toUTCString() : new Date().toUTCString();
const dashboardUrl = `${buildSpaceUrl(space, "/rpayments")}`;
const payerLabel = payerEmail || p.payerIdentity?.slice(0, 10) + '...' || 'Anonymous';
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,#10b981,#06b6d4);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
<div style="font-size:48px;margin-bottom:8px">&#128176;</div>
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:700">You Received a Payment</h1>
</td></tr>
<tr><td style="background:#1a1a24;padding:28px 24px">
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px">
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px;width:120px">Amount</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px;font-weight:600">${p.amount} ${p.token}${p.fiatAmount ? ` (\u2248 $${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}</td></tr>
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">From</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${payerLabel}</td></tr>
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Network</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${chainName}</td></tr>
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Transaction</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;font-size:14px">${txLink}</td></tr>
<tr><td style="padding:10px 0;color:#9ca3af;font-size:14px">Date</td>
<td style="padding:10px 0;color:#f0f0f5;font-size:14px">${paidDate}</td></tr>
</table>
<div style="text-align:center;margin-bottom:8px">
<a href="${dashboardUrl}" style="display:inline-block;padding:10px 24px;background:linear-gradient(135deg,#10b981,#06b6d4);color:#fff;font-size:14px;font-weight:600;text-decoration:none;border-radius:8px">View Dashboard</a>
</div>
</td></tr>
<tr><td style="background:#12121a;border-radius:0 0 12px 12px;padding:20px 24px;text-align:center;border-top:1px solid #2a2a3a">
<p style="margin:0;color:#6b7280;font-size:12px">
Sent by <a href="${dashboardUrl}" style="color:#67e8f9;text-decoration:none">rSpace</a>
</p>
</td></tr>
</table></td></tr></table></body></html>`;
const text = [
`You Received a Payment`,
``,
`Amount: ${p.amount} ${p.token}${p.fiatAmount ? ` (~$${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}`,
`From: ${payerLabel}`,
`Network: ${chainName}`,
`Transaction: ${p.txHash || 'N/A'}`,
`Date: ${paidDate}`,
``,
`View Dashboard: ${dashboardUrl}`,
``,
`Sent by rSpace — ${dashboardUrl}`,
].join('\n');
await transport.sendMail({
from: process.env.SMTP_FROM || 'rSpace <noreply@rmail.online>',
to: email,
subject: `Payment received \u2014 ${p.amount} ${p.token}`,
html,
text,
});
console.log(`[rpayments] Payment received email sent to ${email}`);
}
// ── Utility helpers ──
function getSpacePaymentDocs(space: string): Array<{ docId: string; doc: Automerge.Doc<PaymentRequestDoc> }> {
const prefix = `${space}:payments:`;
const results: Array<{ docId: string; doc: Automerge.Doc<PaymentRequestDoc> }> = [];
for (const id of _syncServer!.listDocs()) {
if (id.startsWith(prefix)) {
const doc = _syncServer!.getDoc<PaymentRequestDoc>(id);
if (doc) results.push({ docId: id, doc });
}
}
return results;
}
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);
}
function paymentToResponse(p: PaymentRequestMeta) {
return {
id: p.id,
description: p.description,
amount: p.amount,
amountEditable: p.amountEditable,
token: p.token,
chainId: p.chainId,
recipientAddress: p.recipientAddress,
fiatAmount: p.fiatAmount,
fiatCurrency: p.fiatCurrency,
status: p.status,
paymentMethod: p.paymentMethod,
txHash: p.txHash,
payerIdentity: p.payerIdentity,
transakOrderId: p.transakOrderId,
paymentType: p.paymentType || 'single',
maxPayments: p.maxPayments || 0,
paymentCount: p.paymentCount || 0,
enabledMethods: p.enabledMethods || { card: true, wallet: true, encryptid: true },
creatorUsername: p.creatorUsername || '',
linkedCartId: p.linkedCartId || null,
interval: p.interval || null,
nextDueAt: p.nextDueAt ? new Date(p.nextDueAt).toISOString() : null,
paymentHistory: (p.paymentHistory || []).map(h => ({
txHash: h.txHash,
paymentMethod: h.paymentMethod,
amount: h.amount,
paidAt: new Date(h.paidAt).toISOString(),
})),
created_at: new Date(p.createdAt).toISOString(),
updated_at: new Date(p.updatedAt).toISOString(),
paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,
expires_at: p.expiresAt ? new Date(p.expiresAt).toISOString() : null,
};
}
// ── Legacy doc migration ──
/** One-time migration: copy `{space}:cart:payments:{id}` docs to `{space}:payments:{id}`. */
function migrateLegacyPaymentDocs(syncServer: SyncServer): void {
if (!syncServer.listDocs) return;
let migrated = 0;
for (const docId of syncServer.listDocs()) {
const idx = docId.indexOf(":cart:payments:");
if (idx <= 0) continue;
const space = docId.slice(0, idx);
const paymentId = docId.slice(idx + ":cart:payments:".length);
if (!paymentId) continue;
const newId = paymentRequestDocId(space, paymentId);
if (syncServer.getDoc(newId)) continue;
const doc = syncServer.getDoc<PaymentRequestDoc>(docId);
if (!doc) continue;
syncServer.setDoc(newId, doc);
migrated++;
}
if (migrated > 0) {
console.log(`[rpayments] Migrated ${migrated} legacy payment doc(s) from :cart:payments: → :payments:`);
}
}
// ── Routes ──
const routes = new Hono();
// POST /api/payments — Create payment request (auth required)
routes.post("/api/payments", async (c) => {
const space = c.req.param("space") || "demo";
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json();
const {
description, amount, amountEditable = false,
token: payToken = 'USDC',
chainId = 8453, recipientAddress,
fiatAmount = null, fiatCurrency = 'USD',
expiresIn = 0,
paymentType = 'single',
maxPayments = 0,
enabledMethods = { card: true, wallet: true, encryptid: true },
interval = null,
linkedCartId = null,
} = body;
if (!description || !recipientAddress) {
return c.json({ error: "Required: description, recipientAddress" }, 400);
}
if (!amountEditable && !amount) {
return c.json({ error: "Required: amount (or set amountEditable: true)" }, 400);
}
const paymentId = crypto.randomUUID();
const now = Date.now();
const expiresAt = expiresIn > 0 ? now + expiresIn * 1000 : 0;
const docId = paymentRequestDocId(space, paymentId);
const payDoc = Automerge.change(Automerge.init<PaymentRequestDoc>(), 'create payment request', (d) => {
const init = paymentRequestSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
d.payment.id = paymentId;
d.payment.description = description;
d.payment.amount = amount ? String(amount) : '0';
d.payment.amountEditable = !!amountEditable;
d.payment.token = payToken;
d.payment.chainId = chainId;
d.payment.recipientAddress = recipientAddress;
d.payment.fiatAmount = fiatAmount ? String(fiatAmount) : null;
d.payment.fiatCurrency = fiatCurrency;
d.payment.creatorDid = claims.sub;
d.payment.creatorUsername = claims.username || '';
d.payment.status = 'pending';
d.payment.paymentType = (['single', 'subscription', 'payer_choice'].includes(paymentType)) ? paymentType : 'single';
d.payment.maxPayments = Math.max(0, parseInt(maxPayments) || 0);
d.payment.paymentCount = 0;
d.payment.enabledMethods = {
card: enabledMethods.card !== false,
wallet: enabledMethods.wallet !== false,
encryptid: enabledMethods.encryptid !== false,
};
const validIntervals = ['weekly', 'biweekly', 'monthly', 'quarterly', 'yearly'];
if (interval && validIntervals.includes(interval)) {
d.payment.interval = interval;
}
d.payment.linkedCartId = linkedCartId || null;
d.payment.createdAt = now;
d.payment.updatedAt = now;
d.payment.expiresAt = expiresAt;
});
_syncServer!.setDoc(docId, payDoc);
const payUrl = `${buildSpaceUrl(space, "/rpayments")}/pay/${paymentId}`;
return c.json({
id: paymentId,
description,
amount: String(amount),
token: payToken,
chainId,
recipientAddress,
status: 'pending',
payUrl,
qrUrl: `${buildSpaceUrl(space, "/rpayments")}/api/payments/${paymentId}/qr`,
created_at: new Date(now).toISOString(),
}, 201);
});
// GET /api/payments — List my payment requests (auth required)
routes.get("/api/payments", async (c) => {
const space = c.req.param("space") || "demo";
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const paymentDocs = getSpacePaymentDocs(space);
const payments = paymentDocs
.map(({ doc }) => doc.payment)
.filter((p) => p.creatorDid === claims.sub)
.sort((a, b) => b.createdAt - a.createdAt)
.map((p) => ({
id: p.id,
description: p.description,
amount: p.amount,
token: p.token,
chainId: p.chainId,
recipientAddress: p.recipientAddress,
fiatAmount: p.fiatAmount,
fiatCurrency: p.fiatCurrency,
status: p.status,
paymentMethod: p.paymentMethod,
txHash: p.txHash,
created_at: new Date(p.createdAt).toISOString(),
paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,
}));
return c.json({ payments });
});
// GET /api/payments/:id — Get payment details (public)
routes.get("/api/payments/:id", 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.expiresAt > 0 && Date.now() > p.expiresAt && p.status === 'pending') {
_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'expire payment', (d) => {
d.payment.status = 'expired';
d.payment.updatedAt = Date.now();
});
return c.json({
...paymentToResponse(p),
status: 'expired',
usdcAddress: USDC_ADDRESSES[p.chainId] || null,
});
}
if (p.maxPayments > 0 && p.paymentCount >= p.maxPayments && p.status === 'pending') {
_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'fill payment', (d) => {
d.payment.status = 'filled';
d.payment.updatedAt = Date.now();
});
return c.json({
...paymentToResponse(p),
status: 'filled',
usdcAddress: USDC_ADDRESSES[p.chainId] || null,
});
}
return c.json({
...paymentToResponse(p),
usdcAddress: USDC_ADDRESSES[p.chainId] || null,
});
});
// PATCH /api/payments/:id/status — Update payment status
routes.patch("/api/payments/:id/status", 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 body = await c.req.json();
const { status, txHash, paymentMethod, payerIdentity, transakOrderId, amount, chosenPaymentType, payerEmail } = body;
const validStatuses = ['pending', 'paid', 'confirmed', 'expired', 'cancelled', 'filled'];
if (status && !validStatuses.includes(status)) {
return c.json({ error: `status must be one of: ${validStatuses.join(", ")}` }, 400);
}
const p = doc.payment;
if (status === 'paid' && p.maxPayments > 0 && p.paymentCount >= p.maxPayments) {
return c.json({ error: "This payment request has reached its limit" }, 400);
}
const now = Date.now();
_syncServer!.changeDoc<PaymentRequestDoc>(docId, `payment status → ${status || 'update'}`, (d) => {
if (status) d.payment.status = status;
if (txHash) d.payment.txHash = txHash;
if (paymentMethod) d.payment.paymentMethod = paymentMethod;
if (payerIdentity) d.payment.payerIdentity = payerIdentity;
if (transakOrderId) d.payment.transakOrderId = transakOrderId;
if (amount && d.payment.amountEditable && d.payment.status === 'pending') {
d.payment.amount = String(amount);
}
if (payerEmail && !d.payment.subscriberEmail) {
d.payment.subscriberEmail = payerEmail;
}
d.payment.updatedAt = now;
if (status === 'paid') {
d.payment.paidAt = now;
d.payment.paymentCount = (d.payment.paymentCount || 0) + 1;
if (!d.payment.paymentHistory) d.payment.paymentHistory = [] as any;
(d.payment.paymentHistory as any).push({
txHash: txHash || null,
transakOrderId: transakOrderId || null,
paymentMethod: paymentMethod || null,
payerIdentity: payerIdentity || null,
payerEmail: payerEmail || null,
amount: d.payment.amount,
paidAt: now,
});
const effectiveType = d.payment.paymentType === 'payer_choice'
? (chosenPaymentType === 'subscription' ? 'subscription' : 'single')
: d.payment.paymentType;
const isRecurring = effectiveType === 'subscription' || d.payment.maxPayments > 1;
if (isRecurring) {
if (d.payment.maxPayments > 0 && d.payment.paymentCount >= d.payment.maxPayments) {
d.payment.status = 'filled';
} else {
d.payment.status = 'pending';
if (d.payment.interval) {
d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval);
}
}
}
}
});
const updated = _syncServer!.getDoc<PaymentRequestDoc>(docId);
if (status === 'paid') {
const host = c.req.header("host") || "rspace.online";
const isValidEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
(async () => {
let resolvedPayerEmail = payerEmail;
if (!resolvedPayerEmail && payerIdentity) {
const lookup = await lookupEncryptIDEmail(payerIdentity);
if (lookup.email) resolvedPayerEmail = lookup.email;
}
if (resolvedPayerEmail && isValidEmail(resolvedPayerEmail)) {
sendPaymentSuccessEmail(resolvedPayerEmail, updated!.payment, host, space)
.catch((err) => console.error('[rpayments] payer email failed:', err));
}
const creatorDid = updated!.payment.creatorDid;
if (creatorDid) {
const creatorLookup = await lookupEncryptIDEmail(creatorDid);
if (creatorLookup.email && isValidEmail(creatorLookup.email)) {
sendPaymentReceivedEmail(creatorLookup.email, updated!.payment, host, space, resolvedPayerEmail)
.catch((err) => console.error('[rpayments] recipient email failed:', err));
}
}
})().catch((err) => console.error('[rpayments] payment email resolution failed:', err));
// Notify cross-module handlers (e.g. rCart auto-records contribution on linked shopping cart)
firePaymentPaid({ docId, space, payment: updated!.payment });
}
return c.json(paymentToResponse(updated!.payment));
});
// GET /api/payments/:id/qr — QR code SVG
routes.get("/api/payments/:id/qr", 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 payUrl = `${buildSpaceUrl(space, "/rpayments")}/pay/${paymentId}`;
const svg = await QRCode.toString(payUrl, { type: 'svg', margin: 2 });
return c.body(svg, 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' });
});
// POST /api/payments/:id/transak-session — Get Transak widget URL (public)
routes.post("/api/payments/:id/transak-session", 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.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);
if (p.enabledMethods && !p.enabledMethods.card) return c.json({ error: "Card payments are not enabled for this request" }, 400);
const { email, amount: overrideAmount } = await c.req.json();
if (!email) return c.json({ error: "Required: email" }, 400);
const transakApiKey = getTransakApiKey();
if (!transakApiKey) return c.json({ error: "Transak not configured" }, 503);
const networkMap: Record<number, string> = { 8453: 'base', 84532: 'base', 1: 'ethereum' };
const host = c.req.header('x-forwarded-host') || c.req.header('host') || new URL(c.req.url).hostname;
const effectiveAmount = (p.amountEditable && overrideAmount) ? String(overrideAmount) : p.amount;
const widgetParams: Record<string, string> = {
apiKey: transakApiKey,
referrerDomain: extractRootDomain(host),
cryptoCurrencyCode: p.token,
network: networkMap[p.chainId] || 'base',
defaultCryptoCurrency: p.token,
walletAddress: p.recipientAddress,
disableWalletAddressForm: 'true',
defaultCryptoAmount: effectiveAmount,
partnerOrderId: `pay-${paymentId}`,
email,
isAutoFillUserData: 'true',
hideExchangeScreen: 'true',
paymentMethod: 'credit_debit_card',
themeColor: '6366f1',
colorMode: 'DARK',
hideMenu: 'true',
};
const inferredFiat = p.fiatAmount || (USD_STABLECOINS.includes(p.token) ? effectiveAmount : null);
if (inferredFiat) {
widgetParams.fiatAmount = inferredFiat;
widgetParams.defaultFiatAmount = inferredFiat;
}
const fiatCcy = p.fiatCurrency || (USD_STABLECOINS.includes(p.token) ? 'USD' : null);
if (fiatCcy) {
widgetParams.fiatCurrency = fiatCcy;
widgetParams.defaultFiatCurrency = fiatCcy;
}
const widgetUrl = await createSecureWidgetUrl(widgetParams);
const transakEnv = getTransakEnv();
return c.json({ widgetUrl, env: transakEnv });
});
// POST /api/payments/:id/card-session — On-ramp widget URL (MoonPay preferred, Transak fallback)
routes.post("/api/payments/:id/card-session", 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.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);
if (p.enabledMethods && !p.enabledMethods.card) return c.json({ error: "Card payments are not enabled for this request" }, 400);
const { email, amount: overrideAmount } = await c.req.json();
if (!email) return c.json({ error: "Required: email" }, 400);
const effectiveAmount = (p.amountEditable && overrideAmount) ? String(overrideAmount) : p.amount;
const moonPayKey = getMoonPayApiKey();
if (moonPayKey) {
try {
const widgetUrl = createMoonPayPaymentUrl({
walletAddress: p.recipientAddress,
token: p.token,
chainId: p.chainId,
amount: effectiveAmount,
fiatAmount: p.fiatAmount || undefined,
fiatCurrency: p.fiatCurrency || 'USD',
email,
paymentId,
});
return c.json({ widgetUrl, provider: 'moonpay', env: getMoonPayEnv() });
} catch (err) {
console.error('[rpayments] MoonPay URL generation failed:', err);
}
}
const transakApiKey = getTransakApiKey();
if (!transakApiKey) return c.json({ error: "No payment provider configured" }, 503);
const networkMap: Record<number, string> = { 8453: 'base', 84532: 'base', 1: 'ethereum' };
const host = c.req.header('x-forwarded-host') || c.req.header('host') || new URL(c.req.url).hostname;
const widgetParams: Record<string, string> = {
apiKey: transakApiKey,
referrerDomain: extractRootDomain(host),
cryptoCurrencyCode: p.token,
network: networkMap[p.chainId] || 'base',
defaultCryptoCurrency: p.token,
walletAddress: p.recipientAddress,
disableWalletAddressForm: 'true',
defaultCryptoAmount: effectiveAmount,
partnerOrderId: `pay-${paymentId}`,
email,
isAutoFillUserData: 'true',
hideExchangeScreen: 'true',
paymentMethod: 'credit_debit_card',
themeColor: '6366f1',
colorMode: 'DARK',
hideMenu: 'true',
};
{
const inferredFiat = p.fiatAmount || (USD_STABLECOINS.includes(p.token) ? effectiveAmount : null);
if (inferredFiat) {
widgetParams.fiatAmount = inferredFiat;
widgetParams.defaultFiatAmount = inferredFiat;
}
const fiatCcy = p.fiatCurrency || (USD_STABLECOINS.includes(p.token) ? 'USD' : null);
if (fiatCcy) {
widgetParams.fiatCurrency = fiatCcy;
widgetParams.defaultFiatCurrency = fiatCcy;
}
}
const widgetUrl = await createSecureWidgetUrl(widgetParams);
return c.json({ widgetUrl, provider: 'transak', env: getTransakEnv() });
});
// POST /api/payments/:id/share-email — Email payment link to recipients
routes.post("/api/payments/:id/share-email", 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.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);
const { emails } = await c.req.json();
if (!Array.isArray(emails) || emails.length === 0) return c.json({ error: "Required: emails array" }, 400);
if (emails.length > 50) return c.json({ error: "Maximum 50 recipients per request" }, 400);
const transport = getSmtpTransport();
if (!transport) return c.json({ error: "Email not configured" }, 503);
const payUrl = `${buildSpaceUrl(space, "/rpayments")}/pay/${paymentId}`;
const chainNames: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
const chainName = chainNames[p.chainId] || `Chain ${p.chainId}`;
const displayAmount = (!p.amount || p.amount === '0') && p.amountEditable ? 'any amount' : `${p.amount} ${p.token}`;
const senderName = p.creatorUsername || 'Someone';
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,#06b6d4,#8b5cf6);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
<h1 style="color:#fff;font-size:24px;margin:0">Payment Request</h1>
<p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px">from ${senderName}</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">
<strong>${senderName}</strong> has sent you a payment request${p.description ? ` for <strong>${p.description}</strong>` : ''}.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px">
<tr><td style="color:#94a3b8;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d">Amount</td>
<td style="color:#fff;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d;text-align:right;font-weight:600">${displayAmount}</td></tr>
<tr><td style="color:#94a3b8;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d">Network</td>
<td style="color:#fff;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d;text-align:right">${chainName}</td></tr>
<tr><td style="color:#94a3b8;font-size:13px;padding:8px 0">Wallet</td>
<td style="color:#fff;font-size:13px;padding:8px 0;text-align:right;font-family:monospace">${p.recipientAddress.slice(0, 8)}...${p.recipientAddress.slice(-6)}</td></tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center">
<a href="${payUrl}" style="display:inline-block;background:linear-gradient(135deg,#06b6d4,#8b5cf6);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 &middot; <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
let sent = 0;
const validEmails = emails.filter((e: string) => typeof e === 'string' && e.includes('@'));
for (const email of validEmails) {
try {
await transport.sendMail({
from: `"${senderName} via rSpace" <${fromAddr}>`,
to: email.trim(),
subject: `Payment request from ${senderName}${p.description ? `: ${p.description}` : ''}`,
html,
});
sent++;
} catch (err) {
console.warn(`[rpayments] Failed to send payment email to ${email}:`, err);
}
}
return c.json({ sent, total: validEmails.length });
});
// 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;
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);
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);
}
const now = Date.now();
_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'register subscription', (d) => {
d.payment.subscriberEmail = email || null;
d.payment.payerIdentity = payerAddress;
if (d.payment.interval) {
d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval);
}
d.payment.updatedAt = now;
});
if (txHash) {
_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'record initial payment', (d) => {
d.payment.status = 'pending';
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,
});
});
// ── Page routes ──
// GET /payments — Dashboard
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `Payments | rPayments`,
moduleId: "rpayments",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-payments-dashboard space="${space}"></folk-payments-dashboard>`,
scripts: `<script type="module" src="/modules/rpayments/folk-payments-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rpayments/payments.css">`,
}));
});
// GET /request — Self-service payment request creator
routes.get("/request", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `Request Payment | rPayments`,
moduleId: "rpayments",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-payment-request space="${space}"></folk-payment-request>`,
scripts: `<script type="module" src="/modules/rpayments/folk-payment-request.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rpayments/payments.css">`,
}));
});
// GET /pay/:id — Public pay page (no auth required)
routes.get("/pay/:id", (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) {
const p = doc.payment;
const terminalStates: Record<string, { title: string; msg: string; icon: string }> = {
paid: { title: 'Payment Complete', msg: 'This payment request has already been paid.', icon: '&#10003;' },
confirmed: { title: 'Payment Confirmed', msg: 'This payment has been confirmed on-chain.', icon: '&#10003;' },
expired: { title: 'Payment Expired', msg: 'This payment request has expired and is no longer accepting payments.', icon: '&#9202;' },
cancelled: { title: 'Payment Cancelled', msg: 'This payment request has been cancelled by the creator.', icon: '&#10007;' },
filled: { title: 'Payment Limit Reached', msg: 'This payment request has reached its maximum number of payments.', icon: '&#10003;' },
};
const info = terminalStates[p.status];
if (info) {
const chainNames: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
const explorerBase: Record<number, string> = { 8453: 'https://basescan.org/tx/', 84532: 'https://sepolia.basescan.org/tx/', 1: 'https://etherscan.io/tx/' };
const txLink = p.txHash && explorerBase[p.chainId]
? `<a href="${explorerBase[p.chainId]}${p.txHash}" target="_blank" rel="noopener" style="color:#60a5fa;text-decoration:underline">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>`
: '';
return c.html(renderShell({
title: `${info.title} | rPayments`,
moduleId: "rpayments",
spaceSlug: space,
spaceVisibility: "public",
modules: getModuleInfoList(),
theme: "dark",
body: `
<div style="max-width:480px;margin:60px auto;padding:32px;text-align:center;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#e2e8f0">
<div style="font-size:48px;margin-bottom:16px;color:${p.status === 'paid' || p.status === 'confirmed' || p.status === 'filled' ? '#4ade80' : p.status === 'expired' ? '#fbbf24' : '#f87171'}">${info.icon}</div>
<h1 style="font-size:24px;font-weight:600;margin:0 0 12px">${info.title}</h1>
<p style="color:#94a3b8;font-size:15px;line-height:1.6;margin:0 0 24px">${info.msg}</p>
${p.amount && p.amount !== '0' ? `<div style="font-size:20px;font-weight:600;margin-bottom:8px">${p.amount} ${p.token}</div>` : ''}
${p.fiatAmount ? `<div style="color:#94a3b8;font-size:14px;margin-bottom:16px">&asymp; $${p.fiatAmount} ${p.fiatCurrency || 'USD'}</div>` : ''}
${chainNames[p.chainId] ? `<div style="color:#64748b;font-size:13px;margin-bottom:8px">Network: ${chainNames[p.chainId]}</div>` : ''}
${txLink ? `<div style="font-size:13px;margin-bottom:8px">Tx: ${txLink}</div>` : ''}
${p.paidAt ? `<div style="color:#64748b;font-size:13px">Paid: ${new Date(p.paidAt).toLocaleString()}</div>` : ''}
</div>`,
styles: `<link rel="stylesheet" href="/modules/rpayments/payments.css">`,
}));
}
}
return c.html(renderShell({
title: `Payment | rPayments`,
moduleId: "rpayments",
spaceSlug: space,
spaceVisibility: "public",
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-payment-page space="${space}" payment-id="${paymentId}"></folk-payment-page>`,
scripts: `<script type="module" src="/modules/rpayments/folk-payment-page.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rpayments/payments.css">`,
}));
});
// ── Module definition ──
export const paymentsModule: RSpaceModule = {
id: "rpayments",
name: "rPayments",
icon: "💳",
description: "QR payment requests, subscriptions, and card on-ramp",
publicWrite: true,
scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [
{ pattern: '{space}:payments:{paymentId}', description: 'Payment request', init: paymentRequestSchema.init },
],
routes,
landingPage: renderLanding,
async onInit(ctx) {
_syncServer = ctx.syncServer;
migrateLegacyPaymentDocs(ctx.syncServer);
startSubscriptionScheduler();
},
feeds: [
{
id: "payments",
name: "Payments",
kind: "economic",
description: "Payment request stream with amounts, status, and payer details",
filterable: true,
},
],
acceptsFeeds: ["economic"],
outputPaths: [
{ path: "", name: "Dashboard", icon: "💳", description: "Your payment requests and history" },
{ path: "request", name: "New Request", icon: "", description: "Create a QR payment request" },
],
onboardingActions: [
{ label: "Create Payment Request", icon: "💳", description: "Generate a QR code to get paid", type: 'create', href: '/{space}/rpayments/request' },
],
};