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:
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user