'use strict'; /** * Canonical DashCaddy Pro product catalog. * * Keep product identity, license duration, USD amount, and the Stripe price * environment variable in one place. Checkout (src/billing/stripe-client.js), * the webhook bridge (scripts/stripe-license-bridge.js), the public pricing * page (status/pricing/index.html), and tests must NOT maintain separate * product lists — all of them import from here. * * Pricing source of truth: PRODUCT-SPEC-DECISIONS.md (locked 2026-07-20). * * Lifecycle: * - To add a new tier: append a new frozen product entry below, then add * the matching STRIPE_PRICE_PRO_*D environment variable to * the deployment. The pricing page (status/pricing/index.html) and * the catalog consistency test (__tests__/billing/pricing-page-catalog.test.js) * will both fail until the pricing page is updated in lockstep — this * is the documented drift guard. * - To change a price: edit the matching product entry AND the pricing * page's hardcoded price label (status/pricing/index.html). The * pricing-page-catalog.test.js enforces the two match. * * Note: The pricing page hard-codes the 4 product IDs, prices, and * duration strings (rather than being server-rendered from this catalog). * The hard-coding is intentional — the page is served as static HTML from * `status.sami/pricing` and never touches the live API. The * pricing-page-catalog.test.js enforces consistency between the two * sources, so any drift fails the test suite. */ const PRODUCTS = Object.freeze([ Object.freeze({ id: 'pro-30d', durationDays: 30, amountCents: 2000, priceEnv: 'STRIPE_PRICE_PRO_30D', label: '1 month', priceLabel: '$20', }), Object.freeze({ id: 'pro-90d', durationDays: 90, amountCents: 5000, priceEnv: 'STRIPE_PRICE_PRO_90D', label: '3 months', priceLabel: '$50', }), Object.freeze({ id: 'pro-180d', durationDays: 180, amountCents: 7000, priceEnv: 'STRIPE_PRICE_PRO_180D', label: '6 months', priceLabel: '$70', }), Object.freeze({ id: 'pro-365d', durationDays: 365, amountCents: 9900, priceEnv: 'STRIPE_PRICE_PRO_365D', label: '12 months', priceLabel: '$99', }), ]); const BY_ID = new Map(PRODUCTS.map((product) => [product.id, product])); function listProducts() { return PRODUCTS.slice(); } function getProduct(productId) { return BY_ID.get(productId) || null; } /** * Resolve the Stripe Price ID configured for a product. Returns '' if unset * (caller treats empty string as "this tier is not configured"). */ function getConfiguredPrice(product, env = process.env) { if (!product) return ''; return env[product.priceEnv] || ''; } /** * Map a Stripe Price ID back to a product. Used by the webhook bridge to * validate that a Checkout session's price matches a configured product * (defense against Stripe price-ID drift / repointing). * * Returns null when the price ID is unset or doesn't match any configured * product. */ function findProductByPriceId(priceId, env = process.env) { if (!priceId || typeof priceId !== 'string') return null; for (const product of PRODUCTS) { const configured = getConfiguredPrice(product, env); if (configured && configured === priceId) return product; } return null; } /** * Same as getConfiguredPrice but returns the full product list with the * resolved Stripe Price ID merged in. Useful for the pricing page renderer. */ function getConfiguredProducts(env = process.env) { return PRODUCTS.map((product) => ({ ...product, priceId: getConfiguredPrice(product, env) })); } module.exports = { PRODUCTS, listProducts, getProduct, getConfiguredPrice, getConfiguredProducts, findProductByPriceId, };