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:
parent
e2d26d506c
commit
2a10277ec8
|
|
@ -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">✓</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>`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue