/** * 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 = { 8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", 84532: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", 1: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", }; const CHAIN_NAMES: Record = { 8453: "Base", 84532: "Base Sepolia", 1: "Ethereum" }; const CHAIN_EXPLORERS: Record = { 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; 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 = { 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 | 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(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(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 `

Payment Reminder

Your ${interval} payment is due

Your recurring payment of ${amount} to ${sender} is due.

Payment ${count + 1}${maxPayments > 0 ? ` of ${maxPayments}` : ''}

Pay Now

Powered by rSpace · View payment page

`; } function buildReceiptEmail(sender: string, amount: string, txHash: string, chainId: number, payUrl: string): string { const explorer = CHAIN_EXPLORERS[chainId]; const txLink = explorer ? `${txHash.slice(0, 10)}...${txHash.slice(-8)}` : txHash.slice(0, 18) + '...'; return `

Subscription Payment Processed

Your recurring payment of ${amount} to ${sender} has been automatically processed.

Transaction ${txLink}

Powered by rSpace · View subscription

`; } 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 ? `${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}` : (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 = `

Payment Received

Amount ${p.amount} ${p.token}${p.fiatAmount ? ` (\u2248 $${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}
Network ${chainName}
Method ${p.paymentMethod || 'N/A'}
Transaction ${txLink}
Date ${paidDate}

What happens next

Your contribution flows into a funding flow that distributes resources across the project. Track how funds are allocated in real time.

View rFlows

Resources

Interplanetary Coordination System

Endosymbiotic Finance (coming soon)

Sent by rSpace

`; 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 ', 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 ? `${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}` : (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 = `
💰

You Received a Payment

Amount ${p.amount} ${p.token}${p.fiatAmount ? ` (\u2248 $${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}
From ${payerLabel}
Network ${chainName}
Transaction ${txLink}
Date ${paidDate}

Sent by rSpace

`; 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 ', 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 }> { const prefix = `${space}:payments:`; const results: Array<{ docId: string; doc: Automerge.Doc }> = []; for (const id of _syncServer!.listDocs()) { if (id.startsWith(prefix)) { const doc = _syncServer!.getDoc(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(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(), '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(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(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(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(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(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(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(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(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 = { 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 = { 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(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 = { 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 = { 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(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 = { 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 = `

Payment Request

from ${senderName}

${senderName} has sent you a payment request${p.description ? ` for ${p.description}` : ''}.

Amount ${displayAmount}
Network ${chainName}
Wallet ${p.recipientAddress.slice(0, 8)}...${p.recipientAddress.slice(-6)}
Pay Now

Powered by rSpace · View payment page

`; 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(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(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(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(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: ``, scripts: ``, styles: ``, })); }); // 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: ``, scripts: ``, styles: ``, })); }); // 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(docId); if (doc) { const p = doc.payment; const terminalStates: Record = { paid: { title: 'Payment Complete', msg: 'This payment request has already been paid.', icon: '✓' }, confirmed: { title: 'Payment Confirmed', msg: 'This payment has been confirmed on-chain.', icon: '✓' }, expired: { title: 'Payment Expired', msg: 'This payment request has expired and is no longer accepting payments.', icon: '⏲' }, cancelled: { title: 'Payment Cancelled', msg: 'This payment request has been cancelled by the creator.', icon: '✗' }, filled: { title: 'Payment Limit Reached', msg: 'This payment request has reached its maximum number of payments.', icon: '✓' }, }; const info = terminalStates[p.status]; if (info) { const chainNames: Record = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' }; const explorerBase: Record = { 8453: 'https://basescan.org/tx/', 84532: 'https://sepolia.basescan.org/tx/', 1: 'https://etherscan.io/tx/' }; const txLink = p.txHash && explorerBase[p.chainId] ? `${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}` : ''; return c.html(renderShell({ title: `${info.title} | rPayments`, moduleId: "rpayments", spaceSlug: space, spaceVisibility: "public", modules: getModuleInfoList(), theme: "dark", body: `
${info.icon}

${info.title}

${info.msg}

${p.amount && p.amount !== '0' ? `
${p.amount} ${p.token}
` : ''} ${p.fiatAmount ? `
≈ $${p.fiatAmount} ${p.fiatCurrency || 'USD'}
` : ''} ${chainNames[p.chainId] ? `
Network: ${chainNames[p.chainId]}
` : ''} ${txLink ? `
Tx: ${txLink}
` : ''} ${p.paidAt ? `
Paid: ${new Date(p.paidAt).toLocaleString()}
` : ''}
`, styles: ``, })); } } return c.html(renderShell({ title: `Payment | rPayments`, moduleId: "rpayments", spaceSlug: space, spaceVisibility: "public", modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ``, })); }); // ── 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' }, ], };