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