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 `
✓
@@ -509,6 +513,7 @@ class FolkPaymentPage extends HTMLElement {
${p.txHash ? `
` : ''}
${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(),