feat(rcart): add MoonPay integration with Transak fallback
Add MoonPay as the primary card payment provider for rCart. MoonPay uses HMAC-SHA256 signed URLs (no session API needed, no IP whitelisting). Falls back to Transak if MoonPay keys aren't configured. - shared/moonpay.ts: URL builder with HMAC signing, currency mapping - New /api/payments/:id/card-session endpoint picks provider automatically - Frontend uses unified startCardPayment() with multi-provider message handling - Set MOONPAY_API_KEY + MOONPAY_SECRET_KEY to activate Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c1e8048089
commit
fab155b411
|
|
@ -137,14 +137,15 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
|
|
||||||
// ── Card tab: Transak ──
|
// ── Card tab: Transak ──
|
||||||
|
|
||||||
private async startTransak() {
|
private async startCardPayment() {
|
||||||
if (!this.cardEmail) return;
|
if (!this.cardEmail) return;
|
||||||
this.cardLoading = true;
|
this.cardLoading = true;
|
||||||
this.render();
|
this.render();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const effectiveAmount = this.getEffectiveAmount();
|
const effectiveAmount = this.getEffectiveAmount();
|
||||||
const res = await fetch(`${this.getApiBase()}/api/payments/${this.paymentId}/transak-session`, {
|
// Use the unified card-session endpoint (picks MoonPay or Transak server-side)
|
||||||
|
const res = await fetch(`${this.getApiBase()}/api/payments/${this.paymentId}/card-session`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email: this.cardEmail, ...(effectiveAmount ? { amount: effectiveAmount } : {}) }),
|
body: JSON.stringify({ email: this.cardEmail, ...(effectiveAmount ? { amount: effectiveAmount } : {}) }),
|
||||||
|
|
@ -152,16 +153,17 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Failed to create session');
|
if (!res.ok) throw new Error(data.error || 'Failed to create session');
|
||||||
|
|
||||||
this.transakEnv = data.env || 'PRODUCTION';
|
|
||||||
this.transakUrl = data.widgetUrl;
|
this.transakUrl = data.widgetUrl;
|
||||||
|
this.transakEnv = data.env || 'sandbox';
|
||||||
|
const provider = data.provider || 'transak';
|
||||||
|
|
||||||
// Listen for Transak postMessage events
|
// Listen for postMessage events from both MoonPay and Transak
|
||||||
window.addEventListener('message', this.handleTransakMessage);
|
window.addEventListener('message', this.handlePaymentMessage);
|
||||||
|
|
||||||
// Transak blocks iframes (X-Frame-Options: sameorigin) — always use popup
|
// Open in popup (both providers block iframes)
|
||||||
this.transakPopup = window.open(
|
this.transakPopup = window.open(
|
||||||
data.widgetUrl,
|
data.widgetUrl,
|
||||||
'transak-payment',
|
'card-payment',
|
||||||
'width=450,height=700,scrollbars=yes,resizable=yes',
|
'width=450,height=700,scrollbars=yes,resizable=yes',
|
||||||
);
|
);
|
||||||
// Poll for popup close (user cancelled)
|
// Poll for popup close (user cancelled)
|
||||||
|
|
@ -178,12 +180,18 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleTransakMessage = (e: MessageEvent) => {
|
private handlePaymentMessage = (e: MessageEvent) => {
|
||||||
if (!e.data?.event_id) return;
|
// Transak events
|
||||||
if (e.data.event_id === 'TRANSAK_ORDER_SUCCESSFUL' || e.data.event_id === 'TRANSAK_ORDER_COMPLETED') {
|
if (e.data?.event_id === 'TRANSAK_ORDER_SUCCESSFUL' || e.data?.event_id === 'TRANSAK_ORDER_COMPLETED') {
|
||||||
const orderId = e.data.data?.id || e.data.data?.orderId;
|
const orderId = e.data.data?.id || e.data.data?.orderId;
|
||||||
this.updatePaymentStatus('paid', 'transak', null, orderId);
|
this.updatePaymentStatus('paid', 'transak', null, orderId);
|
||||||
window.removeEventListener('message', this.handleTransakMessage);
|
window.removeEventListener('message', this.handlePaymentMessage);
|
||||||
|
}
|
||||||
|
// MoonPay events
|
||||||
|
if (e.data?.type === 'moonpay_onTransactionCompleted' || e.data?.type === 'MOONPAY_TRANSACTION_COMPLETED') {
|
||||||
|
const txId = e.data.data?.id || e.data.transactionId;
|
||||||
|
this.updatePaymentStatus('paid', 'moonpay', null, txId);
|
||||||
|
window.removeEventListener('message', this.handlePaymentMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -574,7 +582,7 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
<div class="transak-popup-status">
|
<div class="transak-popup-status">
|
||||||
<p class="tab-desc">Transak payment opened in a new window.</p>
|
<p class="tab-desc">Transak payment opened in a new window.</p>
|
||||||
<p class="tab-desc" style="font-size:0.8125rem; color:var(--rs-text-muted)">Complete the payment in the popup window. This page will update automatically.</p>
|
<p class="tab-desc" style="font-size:0.8125rem; color:var(--rs-text-muted)">Complete the payment in the popup window. This page will update automatically.</p>
|
||||||
<button class="btn" data-action="reopen-transak" style="margin-top:0.5rem">Re-open Payment Window</button>
|
<button class="btn" data-action="reopen-card-payment" style="margin-top:0.5rem">Re-open Payment Window</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -585,7 +593,7 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<input type="email" placeholder="Your email address" class="input" data-field="card-email" value="${this.esc(this.cardEmail)}" />
|
<input type="email" placeholder="Your email address" class="input" data-field="card-email" value="${this.esc(this.cardEmail)}" />
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" data-action="start-transak" ${this.cardLoading ? 'disabled' : ''}>
|
<button class="btn btn-primary" data-action="start-card-payment" ${this.cardLoading ? 'disabled' : ''}>
|
||||||
${this.cardLoading ? 'Loading...' : 'Pay with Card'}
|
${this.cardLoading ? 'Loading...' : 'Pay with Card'}
|
||||||
</button>
|
</button>
|
||||||
${this.error ? `<div class="field-error">${this.esc(this.error)}</div>` : ''}
|
${this.error ? `<div class="field-error">${this.esc(this.error)}</div>` : ''}
|
||||||
|
|
@ -677,8 +685,8 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
// Card tab
|
// Card tab
|
||||||
const emailInput = this.shadow.querySelector('[data-field="card-email"]') as HTMLInputElement;
|
const emailInput = this.shadow.querySelector('[data-field="card-email"]') as HTMLInputElement;
|
||||||
emailInput?.addEventListener('input', () => { this.cardEmail = emailInput.value; });
|
emailInput?.addEventListener('input', () => { this.cardEmail = emailInput.value; });
|
||||||
this.shadow.querySelector('[data-action="start-transak"]')?.addEventListener('click', () => this.startTransak());
|
this.shadow.querySelector('[data-action="start-card-payment"]')?.addEventListener('click', () => this.startCardPayment());
|
||||||
this.shadow.querySelector('[data-action="reopen-transak"]')?.addEventListener('click', () => {
|
this.shadow.querySelector('[data-action="reopen-card-payment"]')?.addEventListener('click', () => {
|
||||||
if (this.transakUrl) {
|
if (this.transakUrl) {
|
||||||
this.transakPopup = window.open(
|
this.transakPopup = window.open(
|
||||||
this.transakUrl,
|
this.transakUrl,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
} from './schemas';
|
} from './schemas';
|
||||||
import { extractProductFromUrl } from './extract';
|
import { extractProductFromUrl } from './extract';
|
||||||
import { createSecureWidgetUrl, extractRootDomain, getTransakApiKey, getTransakEnv } from '../../shared/transak';
|
import { createSecureWidgetUrl, extractRootDomain, getTransakApiKey, getTransakEnv } from '../../shared/transak';
|
||||||
|
import { createMoonPayPaymentUrl, getMoonPayApiKey, getMoonPayEnv } from '../../shared/moonpay';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { createTransport, type Transporter } from "nodemailer";
|
import { createTransport, type Transporter } from "nodemailer";
|
||||||
import {
|
import {
|
||||||
|
|
@ -1796,6 +1797,77 @@ routes.post("/api/payments/:id/transak-session", async (c) => {
|
||||||
return c.json({ widgetUrl, env: transakEnv });
|
return c.json({ widgetUrl, env: transakEnv });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/payments/:id/card-session — Get on-ramp widget URL (MoonPay preferred, Transak fallback)
|
||||||
|
routes.post("/api/payments/:id/card-session", async (c) => {
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const paymentId = c.req.param("id");
|
||||||
|
const docId = paymentRequestDocId(space, paymentId);
|
||||||
|
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
|
||||||
|
if (!doc) return c.json({ error: "Payment request not found" }, 404);
|
||||||
|
|
||||||
|
const p = doc.payment;
|
||||||
|
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, amount: overrideAmount } = await c.req.json();
|
||||||
|
if (!email) return c.json({ error: "Required: email" }, 400);
|
||||||
|
|
||||||
|
const effectiveAmount = (p.amountEditable && overrideAmount) ? String(overrideAmount) : p.amount;
|
||||||
|
|
||||||
|
// Try MoonPay first (simpler, no session API needed)
|
||||||
|
const moonPayKey = getMoonPayApiKey();
|
||||||
|
if (moonPayKey) {
|
||||||
|
try {
|
||||||
|
const widgetUrl = createMoonPayPaymentUrl({
|
||||||
|
walletAddress: p.recipientAddress,
|
||||||
|
token: p.token,
|
||||||
|
chainId: p.chainId,
|
||||||
|
amount: effectiveAmount,
|
||||||
|
fiatAmount: p.fiatAmount || undefined,
|
||||||
|
fiatCurrency: p.fiatCurrency || 'USD',
|
||||||
|
email,
|
||||||
|
paymentId,
|
||||||
|
});
|
||||||
|
return c.json({ widgetUrl, provider: 'moonpay', env: getMoonPayEnv() });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[rcart] MoonPay URL generation failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Transak
|
||||||
|
const transakApiKey = getTransakApiKey();
|
||||||
|
if (!transakApiKey) return c.json({ error: "No payment provider configured" }, 503);
|
||||||
|
|
||||||
|
const networkMap: Record<number, string> = { 8453: 'base', 84532: 'base', 1: 'ethereum' };
|
||||||
|
const host = c.req.header('x-forwarded-host') || c.req.header('host') || new URL(c.req.url).hostname;
|
||||||
|
|
||||||
|
const widgetParams: Record<string, string> = {
|
||||||
|
apiKey: transakApiKey,
|
||||||
|
referrerDomain: extractRootDomain(host),
|
||||||
|
cryptoCurrencyCode: p.token,
|
||||||
|
network: networkMap[p.chainId] || 'base',
|
||||||
|
defaultCryptoCurrency: p.token,
|
||||||
|
walletAddress: p.recipientAddress,
|
||||||
|
disableWalletAddressForm: 'true',
|
||||||
|
defaultCryptoAmount: effectiveAmount,
|
||||||
|
partnerOrderId: `pay-${paymentId}`,
|
||||||
|
email,
|
||||||
|
themeColor: '6366f1',
|
||||||
|
hideMenu: 'true',
|
||||||
|
};
|
||||||
|
if (p.fiatAmount) {
|
||||||
|
widgetParams.fiatAmount = p.fiatAmount;
|
||||||
|
widgetParams.defaultFiatAmount = p.fiatAmount;
|
||||||
|
}
|
||||||
|
if (p.fiatCurrency) {
|
||||||
|
widgetParams.fiatCurrency = p.fiatCurrency;
|
||||||
|
widgetParams.defaultFiatCurrency = p.fiatCurrency;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgetUrl = await createSecureWidgetUrl(widgetParams);
|
||||||
|
return c.json({ widgetUrl, provider: 'transak', env: getTransakEnv() });
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/payments/:id/share-email — Email payment link to recipients
|
// POST /api/payments/:id/share-email — Email payment link to recipients
|
||||||
routes.post("/api/payments/:id/share-email", async (c) => {
|
routes.post("/api/payments/:id/share-email", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
/**
|
||||||
|
* MoonPay API utilities — fiat-to-crypto on-ramp.
|
||||||
|
*
|
||||||
|
* MoonPay widget URLs are built with query params and signed server-side
|
||||||
|
* with HMAC-SHA256 using the API secret. No session API needed.
|
||||||
|
*
|
||||||
|
* Environment: MOONPAY_ENV controls sandbox vs production.
|
||||||
|
* Docs: https://dev.moonpay.com/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHmac } from 'crypto';
|
||||||
|
|
||||||
|
export type MoonPayEnv = 'sandbox' | 'production';
|
||||||
|
|
||||||
|
const WIDGET_URLS = {
|
||||||
|
sandbox: 'https://buy-sandbox.moonpay.com',
|
||||||
|
production: 'https://buy.moonpay.com',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Get current MoonPay environment */
|
||||||
|
export function getMoonPayEnv(): MoonPayEnv {
|
||||||
|
const env = process.env.MOONPAY_ENV?.toLowerCase();
|
||||||
|
return env === 'production' ? 'production' : 'sandbox';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get MoonPay publishable API key */
|
||||||
|
export function getMoonPayApiKey(): string {
|
||||||
|
const env = getMoonPayEnv();
|
||||||
|
return (env === 'production'
|
||||||
|
? process.env.MOONPAY_API_KEY_PRODUCTION
|
||||||
|
: process.env.MOONPAY_API_KEY_SANDBOX
|
||||||
|
) || process.env.MOONPAY_API_KEY || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get MoonPay secret key (for URL signing) */
|
||||||
|
export function getMoonPaySecretKey(): string {
|
||||||
|
const env = getMoonPayEnv();
|
||||||
|
return (env === 'production'
|
||||||
|
? process.env.MOONPAY_SECRET_KEY_PRODUCTION
|
||||||
|
: process.env.MOONPAY_SECRET_KEY_SANDBOX
|
||||||
|
) || process.env.MOONPAY_SECRET_KEY || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chain ID to MoonPay network name */
|
||||||
|
const NETWORK_MAP: Record<number, string> = {
|
||||||
|
8453: 'base',
|
||||||
|
84532: 'base',
|
||||||
|
1: 'ethereum',
|
||||||
|
137: 'polygon',
|
||||||
|
42161: 'arbitrum',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Token symbol to MoonPay currency code */
|
||||||
|
const CURRENCY_MAP: Record<string, Record<string, string>> = {
|
||||||
|
USDC: {
|
||||||
|
base: 'usdc_base',
|
||||||
|
ethereum: 'usdc',
|
||||||
|
polygon: 'usdc_polygon',
|
||||||
|
arbitrum: 'usdc_arbitrum',
|
||||||
|
},
|
||||||
|
ETH: {
|
||||||
|
base: 'eth_base',
|
||||||
|
ethereum: 'eth',
|
||||||
|
arbitrum: 'eth_arbitrum',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MoonPayWidgetParams {
|
||||||
|
walletAddress: string;
|
||||||
|
email?: string;
|
||||||
|
currencyCode?: string; // e.g. 'usdc_base'
|
||||||
|
baseCurrencyCode?: string; // e.g. 'usd'
|
||||||
|
baseCurrencyAmount?: number; // fiat amount
|
||||||
|
quoteCurrencyAmount?: number; // crypto amount
|
||||||
|
externalTransactionId?: string;
|
||||||
|
colorCode?: string; // hex without # (e.g. '6366f1')
|
||||||
|
redirectURL?: string;
|
||||||
|
showWalletAddressForm?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a signed MoonPay widget URL.
|
||||||
|
* The URL is signed with HMAC-SHA256 using the secret key.
|
||||||
|
*/
|
||||||
|
export function createMoonPayWidgetUrl(params: MoonPayWidgetParams): string {
|
||||||
|
const env = getMoonPayEnv();
|
||||||
|
const apiKey = getMoonPayApiKey();
|
||||||
|
const secretKey = getMoonPaySecretKey();
|
||||||
|
|
||||||
|
if (!apiKey) throw new Error('MoonPay API key not configured');
|
||||||
|
if (!secretKey) throw new Error('MoonPay secret key not configured');
|
||||||
|
|
||||||
|
const base = WIDGET_URLS[env];
|
||||||
|
const url = new URL(base);
|
||||||
|
|
||||||
|
url.searchParams.set('apiKey', apiKey);
|
||||||
|
url.searchParams.set('walletAddress', params.walletAddress);
|
||||||
|
|
||||||
|
if (params.currencyCode) url.searchParams.set('currencyCode', params.currencyCode);
|
||||||
|
if (params.baseCurrencyCode) url.searchParams.set('baseCurrencyCode', params.baseCurrencyCode);
|
||||||
|
if (params.baseCurrencyAmount) url.searchParams.set('baseCurrencyAmount', String(params.baseCurrencyAmount));
|
||||||
|
if (params.quoteCurrencyAmount) url.searchParams.set('quoteCurrencyAmount', String(params.quoteCurrencyAmount));
|
||||||
|
if (params.email) url.searchParams.set('email', params.email);
|
||||||
|
if (params.externalTransactionId) url.searchParams.set('externalTransactionId', params.externalTransactionId);
|
||||||
|
if (params.colorCode) url.searchParams.set('colorCode', params.colorCode);
|
||||||
|
if (params.redirectURL) url.searchParams.set('redirectURL', params.redirectURL);
|
||||||
|
if (params.showWalletAddressForm === false) url.searchParams.set('showWalletAddressForm', 'false');
|
||||||
|
|
||||||
|
// Sign the URL: HMAC-SHA256 of the query string (including the leading '?')
|
||||||
|
const signature = createHmac('sha256', secretKey)
|
||||||
|
.update(url.search)
|
||||||
|
.digest('base64url');
|
||||||
|
url.searchParams.set('signature', signature);
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a MoonPay widget URL for a payment request.
|
||||||
|
* Convenience wrapper that maps token/chainId to MoonPay currency codes.
|
||||||
|
*/
|
||||||
|
export function createMoonPayPaymentUrl(opts: {
|
||||||
|
walletAddress: string;
|
||||||
|
token: string;
|
||||||
|
chainId: number;
|
||||||
|
amount?: string;
|
||||||
|
fiatAmount?: string;
|
||||||
|
fiatCurrency?: string;
|
||||||
|
email?: string;
|
||||||
|
paymentId?: string;
|
||||||
|
}): string {
|
||||||
|
const network = NETWORK_MAP[opts.chainId] || 'base';
|
||||||
|
const currencyCode = CURRENCY_MAP[opts.token]?.[network] || 'usdc_base';
|
||||||
|
|
||||||
|
const params: MoonPayWidgetParams = {
|
||||||
|
walletAddress: opts.walletAddress,
|
||||||
|
currencyCode,
|
||||||
|
email: opts.email,
|
||||||
|
externalTransactionId: opts.paymentId ? `pay-${opts.paymentId}` : undefined,
|
||||||
|
colorCode: '6366f1',
|
||||||
|
showWalletAddressForm: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prefer fiat amount if available, otherwise use crypto amount
|
||||||
|
if (opts.fiatAmount && parseFloat(opts.fiatAmount) > 0) {
|
||||||
|
params.baseCurrencyAmount = parseFloat(opts.fiatAmount);
|
||||||
|
params.baseCurrencyCode = (opts.fiatCurrency || 'USD').toLowerCase();
|
||||||
|
} else if (opts.amount && parseFloat(opts.amount) > 0) {
|
||||||
|
params.quoteCurrencyAmount = parseFloat(opts.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createMoonPayWidgetUrl(params);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue