1294 lines
53 KiB
TypeScript
1294 lines
53 KiB
TypeScript
/**
|
||
* 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 · <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">✓</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>`;
|
||
}
|
||
|
||
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">✓</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">💰</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 · <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: '✓' },
|
||
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<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">≈ $${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' },
|
||
],
|
||
};
|