feat(rcart): add subscription/inventory/method-toggles to payment requests + fix 403
- Fix: add publicWrite to cartModule so payment routes aren't blocked by space-level write auth (routes have their own auth checks) - Add paymentType field: 'single' (one-time) or 'subscription' (reusable QR) - Add maxPayments inventory limit: QR codes auto-fill when limit reached - Add paymentCount to track how many payments received - Add enabledMethods: toggle card/wallet/encryptid per payment request - Payment page only shows enabled tabs, shows inventory progress bar - Subscriptions reset to 'pending' after each payment until filled - New 'filled' status for inventory-limited payments that are full Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b8788fa81e
commit
636fc133fe
|
|
@ -379,7 +379,8 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
const isPaid = p.status === 'paid' || p.status === 'confirmed';
|
const isPaid = p.status === 'paid' || p.status === 'confirmed';
|
||||||
const isExpired = p.status === 'expired';
|
const isExpired = p.status === 'expired';
|
||||||
const isCancelled = p.status === 'cancelled';
|
const isCancelled = p.status === 'cancelled';
|
||||||
const isTerminal = isPaid || isExpired || isCancelled;
|
const isFilled = p.status === 'filled';
|
||||||
|
const isTerminal = isPaid || isExpired || isCancelled || isFilled;
|
||||||
|
|
||||||
const chainNames: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
|
const chainNames: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
|
||||||
const chainName = chainNames[p.chainId] || `Chain ${p.chainId}`;
|
const chainName = chainNames[p.chainId] || `Chain ${p.chainId}`;
|
||||||
|
|
@ -411,7 +412,14 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
|
|
||||||
<div class="description">${this.esc(p.description)}</div>
|
<div class="description">${this.esc(p.description)}</div>
|
||||||
|
|
||||||
|
${p.paymentType === 'subscription' ? '<div class="type-badge">Subscription</div>' : ''}
|
||||||
|
${p.maxPayments > 0 ? `<div class="inventory-bar">
|
||||||
|
<div class="inventory-label">${p.paymentCount || 0} / ${p.maxPayments} payments</div>
|
||||||
|
<div class="inventory-track"><div class="inventory-fill" style="width:${Math.min(100, ((p.paymentCount || 0) / p.maxPayments) * 100)}%"></div></div>
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
${isPaid ? this.renderPaidConfirmation() :
|
${isPaid ? this.renderPaidConfirmation() :
|
||||||
|
isFilled ? '<div class="terminal-msg">This payment request has reached its limit.</div>' :
|
||||||
isTerminal ? `<div class="terminal-msg">${isExpired ? 'This payment request has expired.' : 'This payment request has been cancelled.'}</div>` :
|
isTerminal ? `<div class="terminal-msg">${isExpired ? 'This payment request has expired.' : 'This payment request has been cancelled.'}</div>` :
|
||||||
this.renderPaymentTabs()}
|
this.renderPaymentTabs()}
|
||||||
|
|
||||||
|
|
@ -449,11 +457,26 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderPaymentTabs(): string {
|
private renderPaymentTabs(): string {
|
||||||
|
const methods = this.payment.enabledMethods || { card: true, wallet: true, encryptid: true };
|
||||||
|
const enabledTabs: Array<{ id: string; label: string }> = [];
|
||||||
|
if (methods.card) enabledTabs.push({ id: 'card', label: 'Card' });
|
||||||
|
if (methods.wallet) enabledTabs.push({ id: 'wallet', label: 'Wallet' });
|
||||||
|
if (methods.encryptid) enabledTabs.push({ id: 'encryptid', label: 'EncryptID' });
|
||||||
|
|
||||||
|
if (enabledTabs.length === 0) {
|
||||||
|
return '<div class="terminal-msg">No payment methods are available for this request.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select first enabled tab if current tab is disabled
|
||||||
|
if (!enabledTabs.find(t => t.id === this.activeTab)) {
|
||||||
|
this.activeTab = enabledTabs[0].id as any;
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab ${this.activeTab === 'card' ? 'active' : ''}" data-tab="card">Card</button>
|
${enabledTabs.map(t =>
|
||||||
<button class="tab ${this.activeTab === 'wallet' ? 'active' : ''}" data-tab="wallet">Wallet</button>
|
`<button class="tab ${this.activeTab === t.id ? 'active' : ''}" data-tab="${t.id}">${t.label}</button>`
|
||||||
<button class="tab ${this.activeTab === 'encryptid' ? 'active' : ''}" data-tab="encryptid">EncryptID</button>
|
).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
|
|
@ -595,6 +618,15 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
.status-paid, .status-confirmed { background: rgba(34,197,94,0.15); color: #4ade80; }
|
.status-paid, .status-confirmed { background: rgba(34,197,94,0.15); color: #4ade80; }
|
||||||
.status-expired { background: rgba(239,68,68,0.15); color: #f87171; }
|
.status-expired { background: rgba(239,68,68,0.15); color: #f87171; }
|
||||||
.status-cancelled { background: rgba(156,163,175,0.15); color: #9ca3af; }
|
.status-cancelled { background: rgba(156,163,175,0.15); color: #9ca3af; }
|
||||||
|
.status-filled { background: rgba(99,102,241,0.15); color: #818cf8; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.inventory-bar { margin-bottom: 1rem; padding: 0.5rem 0.75rem; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; }
|
||||||
|
.inventory-label { font-size: 0.75rem; color: var(--rs-text-secondary); margin-bottom: 0.375rem; text-align: center; }
|
||||||
|
.inventory-track { height: 6px; border-radius: 3px; background: rgba(255,255,255,0.08); overflow: hidden; }
|
||||||
|
.inventory-fill { height: 100%; border-radius: 3px; background: var(--rs-primary-hover); transition: width 0.3s ease; }
|
||||||
|
|
||||||
.amount-display { text-align: center; margin-bottom: 1rem; }
|
.amount-display { text-align: center; margin-bottom: 1rem; }
|
||||||
.amount { display: block; font-size: 2rem; font-weight: 700; color: var(--rs-text-primary); }
|
.amount { display: block; font-size: 2rem; font-weight: 700; color: var(--rs-text-primary); }
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ class FolkPaymentRequest extends HTMLElement {
|
||||||
private amountEditable = false;
|
private amountEditable = false;
|
||||||
private token: 'USDC' | 'ETH' = 'USDC';
|
private token: 'USDC' | 'ETH' = 'USDC';
|
||||||
private chainId = 8453;
|
private chainId = 8453;
|
||||||
|
private paymentType: 'single' | 'subscription' = 'single';
|
||||||
|
private maxPayments = 0; // 0 = unlimited
|
||||||
|
private enabledMethods = { card: true, wallet: true, encryptid: true };
|
||||||
|
|
||||||
// Result state
|
// Result state
|
||||||
private generating = false;
|
private generating = false;
|
||||||
|
|
@ -158,6 +161,9 @@ class FolkPaymentRequest extends HTMLElement {
|
||||||
token: this.token,
|
token: this.token,
|
||||||
chainId: this.chainId,
|
chainId: this.chainId,
|
||||||
recipientAddress: this.walletAddress,
|
recipientAddress: this.walletAddress,
|
||||||
|
paymentType: this.paymentType,
|
||||||
|
maxPayments: this.maxPayments,
|
||||||
|
enabledMethods: this.enabledMethods,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -197,6 +203,9 @@ class FolkPaymentRequest extends HTMLElement {
|
||||||
this.description = '';
|
this.description = '';
|
||||||
this.amount = '';
|
this.amount = '';
|
||||||
this.amountEditable = false;
|
this.amountEditable = false;
|
||||||
|
this.paymentType = 'single';
|
||||||
|
this.maxPayments = 0;
|
||||||
|
this.enabledMethods = { card: true, wallet: true, encryptid: true };
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -273,7 +282,60 @@ class FolkPaymentRequest extends HTMLElement {
|
||||||
<label for="amount-editable">Let payer choose the amount</label>
|
<label for="amount-editable">Let payer choose the amount</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-primary btn-lg" data-action="generate" ${this.generating ? 'disabled' : ''}>
|
<div class="section-divider"></div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Payment Type</label>
|
||||||
|
<div class="toggle-group">
|
||||||
|
<button class="toggle-btn ${this.paymentType === 'single' ? 'active' : ''}" data-payment-type="single">Single Payment</button>
|
||||||
|
<button class="toggle-btn ${this.paymentType === 'subscription' ? 'active' : ''}" data-payment-type="subscription">Subscription</button>
|
||||||
|
</div>
|
||||||
|
<span class="field-hint">${this.paymentType === 'subscription'
|
||||||
|
? 'QR stays active — accepts multiple payments over time'
|
||||||
|
: 'One-time payment — QR deactivates after payment'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Inventory Limit</label>
|
||||||
|
<div class="field-row" style="align-items:center">
|
||||||
|
<input type="number" class="input" data-field="maxPayments" placeholder="Unlimited" min="0" step="1"
|
||||||
|
value="${this.maxPayments || ''}" style="width:120px" />
|
||||||
|
<span class="field-hint" style="margin:0">${this.maxPayments > 0
|
||||||
|
? `QR stops accepting after ${this.maxPayments} payment${this.maxPayments > 1 ? 's' : ''}`
|
||||||
|
: 'No limit — accepts payments indefinitely'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-divider"></div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Accepted Payment Methods</label>
|
||||||
|
<div class="method-toggles">
|
||||||
|
<label class="method-toggle">
|
||||||
|
<input type="checkbox" data-method="card" ${this.enabledMethods.card ? 'checked' : ''} />
|
||||||
|
<span class="method-icon">💳</span>
|
||||||
|
<span class="method-name">Credit Card</span>
|
||||||
|
<span class="method-desc">Transak on-ramp</span>
|
||||||
|
</label>
|
||||||
|
<label class="method-toggle">
|
||||||
|
<input type="checkbox" data-method="wallet" ${this.enabledMethods.wallet ? 'checked' : ''} />
|
||||||
|
<span class="method-icon">🧳</span>
|
||||||
|
<span class="method-name">External Wallet</span>
|
||||||
|
<span class="method-desc">MetaMask, etc.</span>
|
||||||
|
</label>
|
||||||
|
<label class="method-toggle">
|
||||||
|
<input type="checkbox" data-method="encryptid" ${this.enabledMethods.encryptid ? 'checked' : ''} />
|
||||||
|
<span class="method-icon">🔑</span>
|
||||||
|
<span class="method-name">EncryptID</span>
|
||||||
|
<span class="method-desc">Passkey wallet</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
${!this.enabledMethods.card && !this.enabledMethods.wallet && !this.enabledMethods.encryptid
|
||||||
|
? '<div class="field-error">At least one payment method must be enabled</div>' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary btn-lg" data-action="generate"
|
||||||
|
${this.generating || (!this.enabledMethods.card && !this.enabledMethods.wallet && !this.enabledMethods.encryptid) ? 'disabled' : ''}>
|
||||||
${this.generating ? 'Generating...' : 'Generate QR Code'}
|
${this.generating ? 'Generating...' : 'Generate QR Code'}
|
||||||
</button>
|
</button>
|
||||||
${this.authError ? `<div class="field-error">${this.esc(this.authError)}</div>` : ''}
|
${this.authError ? `<div class="field-error">${this.esc(this.authError)}</div>` : ''}
|
||||||
|
|
@ -286,12 +348,23 @@ class FolkPaymentRequest extends HTMLElement {
|
||||||
? 'Any amount'
|
? 'Any amount'
|
||||||
: `${p.amount} ${p.token}`;
|
: `${p.amount} ${p.token}`;
|
||||||
|
|
||||||
|
const tags: string[] = [];
|
||||||
|
if (this.paymentType === 'subscription') tags.push('Subscription');
|
||||||
|
if (this.amountEditable) tags.push('Editable amount');
|
||||||
|
if (this.maxPayments > 0) tags.push(`Limit: ${this.maxPayments}`);
|
||||||
|
const methods = [
|
||||||
|
this.enabledMethods.card ? 'Card' : '',
|
||||||
|
this.enabledMethods.wallet ? 'Wallet' : '',
|
||||||
|
this.enabledMethods.encryptid ? 'Passkey' : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="result">
|
<div class="result">
|
||||||
<div class="result-header">
|
<div class="result-header">
|
||||||
<div class="result-desc">${this.esc(p.description)}</div>
|
<div class="result-desc">${this.esc(p.description)}</div>
|
||||||
<div class="result-amount">${amountDisplay}</div>
|
<div class="result-amount">${amountDisplay}</div>
|
||||||
${this.amountEditable ? '<div class="result-hint">Amount editable by payer</div>' : ''}
|
${tags.length ? `<div class="result-tags">${tags.map(t => `<span class="result-tag">${t}</span>`).join('')}</div>` : ''}
|
||||||
|
<div class="result-hint">Accepts: ${methods.join(', ')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="qr-display">
|
<div class="qr-display">
|
||||||
|
|
@ -340,6 +413,30 @@ class FolkPaymentRequest extends HTMLElement {
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Payment type toggle
|
||||||
|
this.shadow.querySelectorAll('[data-payment-type]').forEach((el) => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
this.paymentType = (el as HTMLElement).dataset.paymentType as 'single' | 'subscription';
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Max payments
|
||||||
|
const maxInput = this.shadow.querySelector('[data-field="maxPayments"]') as HTMLInputElement;
|
||||||
|
maxInput?.addEventListener('input', () => {
|
||||||
|
this.maxPayments = parseInt(maxInput.value) || 0;
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Payment method toggles
|
||||||
|
this.shadow.querySelectorAll('[data-method]').forEach((el) => {
|
||||||
|
el.addEventListener('change', () => {
|
||||||
|
const method = (el as HTMLInputElement).dataset.method as keyof typeof this.enabledMethods;
|
||||||
|
this.enabledMethods = { ...this.enabledMethods, [method]: (el as HTMLInputElement).checked };
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Generate
|
// Generate
|
||||||
this.shadow.querySelector('[data-action="generate"]')?.addEventListener('click', () => this.generatePayment());
|
this.shadow.querySelector('[data-action="generate"]')?.addEventListener('click', () => this.generatePayment());
|
||||||
|
|
||||||
|
|
@ -422,12 +519,32 @@ class FolkPaymentRequest extends HTMLElement {
|
||||||
.btn-outline:hover { border-color: var(--rs-text-secondary); color: var(--rs-text-primary); }
|
.btn-outline:hover { border-color: var(--rs-text-secondary); color: var(--rs-text-primary); }
|
||||||
|
|
||||||
.field-error { color: #f87171; font-size: 0.8125rem; margin-top: 0.5rem; }
|
.field-error { color: #f87171; font-size: 0.8125rem; margin-top: 0.5rem; }
|
||||||
|
.field-hint { color: var(--rs-text-muted); font-size: 0.75rem; margin-top: 0.25rem; }
|
||||||
|
|
||||||
|
.section-divider { height: 1px; background: var(--rs-border); margin: 0.5rem 0; }
|
||||||
|
|
||||||
|
.toggle-group { display: flex; gap: 0; border: 1px solid var(--rs-border); border-radius: 8px; overflow: hidden; }
|
||||||
|
.toggle-btn { flex: 1; padding: 0.5rem 0.75rem; border: none; background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 0.8125rem; font-weight: 500; transition: all 0.15s; }
|
||||||
|
.toggle-btn:not(:last-child) { border-right: 1px solid var(--rs-border); }
|
||||||
|
.toggle-btn.active { background: var(--rs-primary-hover); color: #fff; }
|
||||||
|
.toggle-btn:hover:not(.active) { background: var(--rs-bg-hover); }
|
||||||
|
|
||||||
|
.method-toggles { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.method-toggle { display: flex; align-items: center; gap: 0.625rem; padding: 0.625rem 0.75rem; border: 1px solid var(--rs-border); border-radius: 8px; cursor: pointer; transition: border-color 0.15s; }
|
||||||
|
.method-toggle:hover { border-color: var(--rs-border-strong); }
|
||||||
|
.method-toggle:has(input:checked) { border-color: var(--rs-primary); background: rgba(99,102,241,0.05); }
|
||||||
|
.method-toggle input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--rs-primary-hover); cursor: pointer; flex-shrink: 0; }
|
||||||
|
.method-icon { font-size: 1.125rem; width: 24px; text-align: center; flex-shrink: 0; }
|
||||||
|
.method-name { color: var(--rs-text-primary); font-size: 0.875rem; font-weight: 500; }
|
||||||
|
.method-desc { color: var(--rs-text-muted); font-size: 0.75rem; margin-left: auto; }
|
||||||
|
|
||||||
/* Result */
|
/* Result */
|
||||||
.result { text-align: center; }
|
.result { text-align: center; }
|
||||||
.result-header { margin-bottom: 1.5rem; }
|
.result-header { margin-bottom: 1.5rem; }
|
||||||
.result-desc { color: var(--rs-text-secondary); font-size: 1rem; margin-bottom: 0.25rem; }
|
.result-desc { color: var(--rs-text-secondary); font-size: 1rem; margin-bottom: 0.25rem; }
|
||||||
.result-amount { color: var(--rs-text-primary); font-size: 2rem; font-weight: 700; }
|
.result-amount { color: var(--rs-text-primary); font-size: 2rem; font-weight: 700; }
|
||||||
|
.result-tags { display: flex; gap: 0.375rem; justify-content: center; flex-wrap: wrap; margin-top: 0.5rem; }
|
||||||
|
.result-tag { padding: 0.1875rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 600; background: rgba(99,102,241,0.12); color: var(--rs-primary-hover); text-transform: uppercase; letter-spacing: 0.03em; }
|
||||||
.result-hint { color: var(--rs-text-muted); font-size: 0.8125rem; margin-top: 0.25rem; }
|
.result-hint { color: var(--rs-text-muted); font-size: 0.8125rem; margin-top: 0.25rem; }
|
||||||
|
|
||||||
.qr-display { margin: 0 auto 1.5rem; padding: 1rem; background: #fff; border-radius: 12px; display: inline-block; }
|
.qr-display { margin: 0 auto 1.5rem; padding: 1rem; background: #fff; border-radius: 12px; display: inline-block; }
|
||||||
|
|
|
||||||
|
|
@ -1138,6 +1138,9 @@ routes.post("/api/payments", async (c) => {
|
||||||
chainId = 8453, recipientAddress,
|
chainId = 8453, recipientAddress,
|
||||||
fiatAmount = null, fiatCurrency = 'USD',
|
fiatAmount = null, fiatCurrency = 'USD',
|
||||||
expiresIn = 0, // seconds, 0 = no expiry
|
expiresIn = 0, // seconds, 0 = no expiry
|
||||||
|
paymentType = 'single',
|
||||||
|
maxPayments = 0,
|
||||||
|
enabledMethods = { card: true, wallet: true, encryptid: true },
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!description || !recipientAddress) {
|
if (!description || !recipientAddress) {
|
||||||
|
|
@ -1167,6 +1170,14 @@ routes.post("/api/payments", async (c) => {
|
||||||
d.payment.fiatCurrency = fiatCurrency;
|
d.payment.fiatCurrency = fiatCurrency;
|
||||||
d.payment.creatorDid = claims.sub;
|
d.payment.creatorDid = claims.sub;
|
||||||
d.payment.status = 'pending';
|
d.payment.status = 'pending';
|
||||||
|
d.payment.paymentType = paymentType === 'subscription' ? 'subscription' : 'single';
|
||||||
|
d.payment.maxPayments = Math.max(0, parseInt(maxPayments) || 0);
|
||||||
|
d.payment.paymentCount = 0;
|
||||||
|
d.payment.enabledMethods = {
|
||||||
|
card: enabledMethods.card !== false,
|
||||||
|
wallet: enabledMethods.wallet !== false,
|
||||||
|
encryptid: enabledMethods.encryptid !== false,
|
||||||
|
};
|
||||||
d.payment.createdAt = now;
|
d.payment.createdAt = now;
|
||||||
d.payment.updatedAt = now;
|
d.payment.updatedAt = now;
|
||||||
d.payment.expiresAt = expiresAt;
|
d.payment.expiresAt = expiresAt;
|
||||||
|
|
@ -1246,6 +1257,19 @@ routes.get("/api/payments/:id", async (c) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check inventory fill
|
||||||
|
if (p.maxPayments > 0 && p.paymentCount >= p.maxPayments && p.status === 'pending') {
|
||||||
|
_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'fill payment', (d) => {
|
||||||
|
d.payment.status = 'filled';
|
||||||
|
d.payment.updatedAt = Date.now();
|
||||||
|
});
|
||||||
|
return c.json({
|
||||||
|
...paymentToResponse(p),
|
||||||
|
status: 'filled',
|
||||||
|
usdcAddress: USDC_ADDRESSES[p.chainId] || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
...paymentToResponse(p),
|
...paymentToResponse(p),
|
||||||
usdcAddress: USDC_ADDRESSES[p.chainId] || null,
|
usdcAddress: USDC_ADDRESSES[p.chainId] || null,
|
||||||
|
|
@ -1262,11 +1286,17 @@ routes.patch("/api/payments/:id/status", async (c) => {
|
||||||
|
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { status, txHash, paymentMethod, payerIdentity, transakOrderId, amount } = body;
|
const { status, txHash, paymentMethod, payerIdentity, transakOrderId, amount } = body;
|
||||||
const validStatuses = ['pending', 'paid', 'confirmed', 'expired', 'cancelled'];
|
const validStatuses = ['pending', 'paid', 'confirmed', 'expired', 'cancelled', 'filled'];
|
||||||
if (status && !validStatuses.includes(status)) {
|
if (status && !validStatuses.includes(status)) {
|
||||||
return c.json({ error: `status must be one of: ${validStatuses.join(", ")}` }, 400);
|
return c.json({ error: `status must be one of: ${validStatuses.join(", ")}` }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if inventory is full before allowing payment
|
||||||
|
const p = doc.payment;
|
||||||
|
if (status === 'paid' && p.maxPayments > 0 && p.paymentCount >= p.maxPayments) {
|
||||||
|
return c.json({ error: "This payment request has reached its limit" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
_syncServer!.changeDoc<PaymentRequestDoc>(docId, `payment status → ${status || 'update'}`, (d) => {
|
_syncServer!.changeDoc<PaymentRequestDoc>(docId, `payment status → ${status || 'update'}`, (d) => {
|
||||||
if (status) d.payment.status = status;
|
if (status) d.payment.status = status;
|
||||||
|
|
@ -1279,7 +1309,19 @@ routes.patch("/api/payments/:id/status", async (c) => {
|
||||||
d.payment.amount = String(amount);
|
d.payment.amount = String(amount);
|
||||||
}
|
}
|
||||||
d.payment.updatedAt = now;
|
d.payment.updatedAt = now;
|
||||||
if (status === 'paid') d.payment.paidAt = now;
|
if (status === 'paid') {
|
||||||
|
d.payment.paidAt = now;
|
||||||
|
d.payment.paymentCount = (d.payment.paymentCount || 0) + 1;
|
||||||
|
// For subscriptions/multi-pay, reset to pending after payment (unless filled)
|
||||||
|
if (d.payment.paymentType === 'subscription' || d.payment.maxPayments > 1) {
|
||||||
|
if (d.payment.maxPayments > 0 && d.payment.paymentCount >= d.payment.maxPayments) {
|
||||||
|
d.payment.status = 'filled';
|
||||||
|
} else {
|
||||||
|
// Keep accepting payments — reset status after recording
|
||||||
|
d.payment.status = 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const updated = _syncServer!.getDoc<PaymentRequestDoc>(docId);
|
const updated = _syncServer!.getDoc<PaymentRequestDoc>(docId);
|
||||||
|
|
@ -1311,6 +1353,7 @@ routes.post("/api/payments/:id/transak-session", async (c) => {
|
||||||
|
|
||||||
const p = doc.payment;
|
const p = doc.payment;
|
||||||
if (p.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);
|
if (p.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);
|
||||||
|
if (p.enabledMethods && !p.enabledMethods.card) return c.json({ error: "Card payments are not enabled for this request" }, 400);
|
||||||
|
|
||||||
const { email } = await c.req.json();
|
const { email } = await c.req.json();
|
||||||
if (!email) return c.json({ error: "Required: email" }, 400);
|
if (!email) return c.json({ error: "Required: email" }, 400);
|
||||||
|
|
@ -1358,6 +1401,10 @@ function paymentToResponse(p: PaymentRequestMeta) {
|
||||||
txHash: p.txHash,
|
txHash: p.txHash,
|
||||||
payerIdentity: p.payerIdentity,
|
payerIdentity: p.payerIdentity,
|
||||||
transakOrderId: p.transakOrderId,
|
transakOrderId: p.transakOrderId,
|
||||||
|
paymentType: p.paymentType || 'single',
|
||||||
|
maxPayments: p.maxPayments || 0,
|
||||||
|
paymentCount: p.paymentCount || 0,
|
||||||
|
enabledMethods: p.enabledMethods || { card: true, wallet: true, encryptid: true },
|
||||||
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,
|
||||||
|
|
@ -1461,6 +1508,7 @@ export const cartModule: RSpaceModule = {
|
||||||
name: "rCart",
|
name: "rCart",
|
||||||
icon: "🛒",
|
icon: "🛒",
|
||||||
description: "Group shopping & cosmolocal print-on-demand shop",
|
description: "Group shopping & cosmolocal print-on-demand shop",
|
||||||
|
publicWrite: true,
|
||||||
scoping: { defaultScope: 'space', userConfigurable: false },
|
scoping: { defaultScope: 'space', userConfigurable: false },
|
||||||
docSchemas: [
|
docSchemas: [
|
||||||
{ pattern: '{space}:cart:catalog', description: 'Product catalog', init: catalogSchema.init },
|
{ pattern: '{space}:cart:catalog', description: 'Product catalog', init: catalogSchema.init },
|
||||||
|
|
|
||||||
|
|
@ -287,11 +287,23 @@ export interface PaymentRequestMeta {
|
||||||
fiatAmount: string | null;
|
fiatAmount: string | null;
|
||||||
fiatCurrency: string;
|
fiatCurrency: string;
|
||||||
creatorDid: string;
|
creatorDid: string;
|
||||||
status: 'pending' | 'paid' | 'confirmed' | 'expired' | 'cancelled';
|
status: 'pending' | 'paid' | 'confirmed' | 'expired' | 'cancelled' | 'filled';
|
||||||
paymentMethod: 'transak' | 'wallet' | 'encryptid' | null;
|
paymentMethod: 'transak' | 'wallet' | 'encryptid' | null;
|
||||||
txHash: string | null;
|
txHash: string | null;
|
||||||
payerIdentity: string | null;
|
payerIdentity: string | null;
|
||||||
transakOrderId: string | null;
|
transakOrderId: string | null;
|
||||||
|
// Payment type: one-time or recurring subscription
|
||||||
|
paymentType: 'single' | 'subscription';
|
||||||
|
// Inventory: max number of payments (0 = unlimited)
|
||||||
|
maxPayments: number;
|
||||||
|
// How many have been paid
|
||||||
|
paymentCount: number;
|
||||||
|
// Which payment methods are enabled for payers
|
||||||
|
enabledMethods: {
|
||||||
|
card: boolean;
|
||||||
|
wallet: boolean;
|
||||||
|
encryptid: boolean;
|
||||||
|
};
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
paidAt: number;
|
paidAt: number;
|
||||||
|
|
@ -337,6 +349,10 @@ export const paymentRequestSchema: DocSchema<PaymentRequestDoc> = {
|
||||||
txHash: null,
|
txHash: null,
|
||||||
payerIdentity: null,
|
payerIdentity: null,
|
||||||
transakOrderId: null,
|
transakOrderId: null,
|
||||||
|
paymentType: 'single',
|
||||||
|
maxPayments: 0,
|
||||||
|
paymentCount: 0,
|
||||||
|
enabledMethods: { card: true, wallet: true, encryptid: true },
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
paidAt: 0,
|
paidAt: 0,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue