Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
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' },
|
||||
};
|
||||
Reference in New Issue
Block a user