import crypto from 'crypto'; /** * NexaPay payment processor wrapper. * * NexaPay accepts cards (Visa/Mastercard/Apple Pay/Google Pay) on the customer * side and settles to VYNDR in stablecoin (USDC/USDT). The customer never * sees crypto. * * Required env vars (set on the deployment, never commit): * NEXAPAY_API_KEY — bearer token used for outbound API calls * NEXAPAY_WEBHOOK_SECRET — HMAC secret for verifying inbound webhooks * NEXAPAY_API_URL — defaults to https://api.nexapay.one/v1 * NEXT_PUBLIC_SITE_URL — used to construct redirect + webhook URLs */ const API_URL = process.env.NEXAPAY_API_URL || 'https://api.nexapay.one/v1'; const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'; export type NexaPayTier = 'analyst' | 'desk'; export interface CreatePaymentLinkParams { userId: string; tier: NexaPayTier; amount: number; // dollars, e.g. 14.99 description: string; founderPricing?: boolean; } export interface NexaPayPaymentLink { id: string; url: string; expires_at: string; } export interface NexaPayWebhookEvent { id: string; type: 'payment.succeeded' | 'payment.failed' | 'payment.refunded' | 'subscription.canceled'; created: number; data: { payment_id: string; customer_id?: string; amount: number; currency: string; metadata: Record; settled_amount?: number; settled_currency?: string; }; } function requireApiKey(): string { const key = process.env.NEXAPAY_API_KEY; if (!key) { throw new Error('NEXAPAY_API_KEY is not set'); } return key; } export async function createPaymentLink(params: CreatePaymentLinkParams): Promise { const apiKey = requireApiKey(); const body = { amount: Math.round(params.amount * 100), currency: 'USD', description: params.description, redirect_url: `${SITE_URL}/scan?upgraded=true`, cancel_url: `${SITE_URL}/?canceled=true#pricing`, webhook_url: `${SITE_URL}/api/webhook/nexapay`, customer_reference: params.userId, metadata: { userId: params.userId, tier: params.tier, type: 'subscription', founderPricing: String(params.founderPricing ?? false), }, }; const res = await fetch(`${API_URL}/payment-links`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); if (!res.ok) { const errBody = await res.text().catch(() => ''); throw new Error(`NexaPay create payment link failed (${res.status}): ${errBody}`); } return (await res.json()) as NexaPayPaymentLink; } export async function getTransaction(paymentId: string) { const apiKey = requireApiKey(); const res = await fetch(`${API_URL}/payments/${paymentId}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); if (!res.ok) { throw new Error(`NexaPay get transaction failed (${res.status})`); } return res.json(); } /** * Verify a NexaPay webhook signature. * NexaPay sends `x-nexapay-signature: t=, v1=` where v1 is * HMAC-SHA256(secret, `${t}.${rawBody}`). */ export function verifyWebhookSignature(rawBody: string, signatureHeader: string | null): boolean { const secret = process.env.NEXAPAY_WEBHOOK_SECRET; if (!secret || !signatureHeader) return false; const parts = signatureHeader.split(',').reduce>((acc, part) => { const [k, v] = part.trim().split('='); if (k && v) acc[k] = v; return acc; }, {}); const timestamp = parts['t']; const expected = parts['v1']; if (!timestamp || !expected) return false; // 5-minute replay window const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false; const computed = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); try { return crypto.timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(expected, 'hex')); } catch { return false; } } export const TIER_PRICING: Record = { analyst: { regular: 24.99, founder: 14.99, label: 'VYNDR Analyst — Monthly' }, desk: { regular: 49.99, founder: 44.99, label: 'VYNDR Desk — Monthly' }, };