NexaPay purge: VYNDR is Stripe-only — remove all NexaPay traces
NexaPay was cross-project contamination (from another venture) — never a real VYNDR payment path. Purged; Stripe path untouched. Removed: - web/src/services/nexapay.ts (createPaymentLink/getTransaction/HMAC verify) - web/src/app/api/webhook/nexapay/route.ts (the only importer; Next-registered, reachable — now gone) - NexaPay comments in email.ts + checkout/route.ts - Active NexaPay entries in docs/SYSTEM-MANIFEST.md (route list, NEXAPAY_* env table, service row) + stale claim in wiring-data-train.md - sw.js precache entry for the deleted webhook chunk Verified: ZERO NexaPay in code (web/src, src, tests). Full suite 3833 green (count unchanged — nothing depended on it, confirming it was dead). Web build exit 0. sw.js parses clean. Stripe checkout untouched (Next→Express→Stripe). FLAGGED FOR KEV (a repo delete cannot close these): - Coolify env: remove NEXAPAY_API_KEY / NEXAPAY_WEBHOOK_SECRET / NEXAPAY_API_URL - Revoke the NexaPay API key + webhook secret at NexaPay's dashboard; de-register the webhook if an account was ever configured - DB column user_profiles.nexapay_customer_id is orphaned (no reader/writer) — drop via a follow-up migration (migration 011 left as history) Cross-project check: ZERO Noctem-Supabase refs; VYNDR references only its own Supabase (zmdnczhtdxcddsxzttub). NexaPay was the sole contamination found. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
@@ -9,8 +9,7 @@ const VALID_TIERS = new Set(['analyst', 'desk']);
|
||||
/**
|
||||
* Checkout proxy — Next.js → Express → Stripe.
|
||||
*
|
||||
* Session 8 cutover: previously this route created NexaPay payment
|
||||
* links; now it forwards to the Express `/api/stripe/checkout` route
|
||||
* This route forwards to the Express `/api/stripe/checkout` route
|
||||
* (Session 3.4 + 7i) which creates a Stripe Checkout Session
|
||||
* server-side. The browser never sees `sk_test_*` / `sk_live_*` —
|
||||
* only the resulting `https://checkout.stripe.com/...` redirect URL.
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
import { verifyWebhookSignature, type NexaPayWebhookEvent, type NexaPayTier } from '@/services/nexapay';
|
||||
import { sendPaymentReceipt } from '@/services/email';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
// We need the raw body to verify the HMAC signature.
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const rawBody = await req.text();
|
||||
const signature = req.headers.get('x-nexapay-signature');
|
||||
|
||||
if (!verifyWebhookSignature(rawBody, signature)) {
|
||||
return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
let event: NexaPayWebhookEvent;
|
||||
try {
|
||||
event = JSON.parse(rawBody) as NexaPayWebhookEvent;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) {
|
||||
console.error('[nexapay webhook] Supabase service role not configured');
|
||||
return NextResponse.json({ error: 'misconfigured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const userId = event.data.metadata?.userId;
|
||||
const tier = event.data.metadata?.tier as NexaPayTier | undefined;
|
||||
const founderPricing = event.data.metadata?.founderPricing === 'true';
|
||||
|
||||
if (!userId || !tier) {
|
||||
return NextResponse.json({ ok: true, ignored: 'missing metadata' });
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'payment.succeeded': {
|
||||
const subscription_end = new Date();
|
||||
subscription_end.setUTCDate(subscription_end.getUTCDate() + 30);
|
||||
|
||||
const { error } = await sb
|
||||
.from('user_profiles')
|
||||
.update({
|
||||
tier,
|
||||
subscription_status: 'active',
|
||||
subscription_start: new Date().toISOString(),
|
||||
subscription_end: subscription_end.toISOString(),
|
||||
cancel_at_period_end: false,
|
||||
founder_pricing: founderPricing,
|
||||
nexapay_customer_id: event.data.customer_id ?? null,
|
||||
})
|
||||
.eq('id', userId);
|
||||
|
||||
if (error) {
|
||||
console.error('[nexapay webhook] update failed', error);
|
||||
return NextResponse.json({ error: 'update_failed' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Fire-and-forget receipt email. Don't block the webhook ACK.
|
||||
const { data: profileRow } = await sb
|
||||
.from('user_profiles')
|
||||
.select('email')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
if (profileRow?.email) {
|
||||
void sendPaymentReceipt(profileRow.email, {
|
||||
tier,
|
||||
amount: `$${(event.data.amount / 100).toFixed(2)}`,
|
||||
renewsAt: subscription_end.toISOString().slice(0, 10),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'payment.failed': {
|
||||
await sb
|
||||
.from('user_profiles')
|
||||
.update({ subscription_status: 'grace_period' })
|
||||
.eq('id', userId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'payment.refunded':
|
||||
case 'subscription.canceled': {
|
||||
await sb
|
||||
.from('user_profiles')
|
||||
.update({
|
||||
subscription_status: 'canceled',
|
||||
cancel_at_period_end: true,
|
||||
})
|
||||
.eq('id', userId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Three flows for launch:
|
||||
* - sendWelcomeEmail() — on signup
|
||||
* - sendPaymentReceipt() — on successful NexaPay webhook
|
||||
* - sendPaymentReceipt() — on a successful payment webhook
|
||||
* - sendRenewalReminder() — daily cron when subscription_end < 3 days out
|
||||
*
|
||||
* All functions return { ok: boolean, id?: string, error?: string } and
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
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