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.
120 lines
3.7 KiB
JavaScript
120 lines
3.7 KiB
JavaScript
'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_*<duration>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,
|
|
};
|