Files

145 lines
4.3 KiB
TypeScript

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<string, string>;
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<NexaPayPaymentLink> {
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=<unix>, v1=<hex>` 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<Record<string, string>>((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<NexaPayTier, { regular: number; founder: number; label: string }> = {
analyst: { regular: 24.99, founder: 14.99, label: 'VYNDR Analyst — Monthly' },
desk: { regular: 49.99, founder: 44.99, label: 'VYNDR Desk — Monthly' },
};