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}` : ''}

+ + +
+ + Pay Now + +
+
+

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,