Task B — founder checkout is SEAT-GATED; payment-decline grace spans retries

1+2. Checkout price was CODE-gated (founder price only with a valid founder
   code) — so "Claim a Founder Desk" would have charged the $44.99 standard
   price, not the advertised $34.99. Now it's SEAT-gated: resolveCheckoutPrice()
   attaches the founder price while founder seats remain (< FOUNDER_SEATS_TOTAL,
   read from the SAME countFounderSeats() truth as the ClaimMeter), and flips to
   standard at seat 100. createCheckoutSession uses it; the founderCode param is
   kept for back-compat but no longer drives price. The meter flips to "SOLD
   OUT" at capacity. When the count can't be verified we honor the advertised
   founder price (never overcharge).
   - Also hardened countFounderSeats to manual pagination (the for-await form
     broke on non-async-iterable list mocks).

3. Tests: resolveCheckoutPrice at seat 0 → founder, seat 100 → standard, the
   99/100 boundary, null-count → advertised founder price.

4. Grace: invoice.payment_failed now sets a 14-DAY grace (spans Stripe's Smart
   Retry window) instead of 48h — a transient decline no longer revokes access
   mid-retry. Access is revoked only when Stripe actually cancels
   (customer.subscription.deleted keeps its 48h grace). Test updated.

Stripe + founders suites green, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-18 23:04:19 -04:00
parent 39c07a03b9
commit ccb9668f0c
3 changed files with 131 additions and 18 deletions
+70 -12
View File
@@ -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,