const Stripe = require('stripe'); const { getSupabaseServiceClient } = require('../utils/supabase'); let _stripe = null; function getStripe() { if (!_stripe) _stripe = new Stripe(process.env.STRIPE_SECRET_KEY); return _stripe; } // Session 15 — fallback strings like 'price_analyst_monthly' would // 400 from Stripe in production (they're not real `price_xxx` IDs). // All maps now fall back to null; getPriceId then returns the // PRICE_UNCONFIGURED sentinel for unset values. Founder prices // additionally fall back to the standard tier price so a user with // a valid founder code on a deploy that doesn't yet have founder // prices wired still gets a successful checkout at standard rate. const PRICE_MAP = { analyst: process.env.STRIPE_PRICE_ANALYST || null, analyst_founder: process.env.STRIPE_PRICE_ANALYST_FOUNDER || null, desk: process.env.STRIPE_PRICE_DESK || null, desk_founder: process.env.STRIPE_PRICE_DESK_FOUNDER || null, africa: process.env.STRIPE_PRICE_AFRICA || null, }; // Sentinel marker — getPriceId returns this when the tier is valid // but the Stripe price hasn't been provisioned yet. The route layer // checks for it and returns 503 with a friendly message rather than // passing "null" to Stripe. const PRICE_UNCONFIGURED = '__unconfigured__'; const { idFor } = require('../config/stripePrices'); // VYNDR is the canonical brand promo. BETONBLK stays in the default list so // codes distributed before the rebrand keep redeeming during the transition. const VALID_FOUNDER_CODES = (process.env.FOUNDER_CODES || 'FOUNDER2026,VYNDR,BETONBLK,EARLYBIRD').split(','); // Session 55 — founder pricing is still an active launch lever (the ClaimMeter // scarcity meter + the $14.99/$44.99 founder tiers advertise it), so the default // window runs through 2026. The 2026-06-30 default had silently lapsed (current // date 2026-07-10), disabling every founder code. Operators override via env. const FOUNDER_EXPIRY = new Date(process.env.FOUNDER_CODE_EXPIRY || '2026-12-31'); function isFounderCodeValid(code) { if (!code) return false; if (new Date() > FOUNDER_EXPIRY) return false; return VALID_FOUNDER_CODES.includes(code.toUpperCase()); } /** * @deprecated B2 (2026-07-31) — RETIRED as a founder path. founder_slots is the * only founder gate; price comes from claim_founder_slot via resolveCheckoutPrice. * Kept only so any stale import fails loudly rather than silently granting a * founder rate off a promo code. */ function getPriceId(tier, founderCode) { if (founderCode) { throw new Error('[stripe] getPriceId is retired as a founder path — use resolveCheckoutPrice (claim_founder_slot).'); } const isFounder = isFounderCodeValid(founderCode); if (tier === 'analyst') { // Session 15 — if a valid founder code is presented but the // founder price ID isn't wired (env unset), gracefully fall // back to the standard analyst price rather than 503'ing the // user. The founder discount is operator-controlled; the // checkout itself shouldn't break. if (isFounder && PRICE_MAP.analyst_founder) return PRICE_MAP.analyst_founder; return PRICE_MAP.analyst || PRICE_UNCONFIGURED; } if (tier === 'desk') { if (isFounder && PRICE_MAP.desk_founder) return PRICE_MAP.desk_founder; return PRICE_MAP.desk || PRICE_UNCONFIGURED; } if (tier === 'africa') { // Africa tier has no founder discount — it IS the discount. return PRICE_MAP.africa || PRICE_UNCONFIGURED; } throw new Error(`Invalid tier: ${tier}`); } // B2 — `founderCode` is accepted for signature back-compat ONLY. It no longer // influences price or metadata: founder_slots is the sole founder gate. async function createCheckoutSession(userId, email, tier, founderCode) { // eslint-disable-line no-unused-vars const supabase = getSupabaseServiceClient(); // Item B — the price is SEAT-GATED, not code-gated: while founder seats // remain (< FOUNDER_SEATS_TOTAL, same truth as the meter) checkout attaches // the founder price automatically; at seat 100 it flips to standard. The old // founderCode param is accepted for back-compat but no longer drives price. const { priceId, isFounder } = await resolveCheckoutPrice(tier, { userId }); // Session 14 — tier is valid but the upstream Stripe product hasn't been // provisioned. Surface a clean 503 with `code: 'tier_unconfigured'`. if (priceId === PRICE_UNCONFIGURED) { const err = new Error(`Pricing for "${tier}" is not configured yet.`); err.code = 'tier_unconfigured'; err.statusCode = 503; throw err; } // Get or create Stripe customer const { data: user } = await supabase .from('users') .select('stripe_customer_id') .eq('id', userId) .single(); let customerId = user?.stripe_customer_id; if (!customerId) { const customer = await getStripe().customers.create({ email, metadata: { user_id: userId }, }); customerId = customer.id; await supabase .from('users') .update({ stripe_customer_id: customerId }) .eq('id', userId); } // Stripe sends the user to a FRONTEND URL after checkout — not the // Express API. NEXT_PUBLIC_SITE_URL is the canonical frontend origin // (defaults to https://vyndr.app per the email templates), with // BASE_URL as a fallback for legacy deploys that only set the API // origin. localhost:3000 is the Next dev server default; Express // dev runs on 3001 so we never want to send users there. const frontendUrl = process.env.NEXT_PUBLIC_SITE_URL || process.env.BASE_URL || 'http://localhost:3000'; const session = await getStripe().checkout.sessions.create({ customer: customerId, line_items: [{ price: priceId, quantity: 1 }], mode: 'subscription', success_url: `${frontendUrl}/upgrade/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${frontendUrl}/upgrade/cancel`, // is_founder is recorded for AUDIT only. The webhook does NOT read it — // caller-supplied metadata must never decide who pays the founder rate. metadata: { user_id: userId, tier, is_founder_audit: String(isFounder) }, }); return { checkout_url: session.url, session_id: session.id }; } const GRACE_PERIOD_MS = 48 * 60 * 60 * 1000; // Item B — a payment DECLINE is not a cancellation: Stripe Smart Retries run // for ~2 weeks (8 retries) before giving up. The grace on a failed invoice must // span that window so a transient decline doesn't revoke access mid-retry — // access is revoked only when Stripe actually cancels (customer.subscription // .deleted, which keeps the 48h grace). If a retry succeeds first, the // subscription.updated -> active handler clears the grace. const PAYMENT_RETRY_GRACE_MS = 14 * 24 * 60 * 60 * 1000; // Mirror writes to `user_profiles` so Next.js-side reads stay in sync. The // Express side traditionally writes to `users`; user_profiles is the // Next.js-facing copy. Failures here are logged but don't fail the webhook — // dropping a mirror write is recoverable, dropping the webhook isn't. async function mirrorToUserProfile(supabase, userId, patch) { if (!userId) return; try { await supabase.from('user_profiles').update(patch).eq('id', userId); } catch (err) { console.warn('[VYNDR] user_profiles mirror failed:', err.message); } } async function handleWebhookEvent(event) { const supabase = getSupabaseServiceClient(); switch (event.type) { case 'checkout.session.completed': { const session = event.data.object; const userId = session.metadata?.user_id; const tier = session.metadata?.tier; if (userId && tier) { // B3 — finalize through the SINGLE-WRITER function. It flips // user_profiles.founder_pricing (canonical) AND mirrors // users.founder_status in the SAME transaction, so the two can never // drift again (they already had: 1 vs 0 in prod). Founder truth comes // from the CLAIM, not from checkout metadata — metadata is caller- // supplied and must never decide who pays the founder rate. const { data: wasFounder, error: finErr } = await supabase.rpc('finalize_founder_slot', { p_user_id: userId, p_stripe_subscription_id: session.subscription || null, p_stripe_customer_id: session.customer || null, p_tier: tier, }); if (finErr) { console.error('[stripe] finalize_founder_slot failed:', finErr.message); // Never leave a PAID customer unentitled: set the tier even if the // slot bookkeeping failed. founder_pricing stays whatever the claim // made it — we do not guess a founder flag here. await supabase.from('users').update({ tier, stripe_customer_id: session.customer, grace_period_until: null, mfa_setup_prompted: false }).eq('id', userId); await mirrorToUserProfile(supabase, userId, { tier, subscription_status: 'active', grace_period_until: null, mfa_setup_prompted: false }); } else { // VERIFY-AFTER-WRITE: re-read the end state rather than trusting the call. const { data: chk } = await supabase.from('user_profiles') .select('tier, subscription_status, founder_pricing, stripe_subscription_id') .eq('id', userId).single(); console.log('[stripe] finalized', userId, 'founder=', wasFounder, 'verified=', JSON.stringify(chk)); await supabase.from('users').update({ grace_period_until: null, mfa_setup_prompted: false }).eq('id', userId); } } break; } case 'customer.subscription.updated': { // Reflect plan changes (upgrade/downgrade) coming from the customer // portal. Stripe encodes the new price on items.data[0].price.id; we // map that back to a tier via PRICE_MAP. const subscription = event.data.object; const priceId = subscription.items?.data?.[0]?.price?.id; const status = subscription.status; const customerId = subscription.customer; let nextTier = null; if (priceId === PRICE_MAP.analyst || priceId === PRICE_MAP.analyst_founder) nextTier = 'analyst'; else if (priceId === PRICE_MAP.desk || priceId === PRICE_MAP.desk_founder) nextTier = 'desk'; const { data: user } = await supabase .from('users') .select('id') .eq('stripe_customer_id', customerId) .single(); if (user && nextTier && status === 'active') { await supabase .from('users') .update({ tier: nextTier, grace_period_until: null }) .eq('id', user.id); await mirrorToUserProfile(supabase, user.id, { tier: nextTier, subscription_status: 'active', grace_period_until: null, }); } break; } case 'customer.subscription.deleted': { const subscription = event.data.object; const customerId = subscription.customer; // 48hr grace before revoking access. The user keeps paid features // during the window so a cancellation mid-Read doesn't yank the rug. const graceUntil = new Date(Date.now() + GRACE_PERIOD_MS).toISOString(); const { data: user } = await supabase .from('users') .select('id') .eq('stripe_customer_id', customerId) .single(); if (user) { await supabase .from('users') .update({ grace_period_until: graceUntil }) .eq('id', user.id); await mirrorToUserProfile(supabase, user.id, { subscription_status: 'grace_period', grace_period_until: graceUntil, }); } break; } case 'invoice.payment_failed': { const invoice = event.data.object; const customerId = invoice.customer; // 14-day grace (spans Stripe's retry window) — a decline is not a cancel. const graceUntil = new Date(Date.now() + PAYMENT_RETRY_GRACE_MS).toISOString(); const { data: user } = await supabase .from('users') .select('id') .eq('stripe_customer_id', customerId) .single(); if (user) { await supabase .from('users') .update({ grace_period_until: graceUntil }) .eq('id', user.id); await mirrorToUserProfile(supabase, user.id, { subscription_status: 'grace_period', grace_period_until: graceUntil, }); } console.warn('[VYNDR] Payment failed for customer:', customerId, '— grace until', graceUntil); break; } } } async function createPortalSession(stripeCustomerId) { const baseUrl = process.env.BASE_URL || 'http://localhost:3001'; const session = await getStripe().billingPortal.sessions.create({ customer: stripeCustomerId, return_url: `${baseUrl}/tracker`, }); return { portal_url: session.url }; } async function getSubscriptionStatus(stripeCustomerId) { const subscriptions = await getStripe().subscriptions.list({ customer: stripeCustomerId, status: 'active', limit: 1, }); if (subscriptions.data.length === 0) { return { subscription_status: 'none', current_period_end: null, cancel_at_period_end: false }; } const sub = subscriptions.data[0]; return { subscription_status: sub.status, current_period_end: new Date(sub.current_period_end * 1000).toISOString(), cancel_at_period_end: sub.cancel_at_period_end, }; } function constructWebhookEvent(body, signature) { return getStripe().webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET); } // Security follow-up item 0 — the REAL founder-seat count is the number of // ACTIVE Stripe subscriptions on a founder price. It counts Stripe's OWN truth // (subscription status on the live account), NOT a tier/founder_pricing field on // a DB profile — a comped or manually-tiered account can set those without ever // paying, which is exactly the phantom "1" the ClaimMeter was showing. Returns // null when Stripe or the founder prices aren't configured → the counter hides. async function countFounderSeats() { const founderPrices = [PRICE_MAP.analyst_founder, PRICE_MAP.desk_founder].filter(Boolean); if (!process.env.STRIPE_SECRET_KEY || founderPrices.length === 0) return null; const stripe = getStripe(); let count = 0; for (const price of founderPrices) { // Only 'active' (paid + current) subscriptions are a claimed paid seat — // trialing / past_due / canceled are not. Manual pagination (works with the // real SDK's {data, has_more} AND simple test mocks); 20-page cap is a // runaway guard far above the founder seat total. let startingAfter; for (let page = 0; page < 20; page += 1) { // eslint-disable-next-line no-await-in-loop const res = await stripe.subscriptions.list({ price, status: 'active', limit: 100, ...(startingAfter ? { starting_after: startingAfter } : {}), }); const data = (res && res.data) || []; count += data.length; if (!res || !res.has_more || data.length === 0) break; startingAfter = data[data.length - 1].id; } } return count; } const FOUNDER_SEATS_TOTAL = Number(process.env.FOUNDER_SEATS_TOTAL || 100); // Founder-checkout seat gate (item B) — founder pricing is live ONLY while // founder seats remain (< FOUNDER_SEATS_TOTAL), read from the SAME truth as the // ClaimMeter (countFounderSeats). Sold out → standard prices. When the count // can't be verified we honor the ADVERTISED founder price (never charge more // than advertised) — the founder branch below only fires if a founder price ID // is actually configured, so an unconfigured founder price still falls through // to standard. Injectable for tests. async function founderSeatsAvailable(deps = {}) { const counter = deps.countFounderSeats || countFounderSeats; try { const claimed = await counter(); if (claimed == null) return true; return Number(claimed) < FOUNDER_SEATS_TOTAL; } catch { return true; } } // The price a checkout attaches for a tier RIGHT NOW, seat-gated. Returns the // price id + whether it's the founder price (for the founder_pricing webhook flag). async function resolveCheckoutPrice(tier, deps = {}) { // B2 (2026-07-31) — THE ATOMIC CLAIM REPLACES THE COUNT. // This used to call founderSeatsAvailable() — a COUNT read, which is exactly // the race: two checkouts at seat 99 both read 99 and both got founder. // claim_founder_slot takes a real slot inside one statement serialized by the // unique index, and returns a price KEY (never a Stripe id — see // config/stripePrices). No count is read anywhere in this path. const t = String(tier || '').toLowerCase(); if (t !== 'analyst' && t !== 'desk') { return { priceId: PRICE_UNCONFIGURED, isFounder: false, slotNumber: null }; } const supabase = deps.supabase || getSupabaseServiceClient(); const userId = deps.userId || null; if (!userId) return { priceId: idFor(`${t}_standing`) || PRICE_UNCONFIGURED, isFounder: false, slotNumber: null }; const { data, error } = await supabase.rpc('claim_founder_slot', { p_user_id: userId, p_tier: t }); if (error) { // A transient claim failure must NOT silently sell at standing: the founder // rate is LIFETIME, so an overcharge here is permanent. Nor may we grant // founder without a slot — that would push past the 100 cap, and those // prices are permanent too. Both silent options are irreversible, so we // fail RETRYABLY instead and let the customer try again. // (This is distinct from the cap being FULL, which is not an error: that // path returns the standing price normally, per "never error to the // customer" — a full cap is a real answer, a DB blip is not.) console.error('[stripe] claim_founder_slot failed:', error.message); const err = new Error('Could not reserve your rate just now. Please try again.'); err.code = 'claim_failed'; err.statusCode = 503; throw err; } const row = Array.isArray(data) ? data[0] : data; const priceKey = (row && row.price_key) || `${t}_standing`; return { priceId: idFor(priceKey) || PRICE_UNCONFIGURED, isFounder: !!(row && row.is_founder), slotNumber: (row && row.slot_number) || null, }; } module.exports = { createCheckoutSession, handleWebhookEvent, createPortalSession, getSubscriptionStatus, constructWebhookEvent, isFounderCodeValid, getPriceId, countFounderSeats, founderSeatsAvailable, resolveCheckoutPrice, FOUNDER_SEATS_TOTAL, PAYMENT_RETRY_GRACE_MS, // Session 14 — exposed so the route layer + tests can recognize // the "tier valid but Stripe price not provisioned" state. PRICE_UNCONFIGURED, };