145 lines
4.3 KiB
TypeScript
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' },
|
|
};
|