diff --git a/modules/rcart/components/folk-payment-page.ts b/modules/rcart/components/folk-payment-page.ts
index 8b20c64..ba5f063 100644
--- a/modules/rcart/components/folk-payment-page.ts
+++ b/modules/rcart/components/folk-payment-page.ts
@@ -457,7 +457,10 @@ class FolkPaymentPage extends HTMLElement {
${p.description ? `
${this.esc(p.description)}
` : ''}
- ${p.paymentType === 'subscription' ? 'Subscription
' : ''}
+ ${p.paymentType === 'subscription' || (p.paymentType === 'payer_choice' && this.chosenPaymentType === 'subscription') ? `
+ Subscription${p.interval ? ` · ${p.interval}` : ''}
+ ${p.paymentHistory?.length > 0 ? `Payment ${p.paymentCount} of ${p.maxPayments > 0 ? p.maxPayments : '∞'}${p.nextDueAt ? ` · Next due: ${new Date(p.nextDueAt).toLocaleDateString()}` : ''}
` : ''}
+ ` : ''}
${p.paymentType === 'payer_choice' && p.status === 'pending' ? `
How would you like to pay?
@@ -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; }
diff --git a/modules/rcart/components/folk-payment-request.ts b/modules/rcart/components/folk-payment-request.ts
index b9f5a84..99b0f7a 100644
--- a/modules/rcart/components/folk-payment-request.ts
+++ b/modules/rcart/components/folk-payment-request.ts
@@ -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 {
${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'}
+ ${this.paymentType !== 'single' ? `
+
+
+
+
` : ''}
+
@@ -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', () => {
diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts
index 4d9c01b..3a57364 100644
--- a/modules/rcart/mod.ts
+++ b/modules/rcart/mod.ts
@@ -67,6 +67,117 @@ function getProviderUrl(): string {
return PROVIDER_REGISTRY_URL || "http://localhost:3000/demo/providers";
}
+// ── Subscription interval helpers ──
+
+const INTERVAL_MS: Record
= {
+ 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 | 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(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 = `
+
+
+
+
+
+
+ Payment Reminder
+ Your ${p.interval} payment is due
+ |
+|
+
+ Your recurring payment of ${displayAmount} to ${senderName}${p.description ? ` for "${p.description}"` : ''} is due.
+
+ Payment ${p.paymentCount + 1}${p.maxPayments > 0 ? ` of ${p.maxPayments}` : ''}
+
+ |
+|
+ Powered by rSpace · View payment page
+ |
+
+ |
+
+`;
+
+ 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: [
{
diff --git a/modules/rcart/schemas.ts b/modules/rcart/schemas.ts
index eef8d51..4f74484 100644
--- a/modules/rcart/schemas.ts
+++ b/modules/rcart/schemas.ts
@@ -276,6 +276,17 @@ export const shoppingCartIndexSchema: DocSchema = {
// ── 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 = {
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,