diff --git a/src/services/stripeService.js b/src/services/stripeService.js index d1abbd5..80f3f34 100644 --- a/src/services/stripeService.js +++ b/src/services/stripeService.js @@ -67,19 +67,19 @@ function getPriceId(tier, founderCode) { 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. + // 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); + // 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; } - const isFounder = isFounderCodeValid(founderCode); // Get or create Stripe customer const { data: user } = await supabase @@ -123,6 +123,13 @@ async function createCheckoutSession(userId, email, tier, founderCode) { } 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 @@ -229,7 +236,8 @@ async function handleWebhookEvent(event) { case 'invoice.payment_failed': { const invoice = event.data.object; const customerId = invoice.customer; - const graceUntil = new Date(Date.now() + GRACE_PERIOD_MS).toISOString(); + // 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') @@ -297,15 +305,61 @@ async function countFounderSeats() { let count = 0; for (const price of founderPrices) { // Only 'active' (paid + current) subscriptions are a claimed paid seat — - // trialing / past_due / canceled are not. Auto-pages the full list. - // eslint-disable-next-line no-await-in-loop - for await (const _sub of stripe.subscriptions.list({ price, status: 'active', limit: 100 })) { - count += 1; + // 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 = {}) { + const founder = await founderSeatsAvailable(deps); + const t = String(tier || '').toLowerCase(); + if (t === 'analyst') { + if (founder && PRICE_MAP.analyst_founder) return { priceId: PRICE_MAP.analyst_founder, isFounder: true }; + return { priceId: PRICE_MAP.analyst || PRICE_UNCONFIGURED, isFounder: false }; + } + if (t === 'desk') { + if (founder && PRICE_MAP.desk_founder) return { priceId: PRICE_MAP.desk_founder, isFounder: true }; + return { priceId: PRICE_MAP.desk || PRICE_UNCONFIGURED, isFounder: false }; + } + if (t === 'africa') return { priceId: PRICE_MAP.africa || PRICE_UNCONFIGURED, isFounder: false }; + return { priceId: PRICE_UNCONFIGURED, isFounder: false }; +} + module.exports = { createCheckoutSession, handleWebhookEvent, @@ -315,6 +369,10 @@ module.exports = { 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, diff --git a/tests/unit/stripeService.test.js b/tests/unit/stripeService.test.js index 248c082..8288aa4 100644 --- a/tests/unit/stripeService.test.js +++ b/tests/unit/stripeService.test.js @@ -16,7 +16,10 @@ jest.mock('../../src/utils/supabase', () => ({ getSupabaseServiceClient: () => mockSupabaseClient.current, })); -const { isFounderCodeValid, getPriceId, handleWebhookEvent } = require('../../src/services/stripeService'); +const { + isFounderCodeValid, getPriceId, handleWebhookEvent, + resolveCheckoutPrice, founderSeatsAvailable, FOUNDER_SEATS_TOTAL, PAYMENT_RETRY_GRACE_MS, +} = require('../../src/services/stripeService'); describe('stripeService', () => { describe('isFounderCodeValid', () => { @@ -40,6 +43,53 @@ describe('stripeService', () => { }); }); + // Item B — checkout price is SEAT-GATED (founder while < FOUNDER_SEATS_TOTAL, + // standard when sold out), reading the same countFounderSeats() truth as the + // meter. Inject the seat count so we test both ends without hitting Stripe. + describe('resolveCheckoutPrice — seat-gated founder pricing', () => { + const seats = (n) => ({ countFounderSeats: async () => n }); + + test('seat count 0 → founder price (the advertised $34.99 desk)', async () => { + const a = await resolveCheckoutPrice('analyst', seats(0)); + expect(a.priceId).toBe('price_test_analyst_founder'); + expect(a.isFounder).toBe(true); + const d = await resolveCheckoutPrice('desk', seats(0)); + expect(d.priceId).toBe('price_test_desk_founder'); + expect(d.isFounder).toBe(true); + }); + + test('seat count 100 (SOLD OUT) → standard price, isFounder false', async () => { + const a = await resolveCheckoutPrice('analyst', seats(FOUNDER_SEATS_TOTAL)); + expect(a.priceId).toBe('price_test_analyst_monthly'); + expect(a.isFounder).toBe(false); + const d = await resolveCheckoutPrice('desk', seats(FOUNDER_SEATS_TOTAL)); + expect(d.priceId).toBe('price_test_desk_monthly'); + expect(d.isFounder).toBe(false); + }); + + test('seat 99 is still founder, seat 100 flips (the boundary)', async () => { + expect((await resolveCheckoutPrice('desk', seats(99))).isFounder).toBe(true); + expect((await resolveCheckoutPrice('desk', seats(100))).isFounder).toBe(false); + }); + + test('count unverifiable (null) → honor the advertised founder price, never overcharge', async () => { + const d = await resolveCheckoutPrice('desk', { countFounderSeats: async () => null }); + expect(d.priceId).toBe('price_test_desk_founder'); + expect(d.isFounder).toBe(true); + }); + + test('the gate reads countFounderSeats truth (same source as the meter)', async () => { + expect(await founderSeatsAvailable(seats(0))).toBe(true); + expect(await founderSeatsAvailable(seats(FOUNDER_SEATS_TOTAL))).toBe(false); + }); + }); + + describe('grace period — a decline spans Stripe retries (item B)', () => { + test('payment_failed grace is 14 days (Stripe Smart Retry window), not 48h', () => { + expect(PAYMENT_RETRY_GRACE_MS).toBe(14 * 24 * 60 * 60 * 1000); + }); + }); + describe('getPriceId', () => { test('analyst without founder code returns standard price', () => { const id = getPriceId('analyst', null); @@ -150,7 +200,7 @@ describe('stripeService', () => { expect(profilesUpdate.patch.founder_pricing).toBe(true); }); - test('invoice.payment_failed sets a ~48h grace window', async () => { + test('invoice.payment_failed sets a 14-DAY grace (spans Stripe retries, item B)', async () => { const fake = makeFake(); mockSupabaseClient.current = fake; const before = Date.now(); @@ -161,8 +211,8 @@ describe('stripeService', () => { const usersUpdate = fake.updates.find((u) => u.table === 'users'); const profilesUpdate = fake.updates.find((u) => u.table === 'user_profiles'); const graceTs = new Date(usersUpdate.patch.grace_period_until).getTime(); - const expected = before + 48 * 60 * 60 * 1000; - expect(Math.abs(graceTs - expected)).toBeLessThan(60_000); // within a minute of 48h + const expected = before + 14 * 24 * 60 * 60 * 1000; // a decline is not a cancel + expect(Math.abs(graceTs - expected)).toBeLessThan(60_000); // within a minute of 14d expect(profilesUpdate.patch.subscription_status).toBe('grace_period'); }); diff --git a/web/src/components/vyndr/ClaimMeter.tsx b/web/src/components/vyndr/ClaimMeter.tsx index 7deae8a..58958a6 100644 --- a/web/src/components/vyndr/ClaimMeter.tsx +++ b/web/src/components/vyndr/ClaimMeter.tsx @@ -37,13 +37,16 @@ export default function ClaimMeter() { const total = data.total || 100; const claimed = Math.max(0, Math.min(total, data.claimed)); const pct = Math.min(100, Math.round((claimed / total) * 100)); + // Item B — at capacity the meter flips to sold-out (checkout has already + // flipped to standard pricing via the same countFounderSeats truth). + const soldOut = claimed >= total; return (