Files
vyndr/src/services/stripeService.js
T

295 lines
10 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__';
// 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(',');
const FOUNDER_EXPIRY = new Date(process.env.FOUNDER_CODE_EXPIRY || '2026-06-30');
function isFounderCodeValid(code) {
if (!code) return false;
if (new Date() > FOUNDER_EXPIRY) return false;
return VALID_FOUNDER_CODES.includes(code.toUpperCase());
}
function getPriceId(tier, founderCode) {
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}`);
}
async function createCheckoutSession(userId, email, tier, founderCode) {
const supabase = getSupabaseServiceClient();
const priceId = getPriceId(tier, founderCode);
// Session 14 — tier is valid but the upstream Stripe product
// hasn't been provisioned (most common case: africa before
// STRIPE_PRICE_AFRICA is configured in Coolify). Surface a clean
// 503 with `code: 'tier_unconfigured'` instead of letting null
// propagate to Stripe.
if (priceId === PRICE_UNCONFIGURED) {
const err = new Error(`Pricing for "${tier}" is not configured yet.`);
err.code = 'tier_unconfigured';
err.statusCode = 503;
throw err;
}
const isFounder = isFounderCodeValid(founderCode);
// 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`,
metadata: { user_id: userId, tier, is_founder: String(isFounder) },
});
return { checkout_url: session.url, session_id: session.id };
}
const GRACE_PERIOD_MS = 48 * 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;
const isFounder = session.metadata?.is_founder === 'true';
if (userId && tier) {
await supabase
.from('users')
.update({
tier,
stripe_customer_id: session.customer,
founder_status: isFounder,
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,
founder_pricing: isFounder,
});
}
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;
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,
});
}
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);
}
module.exports = {
createCheckoutSession,
handleWebhookEvent,
createPortalSession,
getSubscriptionStatus,
constructWebhookEvent,
isFounderCodeValid,
getPriceId,
// Session 14 — exposed so the route layer + tests can recognize
// the "tier valid but Stripe price not provisioned" state.
PRICE_UNCONFIGURED,
};