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:
Kev
2026-07-27 16:51:59 -04:00
parent 3592aba8d5
commit afb56b144b
9 changed files with 17 additions and 262 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -2
View File
@@ -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.
-100
View File
@@ -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 });
}
+1 -1
View File
@@ -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
-144
View File
@@ -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' },
};