From 2a10277ec81fe63df7a03d1710966e27e785583a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 12 Mar 2026 20:25:17 -0700 Subject: [PATCH] feat(rcart): add real payment flow for cart contributions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contribute button now offers "Pay Now" (creates a PaymentRequest linked to the cart, navigates to the existing payment page) alongside "Record Manual". When the payment completes, the server auto-records a contribution on the cart with amount, method, and txHash. - Add recipientAddress to ShoppingCartDoc, linkedCartId to PaymentRequestMeta - New POST /api/shopping-carts/:cartId/contribute-pay route - Payment status handler propagates paid → cart contribution - Payment page shows "Return to Cart" link for linked payments Co-Authored-By: Claude Opus 4.6 --- modules/rcart/components/folk-payment-page.ts | 5 + modules/rcart/mod.ts | 93 ++++++++++++++++++- modules/rcart/schemas.ts | 5 + 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/modules/rcart/components/folk-payment-page.ts b/modules/rcart/components/folk-payment-page.ts index 109665b..504f820 100644 --- a/modules/rcart/components/folk-payment-page.ts +++ b/modules/rcart/components/folk-payment-page.ts @@ -500,6 +500,10 @@ class FolkPaymentPage extends HTMLElement { }; const explorer = explorerBase[p.chainId] || ''; + const cartLink = p.linkedCartId + ? `
Return to Cart
` + : ''; + return `
@@ -509,6 +513,7 @@ class FolkPaymentPage extends HTMLElement { ${p.txHash ? `
Transaction: ${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}
` : ''} ${p.paid_at ? `
Paid: ${new Date(p.paid_at).toLocaleString()}
` : ''}
+ ${cartLink} `; } diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 65b897f..04bcb65 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -919,7 +919,7 @@ routes.get("/api/shopping-carts", async (c) => { // POST /api/shopping-carts — Create cart routes.post("/api/shopping-carts", async (c) => { const space = c.req.param("space") || "demo"; - const { name, description = "", targetAmount = 0, currency = "USD" } = await c.req.json(); + const { name, description = "", targetAmount = 0, currency = "USD", recipientAddress = null } = await c.req.json(); if (!name) return c.json({ error: "Required: name" }, 400); const cartId = crypto.randomUUID(); @@ -934,6 +934,7 @@ routes.post("/api/shopping-carts", async (c) => { d.cart.name = name; d.cart.description = description; d.cart.status = 'OPEN'; + d.cart.recipientAddress = recipientAddress || null; d.cart.targetAmount = targetAmount; d.cart.fundedAmount = 0; d.cart.currency = currency; @@ -969,6 +970,7 @@ routes.get("/api/shopping-carts/:cartId", async (c) => { name: doc.cart.name, description: doc.cart.description, status: doc.cart.status, + recipientAddress: doc.cart.recipientAddress || null, targetAmount: doc.cart.targetAmount, fundedAmount: doc.cart.fundedAmount, currency: doc.cart.currency, @@ -1191,6 +1193,58 @@ routes.post("/api/shopping-carts/:cartId/contribute", async (c) => { return c.json({ id: contribId, amount, fundedAmount: doc.cart.fundedAmount + amount }, 201); }); +// POST /api/shopping-carts/:cartId/contribute-pay — Create payment request for cart contribution +routes.post("/api/shopping-carts/:cartId/contribute-pay", async (c) => { + const space = c.req.param("space") || "demo"; + const cartId = c.req.param("cartId"); + const docId = shoppingCartDocId(space, cartId); + const doc = _syncServer!.getDoc(docId); + if (!doc) return c.json({ error: "Cart not found" }, 404); + + if (!doc.cart.recipientAddress) { + return c.json({ error: "Cart has no recipient wallet address" }, 400); + } + + const { amount, username = "Anonymous", chainId = 84532, token: payToken = "USDC" } = await c.req.json(); + if (typeof amount !== 'number' || amount <= 0) return c.json({ error: "amount must be > 0" }, 400); + + const paymentId = crypto.randomUUID(); + const now = Date.now(); + const payDocId = paymentRequestDocId(space, paymentId); + + const payDoc = Automerge.change(Automerge.init(), 'create cart contribution payment', (d) => { + const init = paymentRequestSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + d.payment.id = paymentId; + d.payment.description = `Contribution to "${doc.cart.name}"`; + d.payment.amount = String(amount); + d.payment.amountEditable = false; + d.payment.token = payToken; + d.payment.chainId = chainId; + d.payment.recipientAddress = doc.cart.recipientAddress!; + d.payment.fiatAmount = String(amount); + d.payment.fiatCurrency = doc.cart.currency || 'USD'; + d.payment.creatorDid = ''; + d.payment.creatorUsername = username; + d.payment.status = 'pending'; + d.payment.paymentType = 'single'; + d.payment.maxPayments = 0; + d.payment.paymentCount = 0; + d.payment.enabledMethods = { card: true, wallet: true, encryptid: true }; + d.payment.linkedCartId = cartId; + d.payment.createdAt = now; + d.payment.updatedAt = now; + d.payment.expiresAt = 0; + }); + _syncServer!.setDoc(payDocId, payDoc); + + const host = c.req.header("host") || "rspace.online"; + const payUrl = `/${space}/rcart/pay/${paymentId}`; + + return c.json({ paymentId, payUrl, fullPayUrl: `https://${host}${payUrl}` }, 201); +}); + // ── Extension shortcut routes ── // POST /api/cart/quick-add — Simplified endpoint for extension @@ -1594,6 +1648,42 @@ routes.patch("/api/payments/:id/status", async (c) => { .catch((err) => console.error('[rcart] payment email 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, + }); + }); + reindexCart(space, linkedCartId); + } + } + } + return c.json(paymentToResponse(updated!.payment)); }); @@ -2022,6 +2112,7 @@ function paymentToResponse(p: PaymentRequestMeta) { 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 => ({ diff --git a/modules/rcart/schemas.ts b/modules/rcart/schemas.ts index 4f74484..bc2220e 100644 --- a/modules/rcart/schemas.ts +++ b/modules/rcart/schemas.ts @@ -193,6 +193,7 @@ export interface ShoppingCartDoc { description: string; status: CartStatus; createdBy: string | null; + recipientAddress: string | null; targetAmount: number; fundedAmount: number; currency: string; @@ -246,6 +247,7 @@ export const shoppingCartSchema: DocSchema = { description: '', status: 'OPEN', createdBy: null, + recipientAddress: null, targetAmount: 0, fundedAmount: 0, currency: 'USD', @@ -322,6 +324,8 @@ export interface PaymentRequestMeta { 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; @@ -377,6 +381,7 @@ export const paymentRequestSchema: DocSchema = { interval: null, nextDueAt: 0, subscriberEmail: null, + linkedCartId: null, paymentHistory: [], createdAt: Date.now(), updatedAt: Date.now(),