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.
201 lines
7.4 KiB
JavaScript
201 lines
7.4 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* DashCaddy Stripe client — DC-055 + DC-057.
|
|
*
|
|
* Thin wrapper around the Stripe SDK for the OUTBOUND side of one-time
|
|
* license purchasing: creating Checkout Sessions that drive customers to
|
|
* Stripe's hosted payment page.
|
|
*
|
|
* Inbound (webhook) handling lives in scripts/stripe-license-bridge.js
|
|
* (DC-054 + DC-057) — that runs as its own process so the merchant's
|
|
* Stripe webhook secret doesn't have to be loaded into the DashCaddy API
|
|
* host process.
|
|
*
|
|
* # Pricing contract
|
|
*
|
|
* One-time payments keyed by `productId` from src/billing/catalog.js:
|
|
*
|
|
* pro-30d → $20 USD, 30-day license
|
|
* pro-90d → $50 USD, 90-day license
|
|
* pro-180d → $70 USD, 180-day license
|
|
* pro-365d → $99 USD, 365-day license
|
|
*
|
|
* `mode: 'payment'` (NOT 'subscription'). The license is generated once
|
|
* per Checkout completion and the customer pastes it into their host.
|
|
* No recurring billing, no Stripe Customer object retained beyond the
|
|
* session.
|
|
*
|
|
* # Configuration (env vars)
|
|
*
|
|
* Required to create sessions — failures are loud:
|
|
* STRIPE_SECRET_KEY — Stripe API secret (sk_live_... | sk_test_...)
|
|
* STRIPE_PRICE_PRO_30D — Stripe Price ID for the 30-day product
|
|
* STRIPE_PRICE_PRO_90D — Stripe Price ID for the 90-day product
|
|
* STRIPE_PRICE_PRO_180D — Stripe Price ID for the 180-day product
|
|
* STRIPE_PRICE_PRO_365D — Stripe Price ID for the 365-day product
|
|
* STRIPE_SUCCESS_URL — (optional) success page URL override
|
|
* STRIPE_CANCEL_URL — (optional) cancel page URL override
|
|
*
|
|
* The Stripe Price IDs map 1:1 to catalog products. A product whose
|
|
* Stripe Price ID is unset cannot be purchased (returns
|
|
* STRIPE_NOT_CONFIGURED).
|
|
*
|
|
* # Metadata contract (DC-057)
|
|
*
|
|
* The Checkout session carries `metadata.productId` (= one of the
|
|
* catalog IDs). The webhook bridge reads this field back, maps to the
|
|
* catalog, and generates the matching license duration.
|
|
*
|
|
* Why productId and not (e.g.) durationDays: the catalog is the single
|
|
* source of truth. If pricing changes (e.g. new tier added), only the
|
|
* catalog and the bridge change — the Checkout metadata stays abstract.
|
|
*
|
|
* Tested in __tests__/billing/stripe-client.test.js with mocked Stripe SDK.
|
|
*/
|
|
|
|
let stripeSdk = null;
|
|
function _loadStripeSdk() {
|
|
if (stripeSdk) return stripeSdk;
|
|
// Lazy require so tests can install a mock BEFORE first call.
|
|
stripeSdk = require('stripe');
|
|
return stripeSdk;
|
|
}
|
|
|
|
/**
|
|
* Inject a mock Stripe SDK. Used by tests; never call in production code.
|
|
* @param {Object} mockSdk - Object with `checkout.sessions.create` (and any other surface) the tests want to stub.
|
|
*/
|
|
function _setStripeSdk(mockSdk) {
|
|
stripeSdk = mockSdk;
|
|
}
|
|
|
|
const catalog = require('./catalog');
|
|
|
|
/**
|
|
* Read the active configuration. Throws if STRIPE_SECRET_KEY is missing
|
|
* OR if no product has its Stripe Price ID configured — both are loud
|
|
* failures so an operator notices instead of seeing silent 500s.
|
|
*
|
|
* @param {Object} [env] - process.env by default; tests pass custom env.
|
|
* @returns {Object} config snapshot for this invocation
|
|
*/
|
|
function _readConfig(env = process.env) {
|
|
const secretKey = env.STRIPE_SECRET_KEY;
|
|
if (!secretKey) {
|
|
const err = new Error(
|
|
'Stripe billing is not configured. Missing env var: STRIPE_SECRET_KEY. ' +
|
|
'Set it in /opt/dashcaddy/.env and restart the API.'
|
|
);
|
|
err.code = 'STRIPE_NOT_CONFIGURED';
|
|
err.statusCode = 503;
|
|
err.missing = ['STRIPE_SECRET_KEY'];
|
|
throw err;
|
|
}
|
|
return { secretKey };
|
|
}
|
|
|
|
/**
|
|
* Validate the requested productId and resolve its Stripe Price ID.
|
|
* Throws with a structured error if the productId is unknown OR if the
|
|
* product's Stripe Price ID env var is not configured.
|
|
*
|
|
* @param {string} productId
|
|
* @param {Object} env
|
|
* @returns {Object} catalog product entry
|
|
*/
|
|
function _resolveProduct(productId, env = process.env) {
|
|
if (!productId || typeof productId !== 'string') {
|
|
const err = new Error('productId is required');
|
|
err.code = 'INVALID_PRODUCT_ID';
|
|
err.statusCode = 400;
|
|
err.field = 'productId';
|
|
throw err;
|
|
}
|
|
const product = catalog.getProduct(productId);
|
|
if (!product) {
|
|
const err = new Error(`Unknown productId: ${productId}. Valid: ${catalog.PRODUCTS.map(p => p.id).join(', ')}`);
|
|
err.code = 'INVALID_PRODUCT_ID';
|
|
err.statusCode = 400;
|
|
err.field = 'productId';
|
|
throw err;
|
|
}
|
|
const priceId = catalog.getConfiguredPrice(product, env);
|
|
if (!priceId) {
|
|
const err = new Error(
|
|
`Product ${productId} is not configured for purchase. Missing env var: ${product.priceEnv}. ` +
|
|
`Create the Stripe Price and set the env var in /opt/dashcaddy/.env, then restart the API.`
|
|
);
|
|
err.code = 'STRIPE_NOT_CONFIGURED';
|
|
err.statusCode = 503;
|
|
err.missing = [product.priceEnv];
|
|
err.productId = productId;
|
|
throw err;
|
|
}
|
|
return { product, priceId };
|
|
}
|
|
|
|
/**
|
|
* Create a Stripe Checkout Session for a one-time DashCaddy Pro purchase.
|
|
*
|
|
* @param {Object} opts
|
|
* @param {string} opts.productId - catalog id: 'pro-30d' | 'pro-90d' | 'pro-180d' | 'pro-365d'
|
|
* @param {string} [opts.customerEmail] - email to prefill on Checkout (optional)
|
|
* @param {string} [opts.origin] - request origin (e.g. 'https://status.sami') used to build success/cancel URLs
|
|
* @returns {Promise<{ id: string, url: string }>}
|
|
* @throws Error with `.code` and `.statusCode` on configuration/validation failure
|
|
*/
|
|
async function createCheckoutSession({ productId, customerEmail, origin }) {
|
|
const config = _readConfig();
|
|
const { product, priceId } = _resolveProduct(productId);
|
|
const stripe = _loadStripeSdk();
|
|
const api = stripe(config.secretKey);
|
|
|
|
const successUrl = process.env.STRIPE_SUCCESS_URL
|
|
|| (origin ? `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}` : '/billing/success?session_id={CHECKOUT_SESSION_ID}');
|
|
const cancelUrl = process.env.STRIPE_CANCEL_URL
|
|
|| (origin ? `${origin}/pricing` : '/pricing');
|
|
|
|
// mode: 'payment' (one-time, NOT subscription). The license is generated
|
|
// once on `checkout.session.completed` and the customer pastes it into
|
|
// their host. No Customer object, no recurring billing.
|
|
//
|
|
// metadata.productId is the contract with the webhook bridge — it maps
|
|
// back to a catalog entry to get the license duration. If the catalog
|
|
// grows, only the bridge needs to change.
|
|
const params = {
|
|
mode: 'payment',
|
|
line_items: [{ price: priceId, quantity: 1 }],
|
|
success_url: successUrl,
|
|
cancel_url: cancelUrl,
|
|
metadata: {
|
|
productId: product.id,
|
|
product: 'dashcaddy-pro',
|
|
},
|
|
// payment_intent_data carries metadata to the PaymentIntent too, so
|
|
// any downstream Stripe→bridge plumbing that reads PI metadata still
|
|
// gets the productId. (Stripe's webhook includes the PI on
|
|
// checkout.session.completed for retrieval but the canonical metadata
|
|
// field for session-level events is the top-level metadata.)
|
|
payment_intent_data: {
|
|
metadata: { productId: product.id, product: 'dashcaddy-pro' },
|
|
},
|
|
allow_promotion_codes: true,
|
|
};
|
|
if (customerEmail) {
|
|
params.customer_email = customerEmail;
|
|
}
|
|
|
|
const session = await api.checkout.sessions.create(params);
|
|
|
|
return { id: session.id, url: session.url };
|
|
}
|
|
|
|
module.exports = {
|
|
createCheckoutSession,
|
|
// Test seams
|
|
_setStripeSdk,
|
|
_readConfig,
|
|
_resolveProduct,
|
|
};
|