feat(rcart): recurring payment infrastructure with interval scheduling

Layer 1 — Schema & History:
- Added PaymentRecord type for individual payment tracking
- Added interval (weekly/biweekly/monthly/quarterly/yearly), nextDueAt,
  subscriberEmail, and paymentHistory[] to PaymentRequestMeta
- Payment history records txHash, method, amount, paidAt for each payment

Layer 2 — Server-Side Scheduler:
- Hourly subscription reminder scheduler sends email notifications
  when recurring payments are due (based on nextDueAt)
- nextDueAt auto-computed after each successful subscription payment
- Grace period prevents duplicate reminders
- Branded HTML reminder emails with "Pay Now" button

UI Updates:
- Billing interval selector (weekly through yearly) shown for
  subscription and payer_choice payment types
- Payment page shows subscription badge with interval and history count
- Next due date displayed for active subscriptions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 21:22:58 +00:00
parent 8345648d61
commit 4304170a9a
4 changed files with 198 additions and 2 deletions

View File

@ -457,7 +457,10 @@ class FolkPaymentPage extends HTMLElement {
${p.description ? `<div class="description">${this.esc(p.description)}</div>` : ''} ${p.description ? `<div class="description">${this.esc(p.description)}</div>` : ''}
${p.paymentType === 'subscription' ? '<div class="type-badge">Subscription</div>' : ''} ${p.paymentType === 'subscription' || (p.paymentType === 'payer_choice' && this.chosenPaymentType === 'subscription') ? `
<div class="type-badge">Subscription${p.interval ? ` &middot; ${p.interval}` : ''}</div>
${p.paymentHistory?.length > 0 ? `<div class="payment-history-summary">Payment ${p.paymentCount} of ${p.maxPayments > 0 ? p.maxPayments : '&infin;'}${p.nextDueAt ? ` &middot; Next due: ${new Date(p.nextDueAt).toLocaleDateString()}` : ''}</div>` : ''}
` : ''}
${p.paymentType === 'payer_choice' && p.status === 'pending' ? ` ${p.paymentType === 'payer_choice' && p.status === 'pending' ? `
<div class="payer-type-chooser"> <div class="payer-type-chooser">
<span class="chooser-label">How would you like to pay?</span> <span class="chooser-label">How would you like to pay?</span>
@ -689,6 +692,7 @@ class FolkPaymentPage extends HTMLElement {
.type-badge { text-align: center; margin-bottom: 0.75rem; } .type-badge { text-align: center; margin-bottom: 0.75rem; }
.type-badge { display: inline-block; padding: 0.1875rem 0.625rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; background: rgba(99,102,241,0.12); color: #818cf8; } .type-badge { display: inline-block; padding: 0.1875rem 0.625rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; background: rgba(99,102,241,0.12); color: #818cf8; }
.payment-history-summary { text-align: center; color: var(--rs-text-muted); font-size: 0.8125rem; margin-bottom: 0.75rem; }
.payer-type-chooser { text-align: center; margin-bottom: 1rem; padding: 0.75rem; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; } .payer-type-chooser { text-align: center; margin-bottom: 1rem; padding: 0.75rem; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; }
.chooser-label { display: block; font-size: 0.8125rem; color: var(--rs-text-secondary); margin-bottom: 0.5rem; } .chooser-label { display: block; font-size: 0.8125rem; color: var(--rs-text-secondary); margin-bottom: 0.5rem; }

View File

@ -27,6 +27,7 @@ class FolkPaymentRequest extends HTMLElement {
private token: 'USDC' | 'ETH' = 'USDC'; private token: 'USDC' | 'ETH' = 'USDC';
private chainId = 8453; private chainId = 8453;
private paymentType: 'single' | 'subscription' | 'payer_choice' = 'single'; private paymentType: 'single' | 'subscription' | 'payer_choice' = 'single';
private interval: 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'yearly' = 'monthly';
private maxPayments = 0; // 0 = unlimited private maxPayments = 0; // 0 = unlimited
private enabledMethods = { card: true, wallet: true, encryptid: true }; private enabledMethods = { card: true, wallet: true, encryptid: true };
@ -279,6 +280,8 @@ class FolkPaymentRequest extends HTMLElement {
chainId: this.chainId, chainId: this.chainId,
recipientAddress: this.walletAddress, recipientAddress: this.walletAddress,
paymentType: this.paymentType, paymentType: this.paymentType,
...(this.paymentType === 'subscription' || this.paymentType === 'payer_choice'
? { interval: this.interval } : {}),
maxPayments: this.maxPayments, maxPayments: this.maxPayments,
enabledMethods: this.enabledMethods, enabledMethods: this.enabledMethods,
}), }),
@ -432,12 +435,24 @@ class FolkPaymentRequest extends HTMLElement {
<button class="toggle-btn ${this.paymentType === 'payer_choice' ? 'active' : ''}" data-payment-type="payer_choice">Payer Decides</button> <button class="toggle-btn ${this.paymentType === 'payer_choice' ? 'active' : ''}" data-payment-type="payer_choice">Payer Decides</button>
</div> </div>
<span class="field-hint">${this.paymentType === 'subscription' <span class="field-hint">${this.paymentType === 'subscription'
? 'QR stays active — accepts multiple payments over time' ? 'Recurring payments — subscribers get email reminders when due'
: this.paymentType === 'payer_choice' : this.paymentType === 'payer_choice'
? 'Payer chooses whether to pay once or subscribe' ? 'Payer chooses whether to pay once or subscribe'
: 'One-time payment — QR deactivates after payment'}</span> : 'One-time payment — QR deactivates after payment'}</span>
</div> </div>
${this.paymentType !== 'single' ? `
<div class="field">
<label class="label">Billing Interval</label>
<select class="input" data-field="interval">
<option value="weekly" ${this.interval === 'weekly' ? 'selected' : ''}>Weekly</option>
<option value="biweekly" ${this.interval === 'biweekly' ? 'selected' : ''}>Every 2 weeks</option>
<option value="monthly" ${this.interval === 'monthly' ? 'selected' : ''}>Monthly</option>
<option value="quarterly" ${this.interval === 'quarterly' ? 'selected' : ''}>Quarterly</option>
<option value="yearly" ${this.interval === 'yearly' ? 'selected' : ''}>Yearly</option>
</select>
</div>` : ''}
<div class="field"> <div class="field">
<label class="label">Inventory Limit</label> <label class="label">Inventory Limit</label>
<div class="field-row" style="align-items:center"> <div class="field-row" style="align-items:center">
@ -579,6 +594,12 @@ class FolkPaymentRequest extends HTMLElement {
}); });
}); });
// Interval selector
const intervalSelect = this.shadow.querySelector('[data-field="interval"]') as HTMLSelectElement;
intervalSelect?.addEventListener('change', () => {
this.interval = intervalSelect.value as any;
});
// Max payments // Max payments
const maxInput = this.shadow.querySelector('[data-field="maxPayments"]') as HTMLInputElement; const maxInput = this.shadow.querySelector('[data-field="maxPayments"]') as HTMLInputElement;
maxInput?.addEventListener('input', () => { maxInput?.addEventListener('input', () => {

View File

@ -67,6 +67,117 @@ function getProviderUrl(): string {
return PROVIDER_REGISTRY_URL || "http://localhost:3000/demo/providers"; return PROVIDER_REGISTRY_URL || "http://localhost:3000/demo/providers";
} }
// ── Subscription interval helpers ──
const INTERVAL_MS: Record<string, number> = {
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<typeof setInterval> | 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();
if (!transport) return;
// 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<PaymentRequestDoc>(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;
if (!p.subscriberEmail) continue;
// Don't send 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;
// Send reminder email
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}`;
const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@rmail.online';
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
<tr><td style="background:linear-gradient(135deg,#f59e0b,#ef4444);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
<h1 style="color:#fff;font-size:24px;margin:0">Payment Reminder</h1>
<p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px">Your ${p.interval} payment is due</p>
</td></tr>
<tr><td style="background:#1a1a24;padding:32px 24px">
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
Your recurring payment of <strong>${displayAmount}</strong> to <strong>${senderName}</strong>${p.description ? ` for "${p.description}"` : ''} is due.
</p>
<p style="color:#94a3b8;font-size:14px;margin:0 0 24px">Payment ${p.paymentCount + 1}${p.maxPayments > 0 ? ` of ${p.maxPayments}` : ''}</p>
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center">
<a href="${payUrl}" style="display:inline-block;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:16px">
Pay Now
</a>
</td></tr>
</table>
</td></tr>
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
<p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace &middot; <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
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
}
}
}
// ── Automerge helpers ── // ── Automerge helpers ──
/** Lazily create (or retrieve) the catalog doc for a space. */ /** Lazily create (or retrieve) the catalog doc for a space. */
@ -1163,6 +1274,7 @@ routes.post("/api/payments", async (c) => {
paymentType = 'single', paymentType = 'single',
maxPayments = 0, maxPayments = 0,
enabledMethods = { card: true, wallet: true, encryptid: true }, enabledMethods = { card: true, wallet: true, encryptid: true },
interval = null,
} = body; } = body;
if (!description || !recipientAddress) { if (!description || !recipientAddress) {
@ -1201,6 +1313,11 @@ routes.post("/api/payments", async (c) => {
wallet: enabledMethods.wallet !== false, wallet: enabledMethods.wallet !== false,
encryptid: enabledMethods.encryptid !== 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.createdAt = now;
d.payment.updatedAt = now; d.payment.updatedAt = now;
d.payment.expiresAt = expiresAt; d.payment.expiresAt = expiresAt;
@ -1331,10 +1448,27 @@ routes.patch("/api/payments/:id/status", async (c) => {
if (amount && d.payment.amountEditable && d.payment.status === 'pending') { if (amount && d.payment.amountEditable && d.payment.status === 'pending') {
d.payment.amount = String(amount); d.payment.amount = String(amount);
} }
// Store subscriber email for reminder notifications
if (payerEmail && !d.payment.subscriberEmail) {
d.payment.subscriberEmail = payerEmail;
}
d.payment.updatedAt = now; d.payment.updatedAt = now;
if (status === 'paid') { if (status === 'paid') {
d.payment.paidAt = now; d.payment.paidAt = now;
d.payment.paymentCount = (d.payment.paymentCount || 0) + 1; 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 // Determine if this payment should stay open for more payments
const effectiveType = d.payment.paymentType === 'payer_choice' const effectiveType = d.payment.paymentType === 'payer_choice'
? (chosenPaymentType === 'subscription' ? 'subscription' : 'single') ? (chosenPaymentType === 'subscription' ? 'subscription' : 'single')
@ -1346,6 +1480,11 @@ routes.patch("/api/payments/:id/status", async (c) => {
} else { } else {
// Keep accepting payments — reset status after recording // Keep accepting payments — reset status after recording
d.payment.status = 'pending'; d.payment.status = 'pending';
// Compute next due date based on interval
if (d.payment.interval) {
d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval);
}
} }
} }
} }
@ -1661,6 +1800,14 @@ function paymentToResponse(p: PaymentRequestMeta) {
paymentCount: p.paymentCount || 0, paymentCount: p.paymentCount || 0,
enabledMethods: p.enabledMethods || { card: true, wallet: true, encryptid: true }, enabledMethods: p.enabledMethods || { card: true, wallet: true, encryptid: true },
creatorUsername: p.creatorUsername || '', creatorUsername: p.creatorUsername || '',
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(), created_at: new Date(p.createdAt).toISOString(),
updated_at: new Date(p.updatedAt).toISOString(), updated_at: new Date(p.updatedAt).toISOString(),
paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null, paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,
@ -1976,6 +2123,7 @@ export const cartModule: RSpaceModule = {
seedTemplate: seedTemplateCart, seedTemplate: seedTemplateCart,
async onInit(ctx) { async onInit(ctx) {
_syncServer = ctx.syncServer; _syncServer = ctx.syncServer;
startSubscriptionScheduler();
}, },
feeds: [ feeds: [
{ {

View File

@ -276,6 +276,17 @@ export const shoppingCartIndexSchema: DocSchema<ShoppingCartIndexDoc> = {
// ── Payment Request types ── // ── 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 { export interface PaymentRequestMeta {
id: string; id: string;
description: string; description: string;
@ -305,6 +316,14 @@ export interface PaymentRequestMeta {
wallet: boolean; wallet: boolean;
encryptid: 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;
// Payment history (all individual payments)
paymentHistory: PaymentRecord[];
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
paidAt: number; paidAt: number;
@ -355,6 +374,10 @@ export const paymentRequestSchema: DocSchema<PaymentRequestDoc> = {
maxPayments: 0, maxPayments: 0,
paymentCount: 0, paymentCount: 0,
enabledMethods: { card: true, wallet: true, encryptid: true }, enabledMethods: { card: true, wallet: true, encryptid: true },
interval: null,
nextDueAt: 0,
subscriberEmail: null,
paymentHistory: [],
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
paidAt: 0, paidAt: 0,