Files
vyndr/tests/unit/stripeService.test.js
T
builtbykev bedbb8c008 Build 2 Phase B: checkout claims atomically, webhook finalizes, bypass retired
Stripe wired to the Phase-A mechanism. Live prices verified READ-ONLY; no
Stripe object was created and no payment was run.

B1 PRICE KEY -> ID + BOOT ASSERTION (src/config/stripePrices.js). claim_founder_slot
returns a price KEY; this module is the only place a key becomes a Stripe id, and
it reads env (legacy STRIPE_PRICE_ANALYST/DESK accepted as fallbacks so an existing
deploy keeps working). assertPricesConfigured() is wired into server.js and FAILS
BOOT when any of the four is unset — verified by deleting one: it throws
"BOOT FAILED - unset Stripe price env for: desk_founder". A blank price can no
longer sell at the wrong rate or 503 a customer at checkout.

B2 CHECKOUT CLAIMS BEFORE CREATING THE SESSION. resolveCheckoutPrice previously
called founderSeatsAvailable() — a COUNT read, which WAS the race (two checkouts
at seat 99 both read 99, both got founder). It now calls claim_founder_slot and
uses the returned key. The promo-code bypass is retired: founderCode no longer
influences price or metadata, and getPriceId THROWS if handed a code rather than
silently granting a founder rate. metadata.is_founder is renamed is_founder_audit
and the webhook no longer reads it — caller-supplied metadata must never decide
who pays the lifetime founder price.

TRANSIENT-FAILURE POLICY (a real design call, not a default): if the claim RPC
errors we now fail RETRYABLY (503 claim_failed) instead of silently selling at
standing. Both silent options are irreversible — standing permanently overcharges
someone who was entitled to founder, and granting founder without a slot pushes
past the 100 cap at permanent prices. A full cap is NOT an error and still returns
standing normally, per "never error to the customer": a full cap is a real answer,
a DB blip is not.

B3 WEBHOOK FINALIZES THROUGH THE SINGLE WRITER. checkout.session.completed calls
finalize_founder_slot, which flips user_profiles.founder_pricing (canonical) and
mirrors users.founder_status in the SAME txn, so they cannot drift again (they
already had, 1 vs 0). Verify-after-write re-reads the profile and logs the end
state. If finalize errors, the tier is still set so a PAID customer is never left
unentitled, but no founder flag is guessed.

B4 SIGNATURE VERIFICATION was already present (constructEvent with
STRIPE_WEBHOOK_SECRET + express.raw). The live endpoint exists and is enabled:
https://api.vyndr.app/api/stripe/webhook subscribing checkout.session.completed,
customer.subscription.created/updated/deleted, invoice.payment_succeeded/failed.

VERIFICATION: V1 boot assertion proven by simulation. V2 all four prices retrieved
live and confirmed active with correct amounts and monthly recurrence (14.99 /
24.99 / 44.99 / 59.99) — read-only, nothing created. V3 no code path grants founder
except the claim (greps clean; the legacy helper now throws). V4 the handler reads
customer/subscription/metadata.user_id and calls finalize with signature
verification in place. V5 reset to a pristine 100 free / 0 claimed baseline with
both founder flags at 0.

Secrets live only in .env (0600, gitignored, untracked). A pre-commit scan
confirmed NO tracked file contains the key material.

Floor: 320 suites / 3984 passed, 3 skipped (superseded founder-code tests), web
build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-31 18:42:44 -04:00

287 lines
13 KiB
JavaScript

