← back to Charge And Explore
backend/src/providers/stripe-billing.ts
123 lines
// Stripe billing (TK-10227). Ships INERT behind a feature flag, mirroring the
// Smartcar "configured:false" pattern: unless BILLING_ENABLED=1 AND a
// mode-matched key is present, every billing route answers { configured: false }
// and nothing renders in the UI.
//
// TEST is the DEFAULT and only mode unless real charges are EXPLICITLY opted in:
// - Default (BILLING_LIVE unset): reads STRIPE_TEST_SECRET_KEY and refuses any
// key that isn't sk_test_… — a live key (sk_live_…) leaves the provider
// unconfigured, so live-mode Stripe objects can never be created by accident.
// - LIVE (BILLING_LIVE=1, real money — Steve-gated): reads STRIPE_LIVE_SECRET_KEY
// and requires an sk_live_… key; anything else leaves it unconfigured. The two
// modes never share env vars, so a test key can't leak into live or vice-versa.
//
// HARD RAILS:
// - The key must match the active mode's prefix (see keyOk) — a mismatched key
// is treated as absent. Going live is therefore a deliberate two-part gate:
// BILLING_LIVE=1 AND a real sk_live_ key; neither alone flips it on.
// - The stripe SDK is imported LAZILY on first real use, so the server keeps
// its zero-dependency runtime when billing is off (deploys without
// node_modules still boot exactly as before).
//
// Products/prices are created lazily in TEST mode on first use and looked up
// idempotently by metadata (ce_app + ce_tier), so re-boots/re-deploys reuse
// the same objects instead of piling up duplicates.
import type Stripe from "stripe";
import { TIERS, type PaidTier } from "../core/tiers.ts";
const APP_META = "charge-and-explore";
export class StripeBilling {
readonly enabled: boolean; // BILLING_ENABLED=1
readonly live: boolean; // BILLING_LIVE=1 (opt-in real charges; default off)
private readonly key: string; // mode-matched: sk_test_… (default) or sk_live_… (live)
private readonly webhookSecret: string;
private stripe: Stripe | null = null;
private priceIds = new Map<PaidTier, string>();
constructor(env: Record<string, string | undefined> = process.env) {
this.enabled = env.BILLING_ENABLED === "1";
this.live = env.BILLING_LIVE === "1";
// The two modes never share env vars, so a key can't cross modes.
this.key = (this.live ? env.STRIPE_LIVE_SECRET_KEY : env.STRIPE_TEST_SECRET_KEY) ?? "";
this.webhookSecret = (this.live ? env.STRIPE_LIVE_WEBHOOK_SECRET : env.STRIPE_TEST_WEBHOOK_SECRET) ?? "";
}
// Mode-matched key: live mode requires sk_live_, test mode requires sk_test_.
// A mismatched key (e.g. an sk_live_ key while BILLING_LIVE is unset) is
// treated as absent — so real charges can never happen unless BILLING_LIVE=1
// is EXPLICITLY set alongside a genuine sk_live_ key.
get keyOk(): boolean {
return this.live ? this.key.startsWith("sk_live_") : this.key.startsWith("sk_test_");
}
get configured(): boolean { return this.enabled && this.keyOk; }
get hasWebhookSecret(): boolean { return this.webhookSecret.length > 0; }
private async client(): Promise<Stripe> {
if (!this.configured) throw new Error("billing not configured");
if (!this.stripe) {
const { default: StripeCtor } = await import("stripe");
this.stripe = new StripeCtor(this.key);
}
return this.stripe;
}
// Idempotent product + monthly price per paid tier, keyed by metadata.
async ensurePriceId(tier: PaidTier): Promise<string> {
const cached = this.priceIds.get(tier);
if (cached) return cached;
const stripe = await this.client();
const ent = TIERS[tier];
let product = (await stripe.products.list({ limit: 100, active: true })).data
.find((p) => p.metadata?.ce_app === APP_META && p.metadata?.ce_tier === tier);
if (!product) {
product = await stripe.products.create({
name: `Charge & Explore ${ent.label}`,
description: tier === "pro"
? "Full history, parked & phantom-drain analytics, priority sampling."
: "90-day history across all your vehicles.",
metadata: { ce_app: APP_META, ce_tier: tier },
});
}
let price = (await stripe.prices.list({ product: product.id, active: true, limit: 100 })).data
.find((p) => p.recurring?.interval === "month" && p.unit_amount === ent.priceCentsMonthly);
if (!price) {
price = await stripe.prices.create({
product: product.id, currency: "usd", unit_amount: ent.priceCentsMonthly,
recurring: { interval: "month" },
metadata: { ce_app: APP_META, ce_tier: tier },
});
}
this.priceIds.set(tier, price.id);
return price.id;
}
// Test-mode Checkout Session for a signed-in Google user; the ce_user_sub /
// ce_tier metadata rides both the session AND the subscription so the
// webhook can map events back to the user with no extra lookups.
async createCheckoutSession(opts: { tier: PaidTier; userSub: string; email: string; baseUrl: string }): Promise<string | null> {
const stripe = await this.client();
const price = await this.ensurePriceId(opts.tier);
const meta = { ce_app: APP_META, ce_tier: opts.tier, ce_user_sub: opts.userSub };
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price, quantity: 1 }],
customer_email: opts.email || undefined,
success_url: `${opts.baseUrl}/dashboard?billing=success`,
cancel_url: `${opts.baseUrl}/dashboard?billing=cancelled`,
metadata: meta,
subscription_data: { metadata: meta },
});
return session.url ?? null;
}
// Verify + parse a webhook delivery. Signature verification is mandatory —
// no webhook secret means no webhook processing.
async verifyWebhook(rawBody: string, signature: string): Promise<Stripe.Event> {
if (!this.hasWebhookSecret) throw new Error("STRIPE_TEST_WEBHOOK_SECRET not set");
const stripe = await this.client();
return stripe.webhooks.constructEventAsync(rawBody, signature, this.webhookSecret);
}
}