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:
@@ -67,19 +67,19 @@ function getPriceId(tier, founderCode) {
|
|||||||
|
|
||||||
async function createCheckoutSession(userId, email, tier, founderCode) {
|
async function createCheckoutSession(userId, email, tier, founderCode) {
|
||||||
const supabase = getSupabaseServiceClient();
|
const supabase = getSupabaseServiceClient();
|
||||||
const priceId = getPriceId(tier, founderCode);
|
// Item B — the price is SEAT-GATED, not code-gated: while founder seats
|
||||||
// Session 14 — tier is valid but the upstream Stripe product
|
// remain (< FOUNDER_SEATS_TOTAL, same truth as the meter) checkout attaches
|
||||||
// hasn't been provisioned (most common case: africa before
|
// the founder price automatically; at seat 100 it flips to standard. The old
|
||||||
// STRIPE_PRICE_AFRICA is configured in Coolify). Surface a clean
|
// founderCode param is accepted for back-compat but no longer drives price.
|
||||||
// 503 with `code: 'tier_unconfigured'` instead of letting null
|
const { priceId, isFounder } = await resolveCheckoutPrice(tier);
|
||||||
// propagate to Stripe.
|
// 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) {
|
if (priceId === PRICE_UNCONFIGURED) {
|
||||||
const err = new Error(`Pricing for "${tier}" is not configured yet.`);
|
const err = new Error(`Pricing for "${tier}" is not configured yet.`);
|
||||||
err.code = 'tier_unconfigured';
|
err.code = 'tier_unconfigured';
|
||||||
err.statusCode = 503;
|
err.statusCode = 503;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const isFounder = isFounderCodeValid(founderCode);
|
|
||||||
|
|
||||||
// Get or create Stripe customer
|
// Get or create Stripe customer
|
||||||
const { data: user } = await supabase
|
const { data: user } = await supabase
|
||||||
@@ -123,6 +123,13 @@ async function createCheckoutSession(userId, email, tier, founderCode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GRACE_PERIOD_MS = 48 * 60 * 60 * 1000;
|
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
|
// Mirror writes to `user_profiles` so Next.js-side reads stay in sync. The
|
||||||
// Express side traditionally writes to `users`; user_profiles is the
|
// Express side traditionally writes to `users`; user_profiles is the
|
||||||
@@ -229,7 +236,8 @@ async function handleWebhookEvent(event) {
|
|||||||
case 'invoice.payment_failed': {
|
case 'invoice.payment_failed': {
|
||||||
const invoice = event.data.object;
|
const invoice = event.data.object;
|
||||||
const customerId = invoice.customer;
|
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
|
const { data: user } = await supabase
|
||||||
.from('users')
|
.from('users')
|
||||||
@@ -297,15 +305,61 @@ async function countFounderSeats() {
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
for (const price of founderPrices) {
|
for (const price of founderPrices) {
|
||||||
// Only 'active' (paid + current) subscriptions are a claimed paid seat —
|
// Only 'active' (paid + current) subscriptions are a claimed paid seat —
|
||||||
// trialing / past_due / canceled are not. Auto-pages the full list.
|
// 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
|
// eslint-disable-next-line no-await-in-loop
|
||||||
for await (const _sub of stripe.subscriptions.list({ price, status: 'active', limit: 100 })) {
|
const res = await stripe.subscriptions.list({
|
||||||
count += 1;
|
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;
|
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 = {
|
module.exports = {
|
||||||
createCheckoutSession,
|
createCheckoutSession,
|
||||||
handleWebhookEvent,
|
handleWebhookEvent,
|
||||||
@@ -315,6 +369,10 @@ module.exports = {
|
|||||||
isFounderCodeValid,
|
isFounderCodeValid,
|
||||||
getPriceId,
|
getPriceId,
|
||||||
countFounderSeats,
|
countFounderSeats,
|
||||||
|
founderSeatsAvailable,
|
||||||
|
resolveCheckoutPrice,
|
||||||
|
FOUNDER_SEATS_TOTAL,
|
||||||
|
PAYMENT_RETRY_GRACE_MS,
|
||||||
// Session 14 — exposed so the route layer + tests can recognize
|
// Session 14 — exposed so the route layer + tests can recognize
|
||||||
// the "tier valid but Stripe price not provisioned" state.
|
// the "tier valid but Stripe price not provisioned" state.
|
||||||
PRICE_UNCONFIGURED,
|
PRICE_UNCONFIGURED,
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ jest.mock('../../src/utils/supabase', () => ({
|
|||||||
getSupabaseServiceClient: () => mockSupabaseClient.current,
|
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('stripeService', () => {
|
||||||
describe('isFounderCodeValid', () => {
|
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', () => {
|
describe('getPriceId', () => {
|
||||||
test('analyst without founder code returns standard price', () => {
|
test('analyst without founder code returns standard price', () => {
|
||||||
const id = getPriceId('analyst', null);
|
const id = getPriceId('analyst', null);
|
||||||
@@ -150,7 +200,7 @@ describe('stripeService', () => {
|
|||||||
expect(profilesUpdate.patch.founder_pricing).toBe(true);
|
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();
|
const fake = makeFake();
|
||||||
mockSupabaseClient.current = fake;
|
mockSupabaseClient.current = fake;
|
||||||
const before = Date.now();
|
const before = Date.now();
|
||||||
@@ -161,8 +211,8 @@ describe('stripeService', () => {
|
|||||||
const usersUpdate = fake.updates.find((u) => u.table === 'users');
|
const usersUpdate = fake.updates.find((u) => u.table === 'users');
|
||||||
const profilesUpdate = fake.updates.find((u) => u.table === 'user_profiles');
|
const profilesUpdate = fake.updates.find((u) => u.table === 'user_profiles');
|
||||||
const graceTs = new Date(usersUpdate.patch.grace_period_until).getTime();
|
const graceTs = new Date(usersUpdate.patch.grace_period_until).getTime();
|
||||||
const expected = before + 48 * 60 * 60 * 1000;
|
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 48h
|
expect(Math.abs(graceTs - expected)).toBeLessThan(60_000); // within a minute of 14d
|
||||||
expect(profilesUpdate.patch.subscription_status).toBe('grace_period');
|
expect(profilesUpdate.patch.subscription_status).toBe('grace_period');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,13 +37,16 @@ export default function ClaimMeter() {
|
|||||||
const total = data.total || 100;
|
const total = data.total || 100;
|
||||||
const claimed = Math.max(0, Math.min(total, data.claimed));
|
const claimed = Math.max(0, Math.min(total, data.claimed));
|
||||||
const pct = Math.min(100, Math.round((claimed / total) * 100));
|
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 (
|
return (
|
||||||
<div style={{ maxWidth: 420, margin: '0 auto', width: '100%' }}>
|
<div style={{ maxWidth: 420, margin: '0 auto', width: '100%' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||||
<SectionHead accent="var(--amber)">FOUNDER SEATS</SectionHead>
|
<SectionHead accent="var(--amber)">FOUNDER SEATS</SectionHead>
|
||||||
<span className="mono amber-glow" style={{ fontSize: 13, fontWeight: 700, color: 'var(--amber)' }}>
|
<span className="mono amber-glow" style={{ fontSize: 13, fontWeight: 700, color: 'var(--amber)' }}>
|
||||||
{claimed} / {total} CLAIMED
|
{soldOut ? 'SOLD OUT' : `${claimed} / ${total} CLAIMED`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 6, background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
<div style={{ height: 6, background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
@@ -59,7 +62,9 @@ export default function ClaimMeter() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 8, letterSpacing: '0.02em' }}>
|
<div className="mono" style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 8, letterSpacing: '0.02em' }}>
|
||||||
Founder pricing locks for life. When the seats are gone, the rate is gone.
|
{soldOut
|
||||||
|
? 'Founder seats are gone — standard pricing now applies.'
|
||||||
|
: 'Founder pricing locks for life. When the seats are gone, the rate is gone.'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user