feat(rcart): add real payment flow for cart contributions

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 20:25:17 -07:00
parent e2d26d506c
commit 2a10277ec8
3 changed files with 102 additions and 1 deletions

View File

@ -500,6 +500,10 @@ class FolkPaymentPage extends HTMLElement {
};
const explorer = explorerBase[p.chainId] || '';
const cartLink = p.linkedCartId
? `<div style="margin-top:1.25rem"><a href="/${this.space}/rcart/carts" class="btn btn-primary" style="display:inline-block; text-decoration:none; text-align:center;">Return to Cart</a></div>`
: '';
return `
<div class="confirmation">
<div class="confirm-icon">&#10003;</div>
@ -509,6 +513,7 @@ class FolkPaymentPage extends HTMLElement {
${p.txHash ? `<div>Transaction: <a href="${explorer}${p.txHash}" target="_blank" rel="noopener">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a></div>` : ''}
${p.paid_at ? `<div>Paid: ${new Date(p.paid_at).toLocaleString()}</div>` : ''}
</div>
${cartLink}
</div>`;
}

View File

@ -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<ShoppingCartDoc>(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<PaymentRequestDoc>(), '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<ShoppingCartDoc>(cartDocId);
if (cartDoc) {
const contribAmount = parseFloat(updated!.payment.amount) || 0;
if (contribAmount > 0) {
const contribId = crypto.randomUUID();
const contribNow = Date.now();
_syncServer!.changeDoc<ShoppingCartDoc>(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 => ({

View File

@ -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<ShoppingCartDoc> = {
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<PaymentRequestDoc> = {
interval: null,
nextDueAt: 0,
subscriberEmail: null,
linkedCartId: null,
paymentHistory: [],
createdAt: Date.now(),
updatedAt: Date.now(),