← 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);
  }
}