Files
dashcaddy/dashcaddy-api/src/billing/catalog.js
T
Hermes 9b9711bf24
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-057: close checkout-to-license contract drift (grade B)
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.
2026-08-04 14:18:49 -07:00

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,
};