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 ──
|
||||
|
||||
private async startTransak() {
|
||||
private async startCardPayment() {
|
||||
if (!this.cardEmail) return;
|
||||
this.cardLoading = true;
|
||||
this.render();
|
||||
|
||||
try {
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: this.cardEmail, ...(effectiveAmount ? { amount: effectiveAmount } : {}) }),
|
||||
|
|
@ -152,16 +153,17 @@ class FolkPaymentPage extends HTMLElement {
|
|||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to create session');
|
||||
|
||||
this.transakEnv = data.env || 'PRODUCTION';
|
||||
this.transakUrl = data.widgetUrl;
|
||||
this.transakEnv = data.env || 'sandbox';
|
||||
const provider = data.provider || 'transak';
|
||||
|
||||
// Listen for Transak postMessage events
|
||||
window.addEventListener('message', this.handleTransakMessage);
|
||||
// Listen for postMessage events from both MoonPay and Transak
|
||||
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(
|
||||
data.widgetUrl,
|
||||
'transak-payment',
|
||||
'card-payment',
|
||||
'width=450,height=700,scrollbars=yes,resizable=yes',
|
||||
);
|
||||
// Poll for popup close (user cancelled)
|
||||
|
|
@ -178,12 +180,18 @@ class FolkPaymentPage extends HTMLElement {
|
|||
this.render();
|
||||
}
|
||||
|
||||
private handleTransakMessage = (e: MessageEvent) => {
|
||||
if (!e.data?.event_id) return;
|
||||
if (e.data.event_id === 'TRANSAK_ORDER_SUCCESSFUL' || e.data.event_id === 'TRANSAK_ORDER_COMPLETED') {
|
||||
private handlePaymentMessage = (e: MessageEvent) => {
|
||||
// Transak events
|
||||
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;
|
||||
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">
|
||||
<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>
|
||||
<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>`;
|
||||
}
|
||||
|
|
@ -585,7 +593,7 @@ class FolkPaymentPage extends HTMLElement {
|
|||
<div class="form-row">
|
||||
<input type="email" placeholder="Your email address" class="input" data-field="card-email" value="${this.esc(this.cardEmail)}" />
|
||||
</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'}
|
||||
</button>
|
||||
${this.error ? `<div class="field-error">${this.esc(this.error)}</div>` : ''}
|
||||
|
|
@ -677,8 +685,8 @@ class FolkPaymentPage extends HTMLElement {
|
|||
// Card tab
|
||||
const emailInput = this.shadow.querySelector('[data-field="card-email"]') as HTMLInputElement;
|
||||
emailInput?.addEventListener('input', () => { this.cardEmail = emailInput.value; });
|
||||
this.shadow.querySelector('[data-action="start-transak"]')?.addEventListener('click', () => this.startTransak());
|
||||
this.shadow.querySelector('[data-action="reopen-transak"]')?.addEventListener('click', () => {
|
||||
this.shadow.querySelector('[data-action="start-card-payment"]')?.addEventListener('click', () => this.startCardPayment());
|
||||
this.shadow.querySelector('[data-action="reopen-card-payment"]')?.addEventListener('click', () => {
|
||||
if (this.transakUrl) {
|
||||
this.transakPopup = window.open(
|
||||
this.transakUrl,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
} from './schemas';
|
||||
import { extractProductFromUrl } from './extract';
|
||||
import { createSecureWidgetUrl, extractRootDomain, getTransakApiKey, getTransakEnv } from '../../shared/transak';
|
||||
import { createMoonPayPaymentUrl, getMoonPayApiKey, getMoonPayEnv } from '../../shared/moonpay';
|
||||
import QRCode from 'qrcode';
|
||||
import { createTransport, type Transporter } from "nodemailer";
|
||||
import {
|
||||
|
|
@ -1796,6 +1797,77 @@ routes.post("/api/payments/:id/transak-session", async (c) => {
|
|||
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
|
||||
routes.post("/api/payments/:id/share-email", async (c) => {
|
||||
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