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
+8 -10
View File
@@ -101,7 +101,7 @@ Mounted in `src/app.js`. Auth column meanings:
These are proxies or thin wrappers; they hit Express via `BACKEND_URL` These are proxies or thin wrappers; they hit Express via `BACKEND_URL`
or the Python service via `NEXT_PUBLIC_NBA_SERVICE_URL`. or the Python service via `NEXT_PUBLIC_NBA_SERVICE_URL`.
- `/api/checkout` (POST/GET) — Stripe checkout proxy (Session 8 cutover — was NexaPay) - `/api/checkout` (POST/GET) — Stripe checkout proxy
- `/api/games/[id]` and `/api/games/tonight` — list / detail - `/api/games/[id]` and `/api/games/tonight` — list / detail
- `/api/games/[id]/props` — props for a game - `/api/games/[id]/props` — props for a game
- `/api/intelligence/feed` — homepage live signals - `/api/intelligence/feed` — homepage live signals
@@ -117,7 +117,6 @@ or the Python service via `NEXT_PUBLIC_NBA_SERVICE_URL`.
- `/api/stats/parlays-graded`, `/api/stats/public` — proxy - `/api/stats/parlays-graded`, `/api/stats/public` — proxy
- `/api/user/profile`, `/api/user/scans`, `/api/user/recent-scans` - `/api/user/profile`, `/api/user/scans`, `/api/user/recent-scans`
- `/api/waitlist` — proxy - `/api/waitlist` — proxy
- `/api/webhook/nexapay` — NexaPay webhook (legacy — Stripe cutover Session 8; webhook still listening for any in-flight NexaPay events)
### Next.js pages (Session 8 additions) ### Next.js pages (Session 8 additions)
@@ -175,9 +174,6 @@ back). Updated this session in Section 1 of Session 7c.
| `STRIPE_PRICE_DESK_FOUNDER` | ✓ | | `STRIPE_PRICE_DESK_FOUNDER` | ✓ |
| `FOUNDER_CODES` | ✓ | | `FOUNDER_CODES` | ✓ |
| `FOUNDER_CODE_EXPIRY` | ✓ | | `FOUNDER_CODE_EXPIRY` | ✓ |
| `NEXAPAY_API_URL` | ✓ (added 7c) |
| `NEXAPAY_API_KEY` | ✓ (added 7c) |
| `NEXAPAY_WEBHOOK_SECRET` | ✓ (added 7c) |
### Push ### Push
| Var | Doc? | | Var | Doc? |
@@ -427,7 +423,6 @@ Source: `grep -rn "cacheSet\|cacheGet\|redis\.set"`.
| FanDuel/BetMGM/Caesars/PrizePicks/Covers/Rotowire (legacy) | each `*Adapter.js` | none | tunable | UnifiedOddsProvider | | FanDuel/BetMGM/Caesars/PrizePicks/Covers/Rotowire (legacy) | each `*Adapter.js` | none | tunable | UnifiedOddsProvider |
| Sports-Reference HTML | `scripts/scrape-sports-reference.js` | `REF_HTML_FILE`, `COACH_HTML_FILE` (optional) | 1 req / 5s | scraper | | Sports-Reference HTML | `scripts/scrape-sports-reference.js` | `REF_HTML_FILE`, `COACH_HTML_FILE` (optional) | 1 req / 5s | scraper |
| Resend (email) | `web/src/services/email.ts` | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | n/a | transactional email | | Resend (email) | `web/src/services/email.ts` | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | n/a | transactional email |
| NexaPay | `web/src/services/nexapay.ts` | `NEXAPAY_*` | n/a | checkout fallback |
| PostHog | `web/src/lib/analytics.ts` | `NEXT_PUBLIC_POSTHOG_KEY/HOST` | n/a | browser analytics | | PostHog | `web/src/lib/analytics.ts` | `NEXT_PUBLIC_POSTHOG_KEY/HOST` | n/a | browser analytics |
| football-data.org | `footballDataAdapter.js` | `FOOTBALL_DATA_API_KEY` | 10/min (8 enforced) | poller-soccer, prefetch (TERTIARY) | | football-data.org | `footballDataAdapter.js` | `FOOTBALL_DATA_API_KEY` | 10/min (8 enforced) | poller-soccer, prefetch (TERTIARY) |
| api-football.com | `apiFootballAdapter.js` | `API_FOOTBALL_KEY` | 100/day (soft 90) | soccer cascade (PRIMARY, Session 9) | | api-football.com | `apiFootballAdapter.js` | `API_FOOTBALL_KEY` | 100/day (soft 90) | soccer cascade (PRIMARY, Session 9) |
@@ -703,10 +698,13 @@ The dual-provider divergence flagged in 7h is closed:
shipped. Express `stripeService.js` updated to point `success_url` shipped. Express `stripeService.js` updated to point `success_url`
and `cancel_url` at the new frontend pages via `NEXT_PUBLIC_SITE_URL` and `cancel_url` at the new frontend pages via `NEXT_PUBLIC_SITE_URL`
(the only backend file touched in Session 8). (the only backend file touched in Session 8).
4. NexaPay is still wired but no UI calls it. Disposition (remove vs 4. NexaPay has been PURGED (2026-07-27). VYNDR is Stripe-only — NexaPay was
keep as fallback) is a follow-up call — leaving it in place doesn't cross-project contamination copied from another venture and never a real
cost anything and gives the team a fallback if Stripe goes down VYNDR payment path. Code, route, and env references removed. Provider-side
during the World Cup window. cleanup owed to Kev: remove `NEXAPAY_*` from Coolify env, revoke the NexaPay
API key + webhook secret, and de-register the webhook at NexaPay if an
account was ever configured. DB column `user_profiles.nexapay_customer_id`
is orphaned (no reader/writer) — drop via a follow-up migration.
--- ---
+1 -1
View File
@@ -125,7 +125,7 @@ scorer, pipeline, or real feature touched. Updated HONEST cells:
|---|---|---| |---|---|---|
| /compare (row 20) | HONEST: NO — hardcoded Jokić A+/Wembanyama A + fake VERDICT, nav-linked | Honest **in-development** state; removed from Nav + BottomTabBar. Real two-player build **pulled, awaiting real build**. | | /compare (row 20) | HONEST: NO — hardcoded Jokić A+/Wembanyama A + fake VERDICT, nav-linked | Honest **in-development** state; removed from Nav + BottomTabBar. Real two-player build **pulled, awaiting real build**. |
| Pricing (founder copy) | $34.99 desk / struck $19.99 / FAQ $24.99 — wrong | Founder **Desk $44.99** (matches lib/checkout.js), **Analyst $14.99**; struck "regular" numbers removed; DeskShowcase $34.99→$44.99. First-100 counter is REAL (ClaimMeter→Stripe). No "first 50" desk claim (no such counter). | | Pricing (founder copy) | $34.99 desk / struck $19.99 / FAQ $24.99 — wrong | Founder **Desk $44.99** (matches lib/checkout.js), **Analyst $14.99**; struck "regular" numbers removed; DeskShowcase $34.99→$44.99. First-100 counter is REAL (ClaimMeter→Stripe). No "first 50" desk claim (no such counter). |
| FAQ processor | "NexaPay" | **Stripe** (verified live: Next→Express→checkout.stripe.com). `web/src/services/nexapay.ts` is dead legacy code (webhook-only) — cleanup logged, not user-facing. | | FAQ processor | "NexaPay" | **Stripe** (verified live: Next→Express→checkout.stripe.com). `nexapay.ts` + its webhook route were **PURGED 2026-07-27** (NexaPay Purge order) — cross-project contamination, never a real VYNDR path. Provider-side env/keys + the orphaned `user_profiles.nexapay_customer_id` column flagged for Kev. |
| FAQ + Features "Brier/CLV published from day one" | INFLATED (not surfaced) | Removed. Returns when Brier/CLV are actually surfaced. Backend Brier compute untouched. | | FAQ + Features "Brier/CLV published from day one" | INFLATED (not surfaced) | Removed. Returns when Brier/CLV are actually surfaced. Backend Brier compute untouched. |
| Calibration / edge board (row 24) | HONEST: NO — MobileEdgeBoard placeholder edge% (masked >40%) | **Removed** from the Slate; phones show the real game cards. Component kept as dead code (hidden, not deleted) until a real edge feed exists. | | Calibration / edge board (row 24) | HONEST: NO — MobileEdgeBoard placeholder edge% (masked >40%) | **Removed** from the Slate; phones show the real game cards. Component kept as dead code (hidden, not deleted) until a real edge feed exists. |
| Price triplet (row 19) | HONEST: PARTIAL — null model/EV rendered "MODEL READ WITHHELD · poisoned" (false quarantine) | New **NO_MODEL** honest-absent state: MODEL "—" / "NOT PRICED", no verdict. Fixes grade card + LiveHeroProp. EV layer still doesn't *produce* values (separate build). | | Price triplet (row 19) | HONEST: PARTIAL — null model/EV rendered "MODEL READ WITHHELD · poisoned" (false quarantine) | New **NO_MODEL** honest-absent state: MODEL "—" / "NOT PRICED", no verdict. Fixes grade card + LiveHeroProp. EV layer still doesn't *produce* values (separate build). |
+3 -1
View File
@@ -10,7 +10,9 @@
> **HONESTY PASS shipped 2026-07-27 (6bc18d8, deployed):** every KNOWN live > **HONESTY PASS shipped 2026-07-27 (6bc18d8, deployed):** every KNOWN live
> fabrication removed/hidden — /compare (was hardcoded Jokić/Wembanyama grades) > fabrication removed/hidden — /compare (was hardcoded Jokić/Wembanyama grades)
> → honest in-dev + pulled from nav; founder pricing corrected (Desk $44.99, > → honest in-dev + pulled from nav; founder pricing corrected (Desk $44.99,
> Analyst $14.99, struck/unwired numbers removed); FAQ NexaPay→Stripe; FAQ+Features > Analyst $14.99, struck/unwired numbers removed); FAQ NexaPay→Stripe (NexaPay
> code fully PURGED 2026-07-27 — VYNDR is Stripe-only; env keys + orphaned
> `user_profiles.nexapay_customer_id` column flagged for Kev); FAQ+Features
> "Brier/CLV published" claim removed; MobileEdgeBoard placeholder-edge% removed; > "Brier/CLV published" claim removed; MobileEdgeBoard placeholder-edge% removed;
> price-triplet null model/EV → honest NO_MODEL (no false "poisoned" copy). No > price-triplet null model/EV → honest NO_MODEL (no false "poisoned" copy). No
> feature cut (media/newsletter/WIRE kept). Honest state = "no KNOWN live > feature cut (media/newsletter/WIRE kept). Honest state = "no KNOWN live
+2 -2
View File
@@ -9,8 +9,8 @@ Legend: **✅ wire-able now** (data already in the system) · **🔧 sourceable
### 1a. Billing "RENEWS 6/9/2036" — ✅ fixable ### 1a. Billing "RENEWS 6/9/2036" — ✅ fixable
- **Render:** `web/src/app/profile/page.tsx:128` renders `subscription_end` verbatim from the Supabase `user_profiles` row. Honest render, bad data — no hardcoded 2036 in source. - **Render:** `web/src/app/profile/page.tsx:128` renders `subscription_end` verbatim from the Supabase `user_profiles` row. Honest render, bad data — no hardcoded 2036 in source.
- **Root cause:** the live web payment path is **NexaPay, not Stripe**. `web/src/app/api/webhook/nexapay/route.ts:41-50` writes `subscription_end = now + 30 days` (a synthetic guess). The 2036 value is a **manually-seeded/comped founder-account row** the UI trusts blindly. The *real* Stripe `current_period_end` exists at `GET /api/stripe/status` (`stripeService.js:278`) but the UI never consumes it. - **Root cause (UPDATED 2026-07-27 — NexaPay purged):** VYNDR is Stripe-only. The old note pointed at a `web/src/app/api/webhook/nexapay/route.ts` that wrote `subscription_end = now + 30 days` — that webhook was cross-project contamination and has been DELETED. The 2036 value is a **manually-seeded/comped founder-account row** the UI trusts blindly. The *real* Stripe `current_period_end` exists at `GET /api/stripe/status` (`stripeService.js:278`) but the UI never consumes it.
- **Task:** point the "Renews" stat at a provider-asserted value (consume `/api/stripe/status.current_period_end`, or have the NexaPay webhook persist its real next-bill timestamp instead of now+30); correct/clear the stale 2036 row. Guard: never render a `subscription_end` the provider didn't assert → fall back to `—`. - **Task:** point the "Renews" stat at the Stripe provider-asserted value (consume `/api/stripe/status.current_period_end`); correct/clear the stale 2036 row. Guard: never render a `subscription_end` the provider didn't assert → fall back to `—`.
### 1b. James Wood → "Chicago Cubs" / builds vs AL East — ✅ fixable (nameKey collision) ### 1b. James Wood → "Chicago Cubs" / builds vs AL East — ✅ fixable (nameKey collision)
- **Root cause:** `mlbStatsAdapter.js:192` `people.find(p => nameKey(p.fullName) === targetKey)` returns the **first** exact-nameKey match with **no namesake disambiguation**. A second "James Wood" in the statsapi season list (a Cubs-affiliate namesake) wins → wrong `currentTeam`. The S59 substring guard only covered *fuzzy* mismatches; it never covered two players sharing an exact name — **not a regression**, an uncovered case. - **Root cause:** `mlbStatsAdapter.js:192` `people.find(p => nameKey(p.fullName) === targetKey)` returns the **first** exact-nameKey match with **no namesake disambiguation**. A second "James Wood" in the statsapi season list (a Cubs-affiliate namesake) wins → wrong `currentTeam`. The S59 substring guard only covered *fuzzy* mismatches; it never covered two players sharing an exact name — **not a regression**, an uncovered case.
+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. * Checkout proxy Next.js Express Stripe.
* *
* Session 8 cutover: previously this route created NexaPay payment * This route forwards to the Express `/api/stripe/checkout` route
* links; now it forwards to the Express `/api/stripe/checkout` route
* (Session 3.4 + 7i) which creates a Stripe Checkout Session * (Session 3.4 + 7i) which creates a Stripe Checkout Session
* server-side. The browser never sees `sk_test_*` / `sk_live_*` * server-side. The browser never sees `sk_test_*` / `sk_live_*`
* only the resulting `https://checkout.stripe.com/...` redirect URL. * 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: * Three flows for launch:
* - sendWelcomeEmail() on signup * - sendWelcomeEmail() on signup
* - sendPaymentReceipt() on successful NexaPay webhook * - sendPaymentReceipt() on a successful payment webhook
* - sendRenewalReminder() daily cron when subscription_end < 3 days out * - sendRenewalReminder() daily cron when subscription_end < 3 days out
* *
* All functions return { ok: boolean, id?: string, error?: string } and * 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' },
};