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:
+8
-10
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
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.
|
* 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.
|
||||||
|
|||||||
@@ -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:
|
* 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
|
||||||
|
|||||||
@@ -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