process.env.STRIPE_SECRET_KEY = 'sk_test_dummy';
// Session 15 — the production fallback strings ('price_analyst_monthly'
// etc.) were dropped because they'd 400 from Stripe in live mode. Tests
// that assert getPriceId returns a string containing 'analyst' /
// 'founder' must now provide the env values BEFORE requiring the
// module (PRICE_MAP is frozen at require time).
process.env.STRIPE_PRICE_ANALYST = 'price_test_analyst_monthly';
process.env.STRIPE_PRICE_ANALYST_FOUNDER = 'price_test_analyst_founder';
process.env.STRIPE_PRICE_DESK = 'price_test_desk_monthly';
process.env.STRIPE_PRICE_DESK_FOUNDER = 'price_test_desk_founder';
// Default mock for the founder-code / price-id tests (no DB interaction).
// Webhook tests below replace the implementation per-test.
const mockSupabaseClient = { current: { from: jest.fn() } };
jest.mock('../../src/utils/supabase', () => ({
getSupabaseServiceClient: () => mockSupabaseClient.current,
}));
const {
isFounderCodeValid, getPriceId, handleWebhookEvent,
resolveCheckoutPrice, founderSeatsAvailable, FOUNDER_SEATS_TOTAL, PAYMENT_RETRY_GRACE_MS,
} = require('../../src/services/stripeService');
describe('stripeService', () => {
describe('isFounderCodeValid', () => {
test('valid founder code returns true', () => {
expect(isFounderCodeValid('FOUNDER2026')).toBe(true);
expect(isFounderCodeValid('VYNDR')).toBe(true);
expect(isFounderCodeValid('BETONBLK')).toBe(true); // legacy promo, still honored
});
test('case insensitive', () => {
expect(isFounderCodeValid('founder2026')).toBe(true);
});
test('invalid code returns false', () => {
expect(isFounderCodeValid('INVALID')).toBe(false);
});
test('null/empty returns false', () => {
expect(isFounderCodeValid(null)).toBe(false);
expect(isFounderCodeValid('')).toBe(false);
});
});
// 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 — SUPERSEDED by the atomic claim (Build 2 Phase B)', () => {
// The old contract was seat-gated on a COUNT: "seat 99 is still founder, seat
// 100 flips", and "count unverifiable -> honour founder, never overcharge".
// That count WAS the race — two checkouts at 99 both read 99 and both won.
// Price now comes from claim_founder_slot (a real slot, taken atomically), so
// these assertions describe a mechanism that no longer exists.
const svc = require('../../src/services/stripeService');
it('no longer reads a seat COUNT to decide the price', () => {
const src = require('fs').readFileSync(
require('path').join(__dirname, '../../src/services/stripeService.js'), 'utf8');
const fn = src.slice(src.indexOf('async function resolveCheckoutPrice'));
const nxt = fn.indexOf('\nmodule.exports');
const body = nxt > 0 ? fn.slice(0, nxt) : fn;
// strip comments first — the function's own docstring QUOTES the retired
// call to explain the fix, and matching that is a false positive.
const code = body.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
expect(code).toMatch(/claim_founder_slot/);
expect(code).not.toMatch(/founderSeatsAvailable\(/);
expect(code).not.toMatch(/countFounderSeats\(/);
});
it('an unknown tier is unconfigured, never a guessed founder price', async () => {
const out = await svc.resolveCheckoutPrice('nonsense', { userId: 'u1' });
expect(out.isFounder).toBe(false);
expect(out.slotNumber).toBeNull();
});
it('a transient claim failure FAILS RETRYABLY — never a silent mis-price', async () => {
const supabase = {
rpc: jest.fn(async () => ({ data: true, error: null })), rpc: async () => ({ data: null, error: { message: 'boom' } }) };
await expect(svc.resolveCheckoutPrice('analyst', { userId: 'u1', supabase }))
.rejects.toMatchObject({ code: 'claim_failed', statusCode: 503 });
});
it('a granted claim yields the FOUNDER key; a full cap yields STANDING', async () => {
const grant = { rpc: async () => ({ data: [{ is_founder: true, price_key: 'analyst_founder', slot_number: 7 }], error: null }) };
const full = { rpc: async () => ({ data: [{ is_founder: false, price_key: 'analyst_standing', slot_number: null }], error: null }) };
expect((await svc.resolveCheckoutPrice('analyst', { userId: 'u1', supabase: grant })).isFounder).toBe(true);
expect((await svc.resolveCheckoutPrice('analyst', { userId: 'u1', supabase: full })).isFounder).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 — RETIRED as a founder path (B2)', () => {
const svc = require('../../src/services/stripeService');
it('THROWS if anyone passes a founder code — no silent founder grant', () => {
expect(() => svc.getPriceId('analyst', 'FOUNDER2026')).toThrow(/retired as a founder path/);
expect(() => svc.getPriceId('desk', 'VYNDR')).toThrow(/retired as a founder path/);
});
it('still resolves a plain tier price with no code', () => {
expect(() => svc.getPriceId('analyst')).not.toThrow();
});
});
describe('getPriceId', () => {
test('analyst without founder code returns standard price', () => {
const id = getPriceId('analyst', null);
expect(id).toContain('analyst');
expect(id).not.toContain('founder');
});
test.skip('SUPERSEDED B2 — a founder CODE no longer grants founder pricing; founder_slots is the only gate', () => {
const id = getPriceId('analyst', 'FOUNDER2026');
expect(id).toContain('founder');
});
test('desk without founder code returns standard price', () => {
const id = getPriceId('desk', null);
expect(id).toContain('desk');
expect(id).not.toContain('founder');
});
test.skip('SUPERSEDED B2 — desk founder code retired; the atomic claim decides the price', () => {
const id = getPriceId('desk', 'BETONBLK');
expect(id).toContain('founder');
});
test('invalid tier throws', () => {
expect(() => getPriceId('gold', null)).toThrow('Invalid tier');
});
describe('Session 14 — africa tier', () => {
const original = process.env.STRIPE_PRICE_AFRICA;
afterAll(() => {
if (original == null) delete process.env.STRIPE_PRICE_AFRICA;
else process.env.STRIPE_PRICE_AFRICA = original;
});
test('africa returns the configured price ID when set', () => {
// We can't re-import to pick up the env change after the
// module loaded its PRICE_MAP at require-time, so this test
// asserts the contract: getPriceId('africa') returns either
// a price ID OR the sentinel. The route-layer integration
// test covers the env-flip → 503 path end-to-end.
const result = getPriceId('africa', null);
expect(typeof result).toBe('string');
});
test.skip("SUPERSEDED B2 — africa parked; getPriceId retired as a founder path", () => {
const a = getPriceId('africa', null);
const b = getPriceId('africa', 'FOUNDER2026');
expect(a).toBe(b);
});
test('exports PRICE_UNCONFIGURED sentinel', () => {
const { PRICE_UNCONFIGURED } = require('../../src/services/stripeService');
expect(typeof PRICE_UNCONFIGURED).toBe('string');
expect(PRICE_UNCONFIGURED.length).toBeGreaterThan(0);
});
});
});
describe('handleWebhookEvent', () => {
// A chainable supabase fake whose final-chain return is configurable per call.
// Records every update payload so tests can assert grace_period_until etc.
function makeFake({ findUserById = 'user-1' } = {}) {
const updates = [];
const fake = {
updates,
from(table) {
const ctx = { table, filters: [] };
const proxy = {
update(patch) {
ctx.patch = patch;
ctx.action = 'update';
updates.push(ctx);
return proxy;
},
select() {
ctx.action = 'select';
return proxy;
},
eq(col, val) {
ctx.filters.push([col, val]);
if (ctx.action === 'update') return Promise.resolve({ error: null });
return proxy;
},
single() {
return Promise.resolve({ data: findUserById ? { id: findUserById } : null });
},
};
return proxy;
},
};
fake.rpcCalls = [];
fake.rpc = (fn, args) => { fake.rpcCalls.push({ fn, args }); return Promise.resolve({ data: true, error: null }); };
return fake;
}
test('checkout.session.completed finalizes through the single-writer RPC', async () => {
const fake = makeFake();
mockSupabaseClient.current = fake;
await handleWebhookEvent({
type: 'checkout.session.completed',
data: { object: { metadata: { user_id: 'u1', tier: 'analyst', is_founder: 'true' }, customer: 'cus_1' } },
});
// NEW CONTRACT: the webhook no longer writes the founder flags directly.
// finalize_founder_slot is the SINGLE writer of both, in one txn, so they
// cannot drift again (they already had: 1 vs 0 in prod).
const fin = fake.rpcCalls.find((c) => c.fn === 'finalize_founder_slot');
expect(fin).toBeTruthy();
expect(fin.args.p_user_id).toBe('u1');
expect(fin.args.p_tier).toBe('analyst');
expect(fin.args.p_stripe_customer_id).toBe('cus_1');
// and founder truth comes from the CLAIM, never from caller metadata
expect(JSON.stringify(fin.args)).not.toMatch(/is_founder/);
});
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();
await handleWebhookEvent({
type: 'invoice.payment_failed',
data: { object: { customer: 'cus_2' } },
});
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 + 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');
});
test('customer.subscription.updated active → flips tier to the new plan + clears grace', async () => {
// Plan-change flow (portal-driven upgrade/downgrade). Stripe sends
// the new price on items.data[0].price.id; the service must map it
// back to a tier via PRICE_MAP and reflect that on the user.
const fake = makeFake();
mockSupabaseClient.current = fake;
const newPriceId = process.env.STRIPE_PRICE_DESK || 'price_desk_monthly';
await handleWebhookEvent({
type: 'customer.subscription.updated',
data: {
object: {
customer: 'cus_active',
status: 'active',
items: { data: [{ price: { id: newPriceId } }] },
},
},
});
const usersUpdate = fake.updates.find((u) => u.table === 'users');
const profilesUpdate = fake.updates.find((u) => u.table === 'user_profiles');
expect(usersUpdate.patch.tier).toBe('desk');
expect(usersUpdate.patch.grace_period_until).toBeNull();
expect(profilesUpdate.patch.tier).toBe('desk');
expect(profilesUpdate.patch.subscription_status).toBe('active');
});
test('customer.subscription.deleted sets grace, does not flip tier immediately', async () => {
const fake = makeFake();
mockSupabaseClient.current = fake;
await handleWebhookEvent({
type: 'customer.subscription.deleted',
data: { object: { customer: 'cus_3' } },
});
const usersUpdate = fake.updates.find((u) => u.table === 'users');
expect(usersUpdate.patch.grace_period_until).toBeTruthy();
// tier is intentionally NOT downgraded here — the grace period gate
// handles read-time enforcement.
expect(usersUpdate.patch.tier).toBeUndefined();
});
test('payment_failed with unknown customer logs but does not throw', async () => {
const fake = makeFake({ findUserById: null });
mockSupabaseClient.current = fake;
await expect(
handleWebhookEvent({ type: 'invoice.payment_failed', data: { object: { customer: 'cus_ghost' } } })
).resolves.toBeUndefined();
});
});
});