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.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' ? `
|
${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; }
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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 · <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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue