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:
parent
8345648d61
commit
4304170a9a
|
|
@ -457,7 +457,10 @@ class FolkPaymentPage extends HTMLElement {
|
|||
|
||||
${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 ? ` · ${p.interval}` : ''}</div>
|
||||
${p.paymentHistory?.length > 0 ? `<div class="payment-history-summary">Payment ${p.paymentCount} of ${p.maxPayments > 0 ? p.maxPayments : '∞'}${p.nextDueAt ? ` · Next due: ${new Date(p.nextDueAt).toLocaleDateString()}` : ''}</div>` : ''}
|
||||
` : ''}
|
||||
${p.paymentType === 'payer_choice' && p.status === 'pending' ? `
|
||||
<div class="payer-type-chooser">
|
||||
<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 { 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; }
|
||||
.chooser-label { display: block; font-size: 0.8125rem; color: var(--rs-text-secondary); margin-bottom: 0.5rem; }
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ class FolkPaymentRequest extends HTMLElement {
|
|||
private token: 'USDC' | 'ETH' = 'USDC';
|
||||
private chainId = 8453;
|
||||
private paymentType: 'single' | 'subscription' | 'payer_choice' = 'single';
|
||||
private interval: 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'yearly' = 'monthly';
|
||||
private maxPayments = 0; // 0 = unlimited
|
||||
private enabledMethods = { card: true, wallet: true, encryptid: true };
|
||||
|
||||
|
|
@ -279,6 +280,8 @@ class FolkPaymentRequest extends HTMLElement {
|
|||
chainId: this.chainId,
|
||||
recipientAddress: this.walletAddress,
|
||||
paymentType: this.paymentType,
|
||||
...(this.paymentType === 'subscription' || this.paymentType === 'payer_choice'
|
||||
? { interval: this.interval } : {}),
|
||||
maxPayments: this.maxPayments,
|
||||
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>
|
||||
</div>
|
||||
<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'
|
||||
? 'Payer chooses whether to pay once or subscribe'
|
||||
: 'One-time payment — QR deactivates after payment'}</span>
|
||||
</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">
|
||||
<label class="label">Inventory Limit</label>
|
||||
<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
|
||||
const maxInput = this.shadow.querySelector('[data-field="maxPayments"]') as HTMLInputElement;
|
||||
maxInput?.addEventListener('input', () => {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,117 @@ function getProviderUrl(): string {
|
|||
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 · <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 ──
|
||||
|
||||
/** Lazily create (or retrieve) the catalog doc for a space. */
|
||||
|
|
@ -1163,6 +1274,7 @@ routes.post("/api/payments", async (c) => {
|
|||
paymentType = 'single',
|
||||
maxPayments = 0,
|
||||
enabledMethods = { card: true, wallet: true, encryptid: true },
|
||||
interval = null,
|
||||
} = body;
|
||||
|
||||
if (!description || !recipientAddress) {
|
||||
|
|
@ -1201,6 +1313,11 @@ routes.post("/api/payments", async (c) => {
|
|||
wallet: enabledMethods.wallet !== 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.updatedAt = now;
|
||||
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') {
|
||||
d.payment.amount = String(amount);
|
||||
}
|
||||
// Store subscriber email for reminder notifications
|
||||
if (payerEmail && !d.payment.subscriberEmail) {
|
||||
d.payment.subscriberEmail = payerEmail;
|
||||
}
|
||||
d.payment.updatedAt = now;
|
||||
if (status === 'paid') {
|
||||
d.payment.paidAt = now;
|
||||
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
|
||||
const effectiveType = d.payment.paymentType === 'payer_choice'
|
||||
? (chosenPaymentType === 'subscription' ? 'subscription' : 'single')
|
||||
|
|
@ -1346,6 +1480,11 @@ routes.patch("/api/payments/:id/status", async (c) => {
|
|||
} else {
|
||||
// Keep accepting payments — reset status after recording
|
||||
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,
|
||||
enabledMethods: p.enabledMethods || { card: true, wallet: true, encryptid: true },
|
||||
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(),
|
||||
updated_at: new Date(p.updatedAt).toISOString(),
|
||||
paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,
|
||||
|
|
@ -1976,6 +2123,7 @@ export const cartModule: RSpaceModule = {
|
|||
seedTemplate: seedTemplateCart,
|
||||
async onInit(ctx) {
|
||||
_syncServer = ctx.syncServer;
|
||||
startSubscriptionScheduler();
|
||||
},
|
||||
feeds: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -276,6 +276,17 @@ export const shoppingCartIndexSchema: DocSchema<ShoppingCartIndexDoc> = {
|
|||
|
||||
// ── 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 {
|
||||
id: string;
|
||||
description: string;
|
||||
|
|
@ -305,6 +316,14 @@ export interface PaymentRequestMeta {
|
|||
wallet: 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;
|
||||
updatedAt: number;
|
||||
paidAt: number;
|
||||
|
|
@ -355,6 +374,10 @@ export const paymentRequestSchema: DocSchema<PaymentRequestDoc> = {
|
|||
maxPayments: 0,
|
||||
paymentCount: 0,
|
||||
enabledMethods: { card: true, wallet: true, encryptid: true },
|
||||
interval: null,
|
||||
nextDueAt: 0,
|
||||
subscriberEmail: null,
|
||||
paymentHistory: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
paidAt: 0,
|
||||
|
|
|
|||
Loading…
Reference in New Issue