Canonical product catalog at src/billing/catalog.js shared by Stripe Checkout client (src/billing/stripe-client.js), webhook bridge (scripts/stripe-license-bridge.js), and pricing page (status/pricing/index.html). One-time payment keyed by productId at $20/$50/$70/$99 — no more monthly/annual subscription drift. Bridge resolves duration via metadata.productId (single contract), requires payment_status === 'paid' before fulfillment (rejects unpaid/no_payment_required/missing with ack 200), handles async_payment_succeeded for ACH/SEPA delayed-payment flow. License persisted to fulfillment-store BEFORE email — SMTP failure path serves the persisted code via the new /api/v1/billing/lookup/:sessionId endpoint (the documented customer recovery path). Layer-1 (event-id) + layer-2 (session-id) idempotency prevent duplicate issuance. Checkout return URLs derived from STRIPE_PUBLIC_ORIGIN or STRIPE_ALLOWED_HOSTS (not raw Host header) — closes host-header-poisoning + session-ID-leak attack class. 1498/1498 Jest tests pass (62 suites), zero new ESLint warnings introduced. Test files: - stripe-license-bridge.test.js (24 tests) - billing-lookup.test.js (8 tests, HTTP-level) - bridge-lookup-http.test.js (5 tests, uses exported createServer) - pricing-page-catalog.test.js (9 tests, per-tier consistency) - checkout-origin.test.js (6 tests, host injection rejection) - stripe-client.test.js (rewrite for productId + mode:payment) Bridge code refactored: handleWebhook decomposed into verifySignature + parseEventBody + checkEventIdempotency + fulfillCheckout + ensureLicensePersisted (under ESLint complexity=20 cap). New createServer()/createRequestHandler() factories guarded by require.main === module. Removed 3 stale test files from the rolled-back DC-055 attempt.
235 lines
9.1 KiB
JavaScript
235 lines
9.1 KiB
JavaScript
/**
|
|
* DC-055 + DC-057 billing/stripe-client tests.
|
|
*
|
|
* Strategy: inject a mock Stripe SDK via _setStripeSdk so no real network
|
|
* calls ever happen. Cover the key behaviors of the one-time payment flow:
|
|
*
|
|
* 1. Configuration validation — missing STRIPE_SECRET_KEY fails loudly with 503.
|
|
* 2. productId validation — unknown productId returns 400 INVALID_PRODUCT_ID.
|
|
* 3. Product not configured — Stripe Price ID env var unset returns 503.
|
|
* 4. Happy path — creates a session with mode:payment + correct price ID + URLs.
|
|
* 5. Stripe SDK errors — surface as 502 to the customer, not 500.
|
|
* 6. Metadata contract — emits metadata.productId that the bridge can read back.
|
|
* 7. payment_intent_data — also carries productId metadata for downstream consumers.
|
|
* 8. Catalog drives everything — _resolveProduct reads the catalog, not env.
|
|
*/
|
|
|
|
const stripeClient = require('../../src/billing/stripe-client');
|
|
const catalog = require('../../src/billing/catalog');
|
|
|
|
const REQUIRED_ENV = {
|
|
STRIPE_SECRET_KEY: '«redacted:sk_test_…»',
|
|
STRIPE_PRICE_PRO_30D: 'price_30d_test',
|
|
STRIPE_PRICE_PRO_90D: 'price_90d_test',
|
|
STRIPE_PRICE_PRO_180D: 'price_180d_test',
|
|
STRIPE_PRICE_PRO_365D: 'price_365d_test',
|
|
};
|
|
|
|
function setEnv(overrides = {}) {
|
|
const all = { ...REQUIRED_ENV, ...overrides };
|
|
for (const [k, v] of Object.entries(all)) {
|
|
process.env[k] = v;
|
|
}
|
|
}
|
|
|
|
function clearEnv() {
|
|
for (const k of Object.keys(REQUIRED_ENV)) delete process.env[k];
|
|
}
|
|
|
|
function makeMockStripe(sessionsCreateImpl) {
|
|
const sessions = { create: jest.fn().mockImplementation(sessionsCreateImpl) };
|
|
return jest.fn().mockReturnValue({ checkout: { sessions } });
|
|
}
|
|
|
|
describe('billing/stripe-client', () => {
|
|
afterEach(() => {
|
|
clearEnv();
|
|
stripeClient._setStripeSdk(null);
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
test('throws STRIPE_NOT_CONFIGURED when STRIPE_SECRET_KEY is missing', async () => {
|
|
setEnv({ STRIPE_SECRET_KEY: '' });
|
|
await expect(
|
|
stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' })
|
|
).rejects.toMatchObject({
|
|
code: 'STRIPE_NOT_CONFIGURED',
|
|
statusCode: 503,
|
|
missing: expect.arrayContaining(['STRIPE_SECRET_KEY']),
|
|
});
|
|
});
|
|
|
|
test('throws STRIPE_NOT_CONFIGURED when 30d product Stripe Price ID is missing', async () => {
|
|
setEnv({ STRIPE_PRICE_PRO_30D: '' });
|
|
await expect(
|
|
stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' })
|
|
).rejects.toMatchObject({
|
|
code: 'STRIPE_NOT_CONFIGURED',
|
|
missing: expect.arrayContaining(['STRIPE_PRICE_PRO_30D']),
|
|
productId: 'pro-30d',
|
|
});
|
|
});
|
|
|
|
test('throws INVALID_PRODUCT_ID when productId is missing', async () => {
|
|
setEnv();
|
|
await expect(
|
|
stripeClient.createCheckoutSession({ productId: '', origin: 'https://status.sami' })
|
|
).rejects.toMatchObject({ code: 'INVALID_PRODUCT_ID', statusCode: 400, field: 'productId' });
|
|
});
|
|
|
|
test('throws INVALID_PRODUCT_ID when productId is unknown', async () => {
|
|
setEnv();
|
|
await expect(
|
|
stripeClient.createCheckoutSession({ productId: 'pro-1000d', origin: 'https://status.sami' })
|
|
).rejects.toMatchObject({
|
|
code: 'INVALID_PRODUCT_ID',
|
|
statusCode: 400,
|
|
field: 'productId',
|
|
});
|
|
});
|
|
|
|
test('happy path: pro-30d creates session with mode=payment + correct params', async () => {
|
|
setEnv();
|
|
const mockSession = { id: 'cs_test_abc123', url: 'https://checkout.stripe.com/c/pay/cs_test_abc123' };
|
|
const mockStripe = makeMockStripe(async (params) => {
|
|
// DC-057: one-time payment, NOT subscription.
|
|
expect(params.mode).toBe('payment');
|
|
expect(params.line_items).toEqual([{ price: 'price_30d_test', quantity: 1 }]);
|
|
expect(params.success_url).toBe('https://status.sami/billing/success?session_id={CHECKOUT_SESSION_ID}');
|
|
expect(params.cancel_url).toBe('https://status.sami/pricing');
|
|
// The bridge reads this metadata back to map session → product → duration.
|
|
expect(params.metadata).toMatchObject({ productId: 'pro-30d', product: 'dashcaddy-pro' });
|
|
// payment_intent_data.metadata mirrors it for downstream Stripe→bridge consumers.
|
|
expect(params.payment_intent_data).toBeDefined();
|
|
expect(params.payment_intent_data.metadata).toMatchObject({ productId: 'pro-30d', product: 'dashcaddy-pro' });
|
|
// No subscription_data on one-time payment.
|
|
expect(params.subscription_data).toBeUndefined();
|
|
return mockSession;
|
|
});
|
|
stripeClient._setStripeSdk(mockStripe);
|
|
|
|
const result = await stripeClient.createCheckoutSession({
|
|
productId: 'pro-30d',
|
|
origin: 'https://status.sami',
|
|
});
|
|
expect(result).toEqual({ id: 'cs_test_abc123', url: mockSession.url });
|
|
expect(mockStripe).toHaveBeenCalledWith('«redacted:sk_test_…»');
|
|
});
|
|
|
|
test('happy path: pro-365d uses 365d price ID', async () => {
|
|
setEnv();
|
|
const mockStripe = makeMockStripe(async (params) => {
|
|
expect(params.line_items[0].price).toBe('price_365d_test');
|
|
expect(params.metadata.productId).toBe('pro-365d');
|
|
return { id: 'cs_365_xyz', url: 'https://checkout.stripe.com/c/pay/cs_365_xyz' };
|
|
});
|
|
stripeClient._setStripeSdk(mockStripe);
|
|
|
|
const result = await stripeClient.createCheckoutSession({
|
|
productId: 'pro-365d',
|
|
origin: 'https://status.sami',
|
|
});
|
|
expect(result.id).toBe('cs_365_xyz');
|
|
});
|
|
|
|
test('forwards customerEmail when provided', async () => {
|
|
setEnv();
|
|
const mockStripe = makeMockStripe(async (params) => {
|
|
expect(params.customer_email).toBe('alice@example.com');
|
|
return { id: 'cs_emailed', url: 'https://checkout.stripe.com/c/pay/cs_emailed' };
|
|
});
|
|
stripeClient._setStripeSdk(mockStripe);
|
|
|
|
await stripeClient.createCheckoutSession({
|
|
productId: 'pro-90d',
|
|
customerEmail: 'alice@example.com',
|
|
origin: 'https://status.sami',
|
|
});
|
|
});
|
|
|
|
test('omits customer_email when not provided (no undefined leakage to Stripe)', async () => {
|
|
setEnv();
|
|
const mockStripe = makeMockStripe(async (params) => {
|
|
expect('customer_email' in params).toBe(false);
|
|
return { id: 'cs_no_email', url: 'https://checkout.stripe.com/c/pay/cs_no_email' };
|
|
});
|
|
stripeClient._setStripeSdk(mockStripe);
|
|
|
|
await stripeClient.createCheckoutSession({
|
|
productId: 'pro-30d',
|
|
origin: 'https://status.sami',
|
|
});
|
|
});
|
|
|
|
test('uses STRIPE_SUCCESS_URL override when set', async () => {
|
|
setEnv({ STRIPE_SUCCESS_URL: 'https://custom.example.com/thanks' });
|
|
const mockStripe = makeMockStripe(async (params) => {
|
|
expect(params.success_url).toBe('https://custom.example.com/thanks');
|
|
return { id: 'cs_custom', url: 'x' };
|
|
});
|
|
stripeClient._setStripeSdk(mockStripe);
|
|
|
|
await stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' });
|
|
});
|
|
|
|
test('uses STRIPE_CANCEL_URL override when set', async () => {
|
|
setEnv({ STRIPE_CANCEL_URL: 'https://custom.example.com/back' });
|
|
const mockStripe = makeMockStripe(async (params) => {
|
|
expect(params.cancel_url).toBe('https://custom.example.com/back');
|
|
return { id: 'cs_cancel', url: 'x' };
|
|
});
|
|
stripeClient._setStripeSdk(mockStripe);
|
|
|
|
await stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' });
|
|
});
|
|
|
|
test('works with relative origin (no host header)', async () => {
|
|
setEnv();
|
|
const mockStripe = makeMockStripe(async () => ({ id: 'x', url: 'x' }));
|
|
stripeClient._setStripeSdk(mockStripe);
|
|
|
|
const result = await stripeClient.createCheckoutSession({ productId: 'pro-30d' });
|
|
expect(result.id).toBe('x');
|
|
});
|
|
|
|
test('each catalog product drives a different price ID', async () => {
|
|
setEnv();
|
|
for (const product of catalog.PRODUCTS) {
|
|
const mockStripe = makeMockStripe(async (params) => {
|
|
expect(params.line_items[0].price).toBe(REQUIRED_ENV[product.priceEnv]);
|
|
expect(params.metadata.productId).toBe(product.id);
|
|
return { id: `cs_${product.id}`, url: 'x' };
|
|
});
|
|
stripeClient._setStripeSdk(mockStripe);
|
|
await stripeClient.createCheckoutSession({ productId: product.id, origin: 'https://status.sami' });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('billing/stripe-client — _resolveProduct unit', () => {
|
|
test('resolves known productId with configured price', () => {
|
|
setEnv();
|
|
const result = stripeClient._resolveProduct('pro-30d');
|
|
expect(result.product.id).toBe('pro-30d');
|
|
expect(result.priceId).toBe('price_30d_test');
|
|
});
|
|
|
|
test('returns INVALID_PRODUCT_ID error for unknown productId', () => {
|
|
setEnv();
|
|
expect(() => stripeClient._resolveProduct('pro-1000d')).toThrow();
|
|
try { stripeClient._resolveProduct('pro-1000d'); } catch (e) {
|
|
expect(e.code).toBe('INVALID_PRODUCT_ID');
|
|
expect(e.statusCode).toBe(400);
|
|
}
|
|
});
|
|
|
|
test('returns STRIPE_NOT_CONFIGURED error when product price is unset', () => {
|
|
setEnv({ STRIPE_PRICE_PRO_180D: '' });
|
|
try { stripeClient._resolveProduct('pro-180d'); } catch (e) {
|
|
expect(e.code).toBe('STRIPE_NOT_CONFIGURED');
|
|
expect(e.statusCode).toBe(503);
|
|
expect(e.missing).toContain('STRIPE_PRICE_PRO_180D');
|
|
}
|
|
});
|
|
});
|