Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -14,7 +14,9 @@ const PRICE_MAP = {
|
||||
desk_founder: process.env.STRIPE_PRICE_DESK_FOUNDER || 'price_desk_founder',
|
||||
};
|
||||
|
||||
const VALID_FOUNDER_CODES = (process.env.FOUNDER_CODES || 'FOUNDER2026,BETONBLK,EARLYBIRD').split(',');
|
||||
// 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) {
|
||||
@@ -68,6 +70,21 @@ async function createCheckoutSession(userId, email, tier, founderCode) {
|
||||
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();
|
||||
|
||||
@@ -85,14 +102,49 @@ async function handleWebhookEvent(event) {
|
||||
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': {
|
||||
// Handle plan changes if needed
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -100,7 +152,9 @@ async function handleWebhookEvent(event) {
|
||||
const subscription = event.data.object;
|
||||
const customerId = subscription.customer;
|
||||
|
||||
// Find user by stripe_customer_id and revert to free
|
||||
// 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')
|
||||
@@ -110,14 +164,37 @@ async function handleWebhookEvent(event) {
|
||||
if (user) {
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({ tier: 'free' })
|
||||
.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': {
|
||||
console.warn('[BetonBLK] Payment failed for customer:', event.data.object.customer);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user