Files
vyndr/src/services/stripeService.js
T
builtbykev bedbb8c008 Build 2 Phase B: checkout claims atomically, webhook finalizes, bypass retired
Stripe wired to the Phase-A mechanism. Live prices verified READ-ONLY; no
Stripe object was created and no payment was run.

B1 PRICE KEY -> ID + BOOT ASSERTION (src/config/stripePrices.js). claim_founder_slot
returns a price KEY; this module is the only place a key becomes a Stripe id, and
it reads env (legacy STRIPE_PRICE_ANALYST/DESK accepted as fallbacks so an existing
deploy keeps working). assertPricesConfigured() is wired into server.js and FAILS
BOOT when any of the four is unset — verified by deleting one: it throws
"BOOT FAILED - unset Stripe price env for: desk_founder". A blank price can no
longer sell at the wrong rate or 503 a customer at checkout.

B2 CHECKOUT CLAIMS BEFORE CREATING THE SESSION. resolveCheckoutPrice previously
called founderSeatsAvailable() — a COUNT read, which WAS the race (two checkouts
at seat 99 both read 99, both got founder). It now calls claim_founder_slot and
uses the returned key. The promo-code bypass is retired: founderCode no longer
influences price or metadata, and getPriceId THROWS if handed a code rather than
silently granting a founder rate. metadata.is_founder is renamed is_founder_audit
and the webhook no longer reads it — caller-supplied metadata must never decide
who pays the lifetime founder price.

TRANSIENT-FAILURE POLICY (a real design call, not a default): if the claim RPC
errors we now fail RETRYABLY (503 claim_failed) instead of silently selling at
standing. Both silent options are irreversible — standing permanently overcharges
someone who was entitled to founder, and granting founder without a slot pushes
past the 100 cap at permanent prices. A full cap is NOT an error and still returns
standing normally, per "never error to the customer": a full cap is a real answer,
a DB blip is not.

B3 WEBHOOK FINALIZES THROUGH THE SINGLE WRITER. checkout.session.completed calls
finalize_founder_slot, which flips user_profiles.founder_pricing (canonical) and
mirrors users.founder_status in the SAME txn, so they cannot drift again (they
already had, 1 vs 0). Verify-after-write re-reads the profile and logs the end
state. If finalize errors, the tier is still set so a PAID customer is never left
unentitled, but no founder flag is guessed.

B4 SIGNATURE VERIFICATION was already present (constructEvent with
STRIPE_WEBHOOK_SECRET + express.raw). The live endpoint exists and is enabled:
https://api.vyndr.app/api/stripe/webhook subscribing checkout.session.completed,
customer.subscription.created/updated/deleted, invoice.payment_succeeded/failed.

VERIFICATION: V1 boot assertion proven by simulation. V2 all four prices retrieved
live and confirmed active with correct amounts and monthly recurrence (14.99 /
24.99 / 44.99 / 59.99) — read-only, nothing created. V3 no code path grants founder
except the claim (greps clean; the legacy helper now throws). V4 the handler reads
customer/subscription/metadata.user_id and calls finalize with signature
verification in place. V5 reset to a pristine 100 free / 0 claimed baseline with
both founder flags at 0.

Secrets live only in .env (0600, gitignored, untracked). A pre-commit scan
confirmed NO tracked file contains the key material.

Floor: 320 suites / 3984 passed, 3 skipped (superseded founder-code tests), web
build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-31 18:42:44 -04:00

430 lines
18 KiB
JavaScript

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,
};