From b80386e85c4d530bbe7a23e92cc168f762b279a5 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 18 Apr 2026 14:21:11 -0400 Subject: [PATCH] feat(rpayments): extract QR payment app from rCart into its own rApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New modules/rpayments/ with routes, schemas, components, landing, recurring-executor - Doc ID pattern {space}:payments:{paymentId}; one-time migration from {space}:cart:payments:* - Cross-module hook onPaymentPaid() — rCart registers handler in onInit to auto-record contributions on linked shopping carts when a contribute-pay payment settles - rCart: drop all /api/payments/*, /payments, /request, /pay/:id, /subscriptions, email scheduler, and the inline payments tab in folk-cart-shop; contribute-pay now mints via imported rpayments schemas and returns /rpayments/pay/ links - server/index.ts: register paymentsModule, flip public-endpoint matcher to /rpayments - Display: rPayments (💳) added to module-display, rstack-app-switcher (Commerce), rstack-tab-bar (Funding & Commerce), e2e/fixtures/module-list - Vite: 3 payment component build entries + payments.css repointed to modules/rpayments --- e2e/fixtures/module-list.ts | 1 + lib/module-display.ts | 1 + modules/rcart/components/cart.css | 12 +- modules/rcart/components/folk-cart-shop.ts | 133 +- modules/rcart/mod.ts | 1332 +---------------- modules/rcart/schemas.ts | 119 -- .../components/folk-payment-page.ts | 4 +- .../components/folk-payment-request.ts | 4 +- .../components/folk-payments-dashboard.ts | 12 +- modules/rpayments/components/payments.css | 19 + modules/rpayments/landing.ts | 147 ++ .../lib/recurring-executor.ts | 0 modules/rpayments/mod.ts | 1293 ++++++++++++++++ modules/rpayments/schemas.ts | 123 ++ server/index.ts | 6 +- server/mcp-tools/rcart.ts | 2 +- server/shell.ts | 2 +- shared/components/rstack-app-switcher.ts | 2 + shared/components/rstack-tab-bar.ts | 3 +- vite.config.ts | 69 +- 20 files changed, 1695 insertions(+), 1589 deletions(-) rename modules/{rcart => rpayments}/components/folk-payment-page.ts (99%) rename modules/{rcart => rpayments}/components/folk-payment-request.ts (99%) rename modules/{rcart => rpayments}/components/folk-payments-dashboard.ts (96%) create mode 100644 modules/rpayments/components/payments.css create mode 100644 modules/rpayments/landing.ts rename modules/{rcart => rpayments}/lib/recurring-executor.ts (100%) create mode 100644 modules/rpayments/mod.ts create mode 100644 modules/rpayments/schemas.ts diff --git a/e2e/fixtures/module-list.ts b/e2e/fixtures/module-list.ts index ca7eb58f..e8abb653 100644 --- a/e2e/fixtures/module-list.ts +++ b/e2e/fixtures/module-list.ts @@ -14,6 +14,7 @@ export const MODULES: ModuleEntry[] = [ { id: "rbooks", name: "rBooks", primarySelector: "folk-book-shelf" }, { id: "rpubs", name: "rPubs", primarySelector: "folk-pubs-editor" }, { id: "rcart", name: "rCart", primarySelector: "folk-cart-shop" }, + { id: "rpayments", name: "rPayments", primarySelector: "folk-payments-dashboard" }, { id: "rswag", name: "rSwag", primarySelector: "folk-swag-designer" }, { id: "rchoices", name: "rChoices", primarySelector: "folk-choices-dashboard" }, { id: "rflows", name: "rFlows", primarySelector: "folk-flows-app" }, diff --git a/lib/module-display.ts b/lib/module-display.ts index c0a266e7..d8e5416d 100644 --- a/lib/module-display.ts +++ b/lib/module-display.ts @@ -25,6 +25,7 @@ export const MODULE_META: Record = { rwallet: { badge: "rW", color: "#fde047", name: "rWallet", icon: "💰" }, rvote: { badge: "rV", color: "#c4b5fd", name: "rVote", icon: "🗳️" }, rcart: { badge: "rCt", color: "#fdba74", name: "rCart", icon: "🛒" }, + rpayments: { badge: "rPa", color: "#86efac", name: "rPayments", icon: "💳" }, rdata: { badge: "rD", color: "#d8b4fe", name: "rData", icon: "📊" }, rnetwork: { badge: "rNe", color: "#93c5fd", name: "rNetwork", icon: "🌍" }, rsplat: { badge: "r3", color: "#d8b4fe", name: "rSplat", icon: "🔮" }, diff --git a/modules/rcart/components/cart.css b/modules/rcart/components/cart.css index a6cbb89a..0134bd60 100644 --- a/modules/rcart/components/cart.css +++ b/modules/rcart/components/cart.css @@ -4,18 +4,14 @@ main { } /* - * Narrow page components (payment page, request form, dashboard, group buy) - * set their own max-width; we center them with margin auto so subnav stays full-width. + * Narrow page components (group buy page) set their own max-width; we center + * them with margin auto so subnav stays full-width. */ -folk-payment-request, -folk-payment-page, -folk-group-buy-page, -folk-payments-dashboard { +folk-group-buy-page { margin: 0 auto; } -/* Hide the module subnav on public-facing pages — payers/pledgers don't need shop nav */ -main:has(folk-payment-page) .rapp-subnav, +/* Hide the module subnav on public-facing pages — pledgers don't need shop nav */ main:has(folk-group-buy-page) .rapp-subnav { display: none; } diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index e5ee4a7b..47f0fe99 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -20,9 +20,8 @@ class FolkCartShop extends HTMLElement { private catalog: any[] = []; private orders: any[] = []; private carts: any[] = []; - private payments: any[] = []; private groupBuys: any[] = []; - private view: "carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys" = "carts"; + private view: "carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "group-buys" = "carts"; private selectedCartId: string | null = null; private selectedCart: any = null; private selectedCatalogItem: any = null; @@ -35,17 +34,16 @@ class FolkCartShop extends HTMLElement { private contributingAmount = false; private extensionInstalled = false; private bannerDismissed = false; - private creatingPayment = false; private creatingGroupBuy = false; private _offlineUnsubs: (() => void)[] = []; private _subscribedDocIds: string[] = []; - private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts", "rcart"); + private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "group-buys">("carts", "rcart"); private _stopPresence: (() => void) | null = null; // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ - { target: "[data-action='new-cart']", title: "Create a Cart", message: "Start a new group cart — add a name and invite contributors to add items. Use the navigation bar above to switch between Carts, Catalog, Group Buys, Orders, and Payments.", advanceOnClick: true }, + { target: "[data-action='new-cart']", title: "Create a Cart", message: "Start a new group cart — add a name and invite contributors to add items. Use the navigation bar above to switch between Carts, Catalog, Group Buys, and Orders.", advanceOnClick: true }, ]; constructor() { @@ -73,7 +71,7 @@ class FolkCartShop extends HTMLElement { // Read initial view from attribute (set by server routes) or URL params const initView = this.getAttribute("initial-view"); - if (initView && ["carts","catalog","orders","order-detail","payments","group-buys","subscriptions"].includes(initView)) { + if (initView && ["carts","catalog","orders","order-detail","group-buys"].includes(initView)) { this.view = initView as any; } const params = new URLSearchParams(window.location.search); @@ -319,14 +317,6 @@ class FolkCartShop extends HTMLElement { }, ]; - this.payments = [ - { id: "demo-pay-1", description: "Order #1001 — #DefectFi Tee + stickers", amount: "30.00", token: "USDC", chainId: 8453, recipientAddress: "0x7a3b...9a0b", status: "paid", paymentMethod: "wallet", txHash: "0x7a3b9c1d2e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", created_at: new Date(now - 2 * 86400000).toISOString(), paid_at: new Date(now - 2 * 86400000 + 3600000).toISOString() }, - { id: "demo-pay-2", description: "Order #1003 — The Commons + zine", amount: "32.00", token: "USDC", chainId: 8453, recipientAddress: "0x1122...4556", status: "paid", paymentMethod: "wallet", txHash: "0x1122334455667788990011223344556677889900aabbccddeeff0011223344556", created_at: new Date(now - 5 * 86400000).toISOString(), paid_at: new Date(now - 5 * 86400000 + 1800000).toISOString() }, - { id: "demo-pay-3", description: "Order #1004 — patches + vinyl stickers", amount: "28.00", token: "ETH", chainId: 1, recipientAddress: "0xaabb...aabb", status: "paid", paymentMethod: "wallet", txHash: "0xaabbccdd11223344556677889900aabbccddeeff11223344556677889900aabb", created_at: new Date(now - 4 * 86400000).toISOString(), paid_at: new Date(now - 4 * 86400000 + 7200000).toISOString() }, - { id: "demo-pay-4", description: "Coffee tip", amount: "5.00", token: "USDC", chainId: 8453, recipientAddress: "0x1234...abcd", status: "paid", paymentMethod: "wallet", txHash: "0xfeed1234abcd5678ef901234abcd5678ef901234abcd5678ef901234abcd5678", created_at: new Date(now - 1 * 86400000).toISOString(), paid_at: new Date(now - 1 * 86400000).toISOString() }, - { id: "demo-pay-5", description: "Invoice #42", amount: "25.00", token: "USDC", chainId: 8453, recipientAddress: "0x1234...abcd", status: "pending", paymentMethod: null, txHash: null, created_at: new Date(now - 3600000).toISOString(), paid_at: null }, - ]; - this.groupBuys = [ { id: "demo-gb-1", title: "Cosmolocal Network Tee", productType: "tee", @@ -395,15 +385,6 @@ class FolkCartShop extends HTMLElement { this.orders = ordData.orders || []; this.carts = cartData.carts || []; - // Load payments (auth-gated, may fail for unauthenticated users) - try { - const payRes = await fetch(`${this.getApiBase()}/api/payments`); - if (payRes.ok) { - const payData = await payRes.json(); - this.payments = payData.payments || []; - } - } catch { /* unauthenticated */ } - // Load group buys try { const gbRes = await fetch(`${this.getApiBase()}/api/group-buys`); @@ -526,8 +507,6 @@ class FolkCartShop extends HTMLElement { content = this.renderCatalogDetail(); } else if (this.view === "order-detail") { content = this.renderOrderDetail(); - } else if (this.view === "payments") { - content = this.renderPayments(); } else if (this.view === "group-buys") { content = this.renderGroupBuys(); } else { @@ -640,25 +619,6 @@ class FolkCartShop extends HTMLElement { } }); - // Payment request actions - const newPaymentBtn = this.shadow.querySelector("[data-action='new-payment']"); - newPaymentBtn?.addEventListener("click", () => { - const form = this.shadow.querySelector(".new-payment-form") as HTMLElement; - if (form) form.style.display = form.style.display === 'none' ? 'flex' : 'none'; - }); - this.shadow.querySelector("[data-action='create-payment']")?.addEventListener("click", () => { - this.createPaymentRequest(); - }); - this.shadow.querySelectorAll("[data-action='copy-pay-url']").forEach((el) => { - el.addEventListener("click", () => { - const payId = (el as HTMLElement).dataset.payId; - const url = `${window.location.origin}${this.getApiBase()}/pay/${payId}`; - navigator.clipboard.writeText(url); - (el as HTMLElement).textContent = 'Copied!'; - setTimeout(() => { (el as HTMLElement).textContent = 'Copy Link'; }, 2000); - }); - }); - // Group buy card clicks → navigate to group buy page this.shadow.querySelectorAll("[data-group-buy-id]").forEach((el) => { el.addEventListener("click", () => { @@ -1307,91 +1267,6 @@ class FolkCartShop extends HTMLElement { `; } - // ── Payments view ── - - private renderPayments(): string { - const newPaymentForm = ` - `; - - if (this.payments.length === 0) { - return ` -
-

No payment requests yet. Create one to generate a shareable QR code.

- - ${newPaymentForm} -
`; - } - - const chainNames: Record = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' }; - - return ` -
- - ${newPaymentForm} -
-
- ${this.payments.map((pay) => ` -
-
-

${this.esc(pay.description)}

- ${pay.status} -
-
${this.esc(pay.amount)} ${this.esc(pay.token)}
-
${chainNames[pay.chainId] || 'Chain ' + pay.chainId}${pay.paymentMethod ? ' • via ' + pay.paymentMethod : ''}
-
${new Date(pay.created_at).toLocaleDateString()}
- ${pay.status === 'pending' ? ` -
- Open - - QR -
` : ''} - ${pay.txHash ? `
Tx: ${pay.txHash.slice(0, 10)}...${pay.txHash.slice(-6)}
` : ''} -
- `).join("")} -
`; - } - - private async createPaymentRequest() { - const desc = (this.shadow.querySelector('[data-field="pay-desc"]') as HTMLInputElement)?.value; - const amount = (this.shadow.querySelector('[data-field="pay-amount"]') as HTMLInputElement)?.value; - const token = (this.shadow.querySelector('[data-field="pay-token"]') as HTMLSelectElement)?.value || 'USDC'; - const recipient = (this.shadow.querySelector('[data-field="pay-recipient"]') as HTMLInputElement)?.value; - - if (!desc || !amount || !recipient) return; - - this.creatingPayment = true; - this.render(); - - try { - await fetch(`${this.getApiBase()}/api/payments`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - description: desc, - amount, - token, - recipientAddress: recipient, - chainId: 8453, - }), - }); - await this.loadData(); - } catch (e) { - console.error("Failed to create payment request:", e); - } - this.creatingPayment = false; - } - // ── Styles ── private getStyles(): string { diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 15fe0fde..a76aa464 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -19,71 +19,24 @@ import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { catalogSchema, orderSchema, shoppingCartSchema, shoppingCartIndexSchema, - paymentRequestSchema, groupBuySchema, + groupBuySchema, catalogDocId, orderDocId, shoppingCartDocId, shoppingCartIndexDocId, - paymentRequestDocId, groupBuyDocId, + groupBuyDocId, type CatalogDoc, type CatalogEntry, type OrderDoc, type OrderMeta, type ShoppingCartDoc, type ShoppingCartIndexDoc, - type PaymentRequestDoc, type PaymentRequestMeta, type GroupBuyDoc, type CartItem, type CartStatus, } from './schemas'; import { extractProductFromUrl } from './extract'; -import { createSecureWidgetUrl, extractRootDomain, getTransakApiKey, getTransakEnv } from '../../shared/transak'; -import { createMoonPayPaymentUrl, getMoonPayApiKey, getMoonPayEnv } from '../../shared/moonpay'; - -/** Tokens pegged 1:1 to USD — fiat amount can be inferred from crypto amount */ -const USD_STABLECOINS = ['USDC', 'USDT', 'DAI', 'cUSDC']; -import QRCode from 'qrcode'; -import { createTransport, type Transporter } from "nodemailer"; import { - getRelayerAddress, checkAllowance, executeTransferFrom, buildApprovalCalldata, -} from './lib/recurring-executor'; + paymentRequestSchema, paymentRequestDocId, + type PaymentRequestDoc, +} from '../rpayments/schemas'; +import { onPaymentPaid } from '../rpayments/mod'; let _syncServer: SyncServer | null = null; -// ── 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; - // Internal mailcow network: relay on port 25 without auth - // External: use port 587 with STARTTLS + auth - _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('[rcart] EncryptID email lookup failed:', e); - return { email: null, username: null }; - } -} - const routes = new Hono(); // Provider registry URL (for fulfillment resolution) @@ -95,209 +48,6 @@ function getProviderUrl(): string { return PROVIDER_REGISTRY_URL || "http://localhost:3000/demo/providers"; } -// ── 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; - // Check every hour for due subscriptions - _reminderTimer = setInterval(() => checkDueSubscriptions(), 60 * 60 * 1000); - // Also run once on startup (after 30s delay for init) - setTimeout(() => checkDueSubscriptions(), 30_000); - console.log('[rcart] Subscription reminder scheduler started (hourly)'); -} - -async function checkDueSubscriptions() { - if (!_syncServer) return; - const now = Date.now(); - const transport = getSmtpTransport(); - - // Scan all payment request docs for due subscriptions - const allDocIds = _syncServer.listDocs?.() || []; - for (const docId of allDocIds) { - if (!docId.includes(':payment:')) continue; - try { - const doc = _syncServer.getDoc(docId); - if (!doc) continue; - const p = doc.payment; - - // Only process active subscriptions with a due date in the past - if (p.status !== 'pending') continue; - if (!p.interval || !p.nextDueAt) continue; - if (p.nextDueAt > now) continue; - - // Don't process more than once per interval — check if last payment was recent - const lastPayment = p.paymentHistory?.length > 0 - ? p.paymentHistory[p.paymentHistory.length - 1] - : null; - const gracePeriodMs = Math.min(INTERVAL_MS[p.interval] * 0.5, 3 * 24 * 60 * 60 * 1000); - if (lastPayment && (now - lastPayment.paidAt) < gracePeriodMs) continue; - - const space = doc.meta.spaceSlug || 'demo'; - const host = 'rspace.online'; - const payUrl = `https://${space}.${host}/rcart/pay/${p.id}`; - const senderName = p.creatorUsername || 'Someone'; - const displayAmount = (!p.amount || p.amount === '0') ? 'a payment' : `${p.amount} ${p.token}`; - - // ── Attempt automated pull if payer has approved allowance ── - if (p.payerIdentity && p.token !== 'ETH') { - const usdcAddress = USDC_ADDRESSES[p.chainId]; - if (usdcAddress) { - try { - const decimals = p.token === 'USDC' ? 6 : 18; - const txHash = await executeTransferFrom( - usdcAddress, - p.payerIdentity, - p.recipientAddress, - p.amount || '0', - decimals, - p.chainId, - ); - - // Record the automated payment - _syncServer!.changeDoc(docId, 'automated subscription payment', (d) => { - d.payment.paidAt = now; - d.payment.txHash = txHash; - d.payment.paymentMethod = 'wallet'; - d.payment.paymentCount = (d.payment.paymentCount || 0) + 1; - if (!d.payment.paymentHistory) d.payment.paymentHistory = [] as any; - (d.payment.paymentHistory as any).push({ - txHash, - transakOrderId: null, - paymentMethod: 'wallet', - payerIdentity: p.payerIdentity, - payerEmail: p.subscriberEmail || null, - amount: d.payment.amount, - paidAt: now, - }); - - if (d.payment.maxPayments > 0 && d.payment.paymentCount >= d.payment.maxPayments) { - d.payment.status = 'filled'; - } else { - d.payment.status = 'pending'; - d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval!); - } - d.payment.updatedAt = now; - }); - - console.log(`[rcart] Auto-pulled subscription payment for ${p.id}: ${txHash}`); - - // Send receipt email - if (transport && p.subscriberEmail) { - const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@rmail.online'; - transport.sendMail({ - from: `"${senderName} via rSpace" <${fromAddr}>`, - to: p.subscriberEmail, - subject: `Payment processed: ${displayAmount} to ${senderName}`, - html: buildReceiptEmail(senderName, displayAmount, txHash, p.chainId, payUrl), - }).catch(err => console.warn(`[rcart] Failed to send receipt for ${p.id}:`, err)); - } - continue; // Payment executed — no reminder needed - } catch (err) { - console.warn(`[rcart] Auto-pull failed for ${p.id}:`, err); - // Fall through to send reminder email instead - } - } - } - - // ── Send reminder email (no auto-pull available or it failed) ── - if (!transport || !p.subscriberEmail) continue; - - const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@rmail.online'; - const html = buildReminderEmail(senderName, displayAmount, p.interval!, p.paymentCount, p.maxPayments, payUrl); - - try { - await transport.sendMail({ - from: `"${senderName} via rSpace" <${fromAddr}>`, - to: p.subscriberEmail, - subject: `Payment reminder: ${displayAmount} due to ${senderName}`, - html, - }); - console.log(`[rcart] Sent subscription reminder for ${p.id} to ${p.subscriberEmail}`); - } catch (err) { - console.warn(`[rcart] 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

-
`; -} - -const CHAIN_EXPLORER_URLS: Record = { 8453: 'https://basescan.org/tx/', 84532: 'https://sepolia.basescan.org/tx/', 1: 'https://etherscan.io/tx/' }; - -function buildReceiptEmail(sender: string, amount: string, txHash: string, chainId: number, payUrl: string): string { - const explorer = CHAIN_EXPLORER_URLS[chainId]; - const txLink = explorer ? `${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

-
`; -} - // ── Automerge helpers ── /** Lazily create (or retrieve) the catalog doc for a space. */ @@ -1267,8 +1017,8 @@ routes.post("/api/shopping-carts/:cartId/contribute-pay", async (c) => { }); _syncServer!.setDoc(payDocId, payDoc); - const payUrl = `/rcart/pay/${paymentId}`; - const fullPayUrl = buildSpaceUrl(space, `/rcart/pay/${paymentId}`); + const payUrl = `/rpayments/pay/${paymentId}`; + const fullPayUrl = buildSpaceUrl(space, `/rpayments/pay/${paymentId}`); return c.json({ paymentId, payUrl, fullPayUrl }, 201); }); @@ -1408,942 +1158,6 @@ routes.get("/api/cart/summary", async (c) => { }); }); -// ── PAYMENT REQUEST helpers ── - -/** Get all payment request docs for a space. */ -function getSpacePaymentDocs(space: string): Array<{ docId: string; doc: Automerge.Doc }> { - const prefix = `${space}:cart: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; -} - -// USDC contract addresses -const USDC_ADDRESSES: Record = { - 8453: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // Base - 84532: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', // Base Sepolia - 1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum -}; - -// ── PAYMENT REQUEST ROUTES ── - -// 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, // seconds, 0 = no expiry - paymentType = 'single', - maxPayments = 0, - enabledMethods = { card: true, wallet: true, encryptid: true }, - interval = 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, - }; - // Subscription interval - const validIntervals = ['weekly', 'biweekly', 'monthly', 'quarterly', 'yearly']; - if (interval && validIntervals.includes(interval)) { - d.payment.interval = interval; - } - d.payment.createdAt = now; - d.payment.updatedAt = now; - d.payment.expiresAt = expiresAt; - }); - _syncServer!.setDoc(docId, payDoc); - - const host = c.req.header("host") || "rspace.online"; - const payUrl = `${buildSpaceUrl(space, "/rcart")}/pay/${paymentId}`; - - return c.json({ - id: paymentId, - description, - amount: String(amount), - token: payToken, - chainId, - recipientAddress, - status: 'pending', - payUrl, - qrUrl: `${buildSpaceUrl(space, "/rcart")}/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; - - // Check expiry - 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, - }); - } - - // Check inventory fill - 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); - } - - // Check if inventory is full before allowing payment - 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; - // Allow amount update only if payment is editable and still pending - if (amount && d.payment.amountEditable && d.payment.status === 'pending') { - d.payment.amount = String(amount); - } - // Store subscriber email for reminder notifications - 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; - - // Record payment in history - 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, - }); - - // Determine if this payment should stay open for more payments - 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 { - // Keep accepting payments — reset status after recording - d.payment.status = 'pending'; - - // Compute next due date based on interval - if (d.payment.interval) { - d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval); - } - } - } - } - }); - - const updated = _syncServer!.getDoc(docId); - - // Fire-and-forget payment emails to both payer and recipient - if (status === 'paid') { - const host = c.req.header("host") || "rspace.online"; - const isValidEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); - - // Resolve payer email: from request body, or look up via EncryptID - (async () => { - let resolvedPayerEmail = payerEmail; - if (!resolvedPayerEmail && payerIdentity) { - const lookup = await lookupEncryptIDEmail(payerIdentity); - if (lookup.email) resolvedPayerEmail = lookup.email; - } - // Send "Payment Sent" email to payer - if (resolvedPayerEmail && isValidEmail(resolvedPayerEmail)) { - sendPaymentSuccessEmail(resolvedPayerEmail, updated!.payment, host, space) - .catch((err) => console.error('[rcart] payer email failed:', err)); - } - // Send "Payment Received" email to recipient (creator) - 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('[rcart] recipient email failed:', err)); - } - } - })().catch((err) => console.error('[rcart] payment email resolution failed:', err)); - } - - // Auto-record contribution on linked shopping cart - if (status === 'paid' && updated!.payment.linkedCartId) { - const linkedCartId = updated!.payment.linkedCartId; - const cartDocId = shoppingCartDocId(space, linkedCartId); - const cartDoc = _syncServer!.getDoc(cartDocId); - if (cartDoc) { - const contribAmount = parseFloat(updated!.payment.amount) || 0; - if (contribAmount > 0) { - const contribId = crypto.randomUUID(); - const contribNow = Date.now(); - _syncServer!.changeDoc(cartDocId, 'auto-record payment contribution', (d) => { - d.contributions[contribId] = { - userId: null, - username: updated!.payment.creatorUsername || 'Anonymous', - amount: contribAmount, - currency: d.cart.currency, - paymentMethod: updated!.payment.paymentMethod || 'wallet', - status: 'confirmed', - txHash: updated!.payment.txHash || null, - createdAt: contribNow, - updatedAt: contribNow, - }; - d.cart.fundedAmount = Math.round((d.cart.fundedAmount + contribAmount) * 100) / 100; - d.cart.updatedAt = contribNow; - d.events.push({ - type: 'contribution', - actor: updated!.payment.creatorUsername || 'Anonymous', - detail: `Paid $${contribAmount.toFixed(2)} via ${updated!.payment.paymentMethod || 'wallet'}`, - timestamp: contribNow, - }); - if (d.events.length > 200) d.events.splice(0, d.events.length - 200); - }); - reindexCart(space, linkedCartId); - } - } - } - - 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 host = c.req.header("host") || "rspace.online"; - const payUrl = `${buildSpaceUrl(space, "/rcart")}/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' }; - - // Use forwarded host from reverse proxy, fall back to request URL - const host = c.req.header('x-forwarded-host') || c.req.header('host') || new URL(c.req.url).hostname; - - // Use override amount for editable-amount payments, otherwise use preset amount - 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', - }; - - // Derive fiat amount: use explicit fiatAmount, or infer from crypto amount for stablecoins - 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 — Get 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; - - // Try MoonPay first (simpler, no session API needed) - 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('[rcart] MoonPay URL generation failed:', err); - } - } - - // Fall back to Transak - 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', - }; - // Derive fiat amount: use explicit fiatAmount, or infer from crypto amount for stablecoins - { - 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 host = c.req.header("host") || "rspace.online"; - const payUrl = `${buildSpaceUrl(space, "/rcart")}/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(`[rcart] Failed to send payment email to ${email}:`, err); - } - } - - return c.json({ sent, total: validEmails.length }); -}); - -// ── Recurring payment endpoints ── - -// GET /api/payments/:id/subscription-info — Get subscription approval details -routes.get("/api/payments/:id/subscription-info", async (c) => { - const space = c.req.param("space") || "demo"; - const paymentId = c.req.param("id"); - const docId = paymentRequestDocId(space, paymentId); - const doc = _syncServer!.getDoc(docId); - if (!doc) return c.json({ error: "Payment request not found" }, 404); - - const p = doc.payment; - if (p.paymentType !== 'subscription' && p.paymentType !== 'payer_choice') { - return c.json({ error: "Not a subscription payment" }, 400); - } - - const usdcAddress = USDC_ADDRESSES[p.chainId]; - if (!usdcAddress) return c.json({ error: "Token not supported on this chain" }, 400); - - try { - const relayerAddress = await getRelayerAddress(); - const decimals = p.token === 'USDC' ? 6 : 18; - - // Build the approve calldata for the client - const approval = await buildApprovalCalldata( - p.amount || '0', decimals, p.maxPayments - ); - - return c.json({ - relayerAddress, - tokenAddress: usdcAddress, - chainId: p.chainId, - interval: p.interval, - amountPerPayment: p.amount, - token: p.token, - approveCalldata: approval.calldata, - totalAllowance: approval.totalAllowance, - }); - } catch (e) { - return c.json({ error: "Recurring payments not configured on this server" }, 503); - } -}); - -// POST /api/payments/:id/subscribe — Register a subscription after payer approves allowance -routes.post("/api/payments/:id/subscribe", async (c) => { - const space = c.req.param("space") || "demo"; - const paymentId = c.req.param("id"); - const docId = paymentRequestDocId(space, paymentId); - const doc = _syncServer!.getDoc(docId); - if (!doc) return c.json({ error: "Payment request not found" }, 404); - - const p = doc.payment; - const body = await c.req.json(); - const { payerAddress, email, txHash } = body; - - if (!payerAddress) return c.json({ error: "Required: payerAddress" }, 400); - - const usdcAddress = USDC_ADDRESSES[p.chainId]; - if (!usdcAddress) return c.json({ error: "Token not supported on this chain" }, 400); - - // Verify the allowance exists on-chain - try { - const allowance = await checkAllowance(usdcAddress, payerAddress, p.chainId); - const decimals = p.token === 'USDC' ? 6 : 18; - const perPayment = parseTokenAmountServer(p.amount || '0', decimals); - - if (allowance < perPayment) { - return c.json({ - error: "Insufficient allowance. Please approve the relayer to spend your tokens first.", - allowance: allowance.toString(), - required: perPayment.toString(), - }, 400); - } - } catch (e) { - return c.json({ error: "Failed to verify allowance on-chain" }, 500); - } - - // Store subscription info - const now = Date.now(); - _syncServer!.changeDoc(docId, 'register subscription', (d) => { - d.payment.subscriberEmail = email || null; - // Set payer address for automated pulls - d.payment.payerIdentity = payerAddress; - // First due date is one interval from now - if (d.payment.interval) { - d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval); - } - d.payment.updatedAt = now; - }); - - // If this was the first payment (approval tx included a transfer), record it - if (txHash) { - _syncServer!.changeDoc(docId, 'record initial payment', (d) => { - d.payment.status = 'pending'; // Keep accepting - d.payment.paidAt = now; - d.payment.paymentCount = (d.payment.paymentCount || 0) + 1; - d.payment.txHash = txHash; - if (!d.payment.paymentHistory) d.payment.paymentHistory = [] as any; - (d.payment.paymentHistory as any).push({ - txHash, - transakOrderId: null, - paymentMethod: 'wallet', - payerIdentity: payerAddress, - payerEmail: email || null, - amount: d.payment.amount, - paidAt: now, - }); - if (d.payment.interval) { - d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval); - } - }); - } - - return c.json({ - subscribed: true, - nextDueAt: doc.payment.nextDueAt ? new Date(doc.payment.nextDueAt).toISOString() : null, - interval: doc.payment.interval, - }); -}); - -// Server-side token amount parser -function parseTokenAmountServer(amount: string, decimals: number): bigint { - const parts = amount.split('.'); - const whole = parts[0] || '0'; - const frac = (parts[1] || '').slice(0, decimals).padEnd(decimals, '0'); - return BigInt(whole) * BigInt(10 ** decimals) + BigInt(frac); -} - -// ── Payment success email ── - -const CHAIN_NAMES: Record = { 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/', -}; - -async function sendPaymentSuccessEmail( - email: string, - p: PaymentRequestMeta, - host: string, - space: string, -) { - const transport = getSmtpTransport(); - if (!transport) { - console.warn('[rcart] 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, "/rcart")}`; - - 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, "/rcart")}`; - 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(`[rcart] Payment received email sent to ${email}`); -} - -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, - }; -} - // ── GROUP BUY ROUTES ── // POST /api/group-buys — Create a group buy from a catalog entry @@ -2517,95 +1331,6 @@ routes.get("/group-buy/:id", (c) => { })); }); -// ── Page route: payments dashboard ── -routes.get("/payments", (c) => { - const space = c.req.param("space") || "demo"; - return c.html(renderShell({ - title: `Payments | rCart`, - moduleId: "rcart", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: ``, - scripts: ``, - styles: ``, - })); -}); - -// ── Page route: request payment (self-service QR generator) ── -routes.get("/request", (c) => { - const space = c.req.param("space") || "demo"; - return c.html(renderShell({ - title: `Request Payment | rCart`, - moduleId: "rcart", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: ``, - scripts: ``, - styles: ``, - })); -}); - -// ── Page route: payment page ── -routes.get("/pay/:id", (c) => { - const space = c.req.param("space") || "demo"; - const paymentId = c.req.param("id"); - - // Check payment status server-side for graceful terminal-state messages - 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} | rCart`, - moduleId: "rcart", - 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 | rCart`, - moduleId: "rcart", - spaceSlug: space, - spaceVisibility: "public", // Payment pages are always public - modules: getModuleInfoList(), - theme: "dark", - body: ``, - scripts: ``, - styles: ``, - })); -}); // ── Page routes: shop views (subnav tab links) ── @@ -2627,7 +1352,6 @@ routes.get("/carts", (c) => c.html(renderShop(c.req.param("space") || "demo", "c routes.get("/catalog", (c) => c.html(renderShop(c.req.param("space") || "demo", "catalog"))); routes.get("/orders", (c) => c.html(renderShop(c.req.param("space") || "demo", "orders"))); routes.get("/group-buys", (c) => c.html(renderShop(c.req.param("space") || "demo", "group-buys"))); -routes.get("/subscriptions", (c) => c.html(renderShop(c.req.param("space") || "demo", "subscriptions"))); routes.get("/", (c) => c.html(renderShop(c.req.param("space") || "demo"))); @@ -2701,7 +1425,6 @@ export const cartModule: RSpaceModule = { { pattern: '{space}:cart:orders:{orderId}', description: 'Order document', init: orderSchema.init }, { pattern: '{space}:cart:shopping:{cartId}', description: 'Shopping cart', init: shoppingCartSchema.init }, { pattern: '{space}:cart:shopping-index', description: 'Shopping cart index', init: shoppingCartIndexSchema.init }, - { pattern: '{space}:cart:payments:{paymentId}', description: 'Payment request', init: paymentRequestSchema.init }, ], routes, standaloneDomain: "rcart.online", @@ -2709,7 +1432,42 @@ export const cartModule: RSpaceModule = { seedTemplate: seedTemplateCart, async onInit(ctx) { _syncServer = ctx.syncServer; - startSubscriptionScheduler(); + + // When an rPayments payment with linkedCartId is marked paid, auto-record a contribution on that cart. + onPaymentPaid(({ space, payment }) => { + if (!payment.linkedCartId) return; + const cartDocId = shoppingCartDocId(space, payment.linkedCartId); + const cartDoc = _syncServer!.getDoc(cartDocId); + if (!cartDoc) return; + const contribAmount = parseFloat(payment.amount) || 0; + if (contribAmount <= 0) return; + + const contribId = crypto.randomUUID(); + const contribNow = Date.now(); + _syncServer!.changeDoc(cartDocId, 'auto-record payment contribution', (d) => { + d.contributions[contribId] = { + userId: null, + username: payment.creatorUsername || 'Anonymous', + amount: contribAmount, + currency: d.cart.currency, + paymentMethod: payment.paymentMethod || 'wallet', + status: 'confirmed', + txHash: payment.txHash || null, + createdAt: contribNow, + updatedAt: contribNow, + }; + d.cart.fundedAmount = Math.round((d.cart.fundedAmount + contribAmount) * 100) / 100; + d.cart.updatedAt = contribNow; + d.events.push({ + type: 'contribution', + actor: payment.creatorUsername || 'Anonymous', + detail: `Paid $${contribAmount.toFixed(2)} via ${payment.paymentMethod || 'wallet'}`, + timestamp: contribNow, + }); + if (d.events.length > 200) d.events.splice(0, d.events.length - 200); + }); + reindexCart(space, payment.linkedCartId); + }); }, feeds: [ { @@ -2739,9 +1497,7 @@ export const cartModule: RSpaceModule = { { path: "carts", name: "Carts", icon: "🛒", description: "Group shopping carts" }, { path: "catalog", name: "Catalog", icon: "🛍️", description: "Print-on-demand product catalog" }, { path: "orders", name: "Orders", icon: "📦", description: "Order history and fulfillment tracking" }, - { path: "payments", name: "Payments", icon: "💳", description: "Payment requests and invoices" }, { path: "group-buys", name: "Group Buys", icon: "👥", description: "Volume discount group purchasing campaigns" }, - { path: "subscriptions", name: "Subscriptions", icon: "🔄", description: "Recurring subscription orders" }, ], onboardingActions: [ { label: "Create a Store", icon: "🏪", description: "Set up a community storefront", type: 'create', href: '/{space}/rcart' }, diff --git a/modules/rcart/schemas.ts b/modules/rcart/schemas.ts index bc2220ec..45a5df19 100644 --- a/modules/rcart/schemas.ts +++ b/modules/rcart/schemas.ts @@ -276,121 +276,6 @@ export const shoppingCartIndexSchema: DocSchema = { }), }; -// ── Payment Request types ── - -/** Individual payment record in a subscription's history. */ -export interface PaymentRecord { - txHash: string | null; - transakOrderId: string | null; - paymentMethod: 'transak' | 'wallet' | 'encryptid' | null; - payerIdentity: string | null; - payerEmail: string | null; - amount: string; - paidAt: number; -} - -export interface PaymentRequestMeta { - id: string; - description: string; - amount: string; - amountEditable: boolean; - token: string; - chainId: number; - recipientAddress: string; - fiatAmount: string | null; - fiatCurrency: string; - creatorDid: string; - creatorUsername: string; - status: 'pending' | 'paid' | 'confirmed' | 'expired' | 'cancelled' | 'filled'; - paymentMethod: 'transak' | 'wallet' | 'encryptid' | null; - txHash: string | null; - payerIdentity: string | null; - transakOrderId: string | null; - // Payment type: one-time, recurring, or payer's choice - paymentType: 'single' | 'subscription' | 'payer_choice'; - // Inventory: max number of payments (0 = unlimited) - maxPayments: number; - // How many have been paid - paymentCount: number; - // Which payment methods are enabled for payers - enabledMethods: { - card: boolean; - wallet: boolean; - encryptid: boolean; - }; - // Subscription interval (for recurring payments) - interval: 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'yearly' | null; - // Next payment due date (epoch ms, 0 = not scheduled) - nextDueAt: number; - // Subscriber email (for payment reminders) - subscriberEmail: string | null; - // Linked shopping cart (for contribute-pay flow) - linkedCartId: string | null; - // Payment history (all individual payments) - paymentHistory: PaymentRecord[]; - createdAt: number; - updatedAt: number; - paidAt: number; - expiresAt: number; -} - -export interface PaymentRequestDoc { - meta: { - module: string; - collection: string; - version: number; - spaceSlug: string; - createdAt: number; - }; - payment: PaymentRequestMeta; -} - -export const paymentRequestSchema: DocSchema = { - module: 'cart', - collection: 'payments', - version: 1, - init: (): PaymentRequestDoc => ({ - meta: { - module: 'cart', - collection: 'payments', - version: 1, - spaceSlug: '', - createdAt: Date.now(), - }, - payment: { - id: '', - description: '', - amount: '0', - amountEditable: false, - token: 'USDC', - chainId: 8453, - recipientAddress: '', - fiatAmount: null, - fiatCurrency: 'USD', - creatorDid: '', - creatorUsername: '', - status: 'pending', - paymentMethod: null, - txHash: null, - payerIdentity: null, - transakOrderId: null, - paymentType: 'single', - maxPayments: 0, - paymentCount: 0, - enabledMethods: { card: true, wallet: true, encryptid: true }, - interval: null, - nextDueAt: 0, - subscriberEmail: null, - linkedCartId: null, - paymentHistory: [], - createdAt: Date.now(), - updatedAt: Date.now(), - paidAt: 0, - expiresAt: 0, - }, - }), -}; - // ── Group Buy types ── export type GroupBuyStatus = 'OPEN' | 'LOCKED' | 'ORDERED' | 'CANCELLED'; @@ -486,10 +371,6 @@ export function shoppingCartIndexDocId(space: string) { return `${space}:cart:shopping-index` as const; } -export function paymentRequestDocId(space: string, paymentId: string) { - return `${space}:cart:payments:${paymentId}` as const; -} - export function groupBuyDocId(space: string, buyId: string) { return `${space}:cart:group-buys:${buyId}` as const; } diff --git a/modules/rcart/components/folk-payment-page.ts b/modules/rpayments/components/folk-payment-page.ts similarity index 99% rename from modules/rcart/components/folk-payment-page.ts rename to modules/rpayments/components/folk-payment-page.ts index 72b6b553..c1072245 100644 --- a/modules/rcart/components/folk-payment-page.ts +++ b/modules/rpayments/components/folk-payment-page.ts @@ -71,8 +71,8 @@ class FolkPaymentPage extends HTMLElement { private getApiBase(): string { const path = window.location.pathname; - const match = path.match(/^(\/[^/]+)?\/rcart/); - return match ? match[0] : '/rcart'; + const match = path.match(/^(\/[^/]+)?\/rpayments/); + return match ? match[0] : '/rpayments'; } private async loadPayment() { diff --git a/modules/rcart/components/folk-payment-request.ts b/modules/rpayments/components/folk-payment-request.ts similarity index 99% rename from modules/rcart/components/folk-payment-request.ts rename to modules/rpayments/components/folk-payment-request.ts index 9a699ff2..4cc4026c 100644 --- a/modules/rcart/components/folk-payment-request.ts +++ b/modules/rpayments/components/folk-payment-request.ts @@ -71,8 +71,8 @@ class FolkPaymentRequest extends HTMLElement { private getApiBase(): string { const path = window.location.pathname; - const match = path.match(/^(\/[^/]+)?\/rcart/); - return match ? match[0] : '/rcart'; + const match = path.match(/^(\/[^/]+)?\/rpayments/); + return match ? match[0] : '/rpayments'; } // ── Auth ── diff --git a/modules/rcart/components/folk-payments-dashboard.ts b/modules/rpayments/components/folk-payments-dashboard.ts similarity index 96% rename from modules/rcart/components/folk-payments-dashboard.ts rename to modules/rpayments/components/folk-payments-dashboard.ts index 2d806d07..596864d1 100644 --- a/modules/rcart/components/folk-payments-dashboard.ts +++ b/modules/rpayments/components/folk-payments-dashboard.ts @@ -38,13 +38,13 @@ class FolkPaymentsDashboard extends HTMLElement { private getApiBase(): string { const path = window.location.pathname; - const match = path.match(/^(\/[^/]+)?\/rcart/); - return match ? match[0] : '/rcart'; + const match = path.match(/^(\/[^/]+)?\/rpayments/); + return match ? match[0] : '/rpayments'; } private getSpacePrefix(): string { const path = window.location.pathname; - const match = path.match(/^\/([^/]+)\/rcart/); + const match = path.match(/^\/([^/]+)\/rpayments/); return match ? `/${match[1]}` : ''; } @@ -111,7 +111,7 @@ class FolkPaymentsDashboard extends HTMLElement {

Payments

- + Create Payment Request + + Create Payment Request
@@ -143,7 +143,7 @@ class FolkPaymentsDashboard extends HTMLElement {
💳

No payment requests yet

Create a payment request to generate a QR code anyone can scan to pay you.

- Create your first request + Create your first request
`; } @@ -167,7 +167,7 @@ class FolkPaymentsDashboard extends HTMLElement { : p.paymentType === 'payer_choice' ? 'Flexible' : 'One-time'; - return ` + return `
${this.esc(p.description)}
diff --git a/modules/rpayments/components/payments.css b/modules/rpayments/components/payments.css new file mode 100644 index 00000000..443d8fbe --- /dev/null +++ b/modules/rpayments/components/payments.css @@ -0,0 +1,19 @@ +/* rPayments module layout */ +main { + padding: 0; +} + +/* + * Narrow page components (payment page, request form, dashboard) + * set their own max-width; we center them with margin auto so subnav stays full-width. + */ +folk-payment-request, +folk-payment-page, +folk-payments-dashboard { + margin: 0 auto; +} + +/* Hide the module subnav on public-facing pay page — payers don't need the dashboard nav */ +main:has(folk-payment-page) .rapp-subnav { + display: none; +} diff --git a/modules/rpayments/landing.ts b/modules/rpayments/landing.ts new file mode 100644 index 00000000..0c4f2999 --- /dev/null +++ b/modules/rpayments/landing.ts @@ -0,0 +1,147 @@ +/** + * rPayments landing page — QR payments, subscriptions, on-ramp. + */ +export function renderLanding(): string { + return ` + + + + +
+
+

What rPayments Handles

+
+
+
📱
+

QR Payment Requests

+

Generate a QR code and shareable link for any amount. Payers scan, pay, done — in any wallet or with a credit card.

+
+
+
🔄
+

Subscriptions

+

Weekly, monthly, yearly recurring charges. Payers approve an allowance once; we pull payments automatically.

+
+
+
💳
+

Card On-Ramp

+

Payers without crypto can pay by card. MoonPay and Transak are wired in; funds land as USDC in your wallet.

+
+
+
📧
+

Email Everything

+

Send payment requests by email, reminders before subscription renewals, receipts on payment.

+
+
+
+
+ + +
+
+

How It Works

+
+
+ 1 +

Create a Request

+

Set an amount (or let the payer choose), pick a token and network, choose one-off or recurring.

+
+
+ 2 +

Share the Link or QR

+

Send by email, post the link, or print the QR. The pay page works for anyone — no account needed.

+
+
+ 3 +

Get Paid

+

Funds land in your wallet. The dashboard shows status, payer details, and full payment history.

+
+
+
+
+ + +
+
+
+
+

Any kind of payment

+

+ One primitive, many shapes. rPayments supports every common payment pattern out of the box. +

+
    +
  • One-off invoices — fixed-amount or “pay what you want”
  • +
  • Donation jars — editable amount, unlimited payers, public ledger
  • +
  • Subscriptions — allowance-based auto-pulls with email reminders
  • +
  • Limited drops — fixed inventory, marked “filled” when sold out
  • +
  • Tips via card — MoonPay/Transak fallback for payers without crypto
  • +
+
+
+
+
📈
+

Your wallet, your rules

+

Non-custodial by default.
Self-hosted, no middlemen.

+
+
+
+
+
+ + +
+
+

Built on Open Source

+

The libraries and tools that power rPayments.

+
+
+
+

x402 & ERC-20

+

Open payment protocols. Approvals, transfers, transferFrom — no proprietary rails.

+
+
+
🔑
+

EncryptID

+

Passkey-based identity. Creators sign in with a biometric; payers pay without any account at all.

+
+
+
📡
+

Automerge CRDT

+

Payment state syncs across devices in real time. Dashboards stay live without polling.

+
+
+
🔥
+

Hono

+

Ultrafast web framework for the API layer. Lightweight and edge-ready.

+
+
+
+
+ + +
+
+

Start accepting payments in 30 seconds.

+

Create a request, share the link, get paid.

+ +
+
+ +`; +} diff --git a/modules/rcart/lib/recurring-executor.ts b/modules/rpayments/lib/recurring-executor.ts similarity index 100% rename from modules/rcart/lib/recurring-executor.ts rename to modules/rpayments/lib/recurring-executor.ts diff --git a/modules/rpayments/mod.ts b/modules/rpayments/mod.ts new file mode 100644 index 00000000..2491ae45 --- /dev/null +++ b/modules/rpayments/mod.ts @@ -0,0 +1,1293 @@ +/** + * 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' }, + ], +}; diff --git a/modules/rpayments/schemas.ts b/modules/rpayments/schemas.ts new file mode 100644 index 00000000..4128409f --- /dev/null +++ b/modules/rpayments/schemas.ts @@ -0,0 +1,123 @@ +/** + * rPayments Automerge document schemas. + * + * Single document type: + * - Payment request: one doc per payment (QR invoice, subscription, or one-shot). + * DocId: {space}:payments:{paymentId} + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +/** Individual payment record in a subscription's history. */ +export interface PaymentRecord { + txHash: string | null; + transakOrderId: string | null; + paymentMethod: 'transak' | 'wallet' | 'encryptid' | null; + payerIdentity: string | null; + payerEmail: string | null; + amount: string; + paidAt: number; +} + +export interface PaymentRequestMeta { + id: string; + description: string; + amount: string; + amountEditable: boolean; + token: string; + chainId: number; + recipientAddress: string; + fiatAmount: string | null; + fiatCurrency: string; + creatorDid: string; + creatorUsername: string; + status: 'pending' | 'paid' | 'confirmed' | 'expired' | 'cancelled' | 'filled'; + paymentMethod: 'transak' | 'wallet' | 'encryptid' | null; + txHash: string | null; + payerIdentity: string | null; + transakOrderId: string | null; + paymentType: 'single' | 'subscription' | 'payer_choice'; + maxPayments: number; + paymentCount: number; + enabledMethods: { + card: boolean; + wallet: boolean; + encryptid: boolean; + }; + interval: 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'yearly' | null; + nextDueAt: number; + subscriberEmail: string | null; + /** Linked shopping cart ID (for rCart contribute-pay flow). rCart listens for this via onPaymentPaid. */ + linkedCartId: string | null; + paymentHistory: PaymentRecord[]; + createdAt: number; + updatedAt: number; + paidAt: number; + expiresAt: number; +} + +export interface PaymentRequestDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + payment: PaymentRequestMeta; +} + +export const paymentRequestSchema: DocSchema = { + module: 'payments', + collection: 'payments', + version: 1, + init: (): PaymentRequestDoc => ({ + meta: { + module: 'payments', + collection: 'payments', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + payment: { + id: '', + description: '', + amount: '0', + amountEditable: false, + token: 'USDC', + chainId: 8453, + recipientAddress: '', + fiatAmount: null, + fiatCurrency: 'USD', + creatorDid: '', + creatorUsername: '', + status: 'pending', + paymentMethod: null, + txHash: null, + payerIdentity: null, + transakOrderId: null, + paymentType: 'single', + maxPayments: 0, + paymentCount: 0, + enabledMethods: { card: true, wallet: true, encryptid: true }, + interval: null, + nextDueAt: 0, + subscriberEmail: null, + linkedCartId: null, + paymentHistory: [], + createdAt: Date.now(), + updatedAt: Date.now(), + paidAt: 0, + expiresAt: 0, + }, + }), +}; + +export function paymentRequestDocId(space: string, paymentId: string) { + return `${space}:payments:${paymentId}` as const; +} + +/** Legacy doc ID pattern (when payments lived inside rCart). Used by the one-time migration. */ +export function legacyPaymentRequestDocId(space: string, paymentId: string) { + return `${space}:cart:payments:${paymentId}` as const; +} diff --git a/server/index.ts b/server/index.ts index e5306945..07368f80 100644 --- a/server/index.ts +++ b/server/index.ts @@ -56,6 +56,7 @@ import { canvasModule } from "../modules/rspace/mod"; import { booksModule } from "../modules/rbooks/mod"; import { pubsModule } from "../modules/rpubs/mod"; import { cartModule } from "../modules/rcart/mod"; +import { paymentsModule } from "../modules/rpayments/mod"; import { swagModule } from "../modules/rswag/mod"; import { choicesModule } from "../modules/rchoices/mod"; import { flowsModule } from "../modules/rflows/mod"; @@ -152,6 +153,7 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT')); registerModule(canvasModule); registerModule(pubsModule); registerModule(cartModule); +registerModule(paymentsModule); registerModule(swagModule); registerModule(choicesModule); registerModule(flowsModule); @@ -3399,8 +3401,8 @@ for (const mod of getAllModules()) { || pathname.endsWith("/api/transak/webhook") || pathname.endsWith("/api/coinbase/webhook") || pathname.endsWith("/api/ramp/webhook") - || pathname.includes("/rcart/api/payments") - || pathname.includes("/rcart/pay/") + || pathname.includes("/rpayments/api/payments") + || pathname.includes("/rpayments/pay/") || pathname.includes("/rwallet/api/") || pathname.includes("/rdesign/api/") || pathname.includes("/rtasks/api/") diff --git a/server/mcp-tools/rcart.ts b/server/mcp-tools/rcart.ts index e67db0b7..e1e2f672 100644 --- a/server/mcp-tools/rcart.ts +++ b/server/mcp-tools/rcart.ts @@ -1,5 +1,5 @@ /** - * MCP tools for rCart (catalog, shopping carts, group buys, payments). + * MCP tools for rCart (catalog, shopping carts, group buys). * * Tools: rcart_list_catalog, rcart_list_carts, rcart_get_cart, rcart_list_group_buys */ diff --git a/server/shell.ts b/server/shell.ts index a947a693..b7efd7d7 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -187,7 +187,7 @@ const IS_PRODUCTION = process.env.NODE_ENV === "production"; /** * Build a full external URL for a space + path, using subdomain routing in production. - * E.g. buildSpaceUrl("demo", "/rcart/pay/123", host) → "https://demo.rspace.online/rcart/pay/123" + * E.g. buildSpaceUrl("demo", "/rpayments/pay/123", host) → "https://demo.rspace.online/rpayments/pay/123" */ export function buildSpaceUrl(space: string, path: string, host?: string): string { if (IS_PRODUCTION) { diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts index 65009108..66dcd7d2 100644 --- a/shared/components/rstack-app-switcher.ts +++ b/shared/components/rstack-app-switcher.ts @@ -51,6 +51,7 @@ const MODULE_BADGES: Record = { rflows: { badge: "r🌊", color: "#bef264" }, // lime-300 rwallet: { badge: "r💰", color: "#fde047" }, // yellow-300 rcart: { badge: "r🛒", color: "#fdba74" }, // orange-300 + rpayments: { badge: "r💳", color: "#86efac" }, // green-300 rauctions: { badge: "r🎭", color: "#fca5a5" }, // red-300 // Govern rgov: { badge: "r⚖️", color: "#94a3b8" }, // slate-400 @@ -104,6 +105,7 @@ const MODULE_CATEGORIES: Record = { // Commerce rauctions: "Commerce", rcart: "Commerce", + rpayments: "Commerce", rexchange: "Commerce", rflows: "Commerce", rwallet: "Commerce", diff --git a/shared/components/rstack-tab-bar.ts b/shared/components/rstack-tab-bar.ts index 20dc0133..2fdeb1fb 100644 --- a/shared/components/rstack-tab-bar.ts +++ b/shared/components/rstack-tab-bar.ts @@ -44,6 +44,7 @@ const MODULE_BADGES: Record = { rflows: { badge: "r🌊", color: "#bef264" }, rwallet: { badge: "r💰", color: "#fde047" }, rcart: { badge: "r🛒", color: "#fdba74" }, + rpayments: { badge: "r💳", color: "#86efac" }, rauctions: { badge: "r🏛", color: "#fca5a5" }, rtube: { badge: "r🎬", color: "#f9a8d4" }, rphotos: { badge: "r📸", color: "#f9a8d4" }, @@ -66,7 +67,7 @@ const MODULE_CATEGORIES: Record = { rcal: "Planning", rtrips: "Planning", rmaps: "Planning", rchats: "Communicating", rinbox: "Communicating", rmail: "Communicating", rforum: "Communicating", rchoices: "Deciding", rvote: "Deciding", - rflows: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rauctions: "Funding & Commerce", + rflows: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rpayments: "Funding & Commerce", rauctions: "Funding & Commerce", rphotos: "Sharing", rnetwork: "Sharing", rsocials: "Sharing", rfiles: "Sharing", rbooks: "Sharing", rdata: "Observing", rtasks: "Tasks & Productivity", diff --git a/vite.config.ts b/vite.config.ts index d7bbf03e..f2140f3d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -228,26 +228,6 @@ export default defineConfig({ }, }); - // Build payment page component - await wasmBuild({ - configFile: false, - root: resolve(__dirname, "modules/rcart/components"), - build: { - emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/rcart"), - lib: { - entry: resolve(__dirname, "modules/rcart/components/folk-payment-page.ts"), - formats: ["es"], - fileName: () => "folk-payment-page.js", - }, - rollupOptions: { - output: { - entryFileNames: "folk-payment-page.js", - }, - }, - }, - }); - // Build group buy page component await wasmBuild({ configFile: false, @@ -268,15 +248,44 @@ export default defineConfig({ }, }); + // Copy cart CSS + mkdirSync(resolve(__dirname, "dist/modules/rcart"), { recursive: true }); + copyFileSync( + resolve(__dirname, "modules/rcart/components/cart.css"), + resolve(__dirname, "dist/modules/rcart/cart.css"), + ); + + // ── rPayments module ── + + // Build payment page component + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rpayments/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rpayments"), + lib: { + entry: resolve(__dirname, "modules/rpayments/components/folk-payment-page.ts"), + formats: ["es"], + fileName: () => "folk-payment-page.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-payment-page.js", + }, + }, + }, + }); + // Build payment request (QR generator) component await wasmBuild({ configFile: false, - root: resolve(__dirname, "modules/rcart/components"), + root: resolve(__dirname, "modules/rpayments/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/rcart"), + outDir: resolve(__dirname, "dist/modules/rpayments"), lib: { - entry: resolve(__dirname, "modules/rcart/components/folk-payment-request.ts"), + entry: resolve(__dirname, "modules/rpayments/components/folk-payment-request.ts"), formats: ["es"], fileName: () => "folk-payment-request.js", }, @@ -291,12 +300,12 @@ export default defineConfig({ // Build payments dashboard component await wasmBuild({ configFile: false, - root: resolve(__dirname, "modules/rcart/components"), + root: resolve(__dirname, "modules/rpayments/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/rcart"), + outDir: resolve(__dirname, "dist/modules/rpayments"), lib: { - entry: resolve(__dirname, "modules/rcart/components/folk-payments-dashboard.ts"), + entry: resolve(__dirname, "modules/rpayments/components/folk-payments-dashboard.ts"), formats: ["es"], fileName: () => "folk-payments-dashboard.js", }, @@ -308,11 +317,11 @@ export default defineConfig({ }, }); - // Copy cart CSS - mkdirSync(resolve(__dirname, "dist/modules/rcart"), { recursive: true }); + // Copy payments CSS + mkdirSync(resolve(__dirname, "dist/modules/rpayments"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/rcart/components/cart.css"), - resolve(__dirname, "dist/modules/rcart/cart.css"), + resolve(__dirname, "modules/rpayments/components/payments.css"), + resolve(__dirname, "dist/modules/rpayments/payments.css"), ); // Build swag module component