Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,17 @@ SENTRY_ENVIRONMENT=
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
# Trace environment label; defaults to NODE_ENV when left blank.
# OTEL_ENVIRONMENT=
# -- Stripe billing (optional) ------------------------------------------------
# Billing is DISABLED unless STRIPE_SECRET_KEY is set. When disabled, every user
# gets the generous self-host credit allowance and the /billing routes are
# inert. See docs/billing.md for setup with the Stripe CLI.
# STRIPE_SECRET_KEY=sk_test_xxx
# STRIPE_WEBHOOK_SECRET=whsec_xxx
# STRIPE_PRICE_PRO=price_xxx
# STRIPE_PRICE_ENTERPRISE=price_xxx
# Self-host default limit when billing is disabled (default 999999):
# MONTHLY_CREDIT_LIMIT=999999
# Per-tier monthly message allowances when billing is enabled:
# FREE_TIER_CREDITS=50
# PRO_TIER_CREDITS=1500
# ENTERPRISE_TIER_CREDITS=10000
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"pino": "^10.3.1",
"pino-http": "^11.0.0",
"prom-client": "^15.1.3",
"stripe": "^22.3.0",
"undici": "^6.27.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^4.4.3"
Expand Down
31 changes: 31 additions & 0 deletions apps/api/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ create table if not exists public.user_profiles (
quote_model text,
mfa_on_login boolean not null default false,
legal_research_us boolean not null default true,
-- Stripe billing (see docs/adr/0002-stripe-billing.md). These live on
-- user_profiles because a Mike user has exactly one Stripe customer and one
-- subscription, so a 1:1 column layout avoids an extra join on every credit
-- check. All nullable: a user who never touches billing leaves them unset,
-- and self-hosters without Stripe never populate them at all.
stripe_customer_id text unique,
stripe_subscription_id text,
subscription_status text,
subscription_current_period_end timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
Expand All @@ -39,6 +48,28 @@ create unique index if not exists user_profiles_email_lower_unique
create index if not exists idx_user_profiles_email
on public.user_profiles(email);

create index if not exists idx_user_profiles_stripe_customer
on public.user_profiles(stripe_customer_id);

-- ---------------------------------------------------------------------------
-- Billing webhook idempotency ledger
-- ---------------------------------------------------------------------------
-- Stripe delivers webhook events at-least-once and retries on timeout, so the
-- same event id can arrive multiple times. We record each processed event id
-- here; the primary key makes a duplicate insert fail, which is our signal to
-- skip reprocessing (see apps/api/src/lib/billing/webhook.ts).
create table if not exists public.billing_events (
event_id text primary key,
type text not null,
received_at timestamptz not null default now()
);

-- Only the backend (service role) touches billing data. Enable RLS and revoke
-- direct anon/authenticated grants — matches the deny-all convention in
-- 20260524000000_rls_deny_all.sql.
alter table public.billing_events enable row level security;
revoke all on public.billing_events from anon, authenticated;

create or replace function public.handle_new_user()
returns trigger
language plpgsql
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { userRouter } from "./modules/user/user.routes";
import { downloadsRouter } from "./modules/downloads/downloads.routes";
import { caseLawRouter } from "./modules/case-law/caseLaw.routes";
import { guestRouter } from "./modules/auth/auth.routes";
import { billingRouter, billingWebhookHandler } from "./modules/billing/billing.routes";
import { getAdminClient } from "./lib/supabase";
import { checkStorageReady } from "./lib/storage";
import { env } from "./lib/env";
Expand Down Expand Up @@ -160,6 +161,20 @@ if (metricsEnabled()) {
app.get("/metrics", metricsHandler);
}

// Stripe webhook — MUST be registered BEFORE the global rate limiter and the
// `express.json` body parser below.
// * Raw body: Stripe signs the exact bytes it sends. If `express.json`
// parsed and re-serialised the body first, the signature would no longer
// match and every event would be rejected. We attach `express.raw` to this
// one path so `req.body` is the original Buffer (see lib/billing/webhook.ts).
// * Before the rate limiter: Stripe retries failed deliveries and can burst;
// we don't want legitimate, signature-verified webhooks throttled.
app.post(
"/billing/webhook",
express.raw({ type: "application/json" }),
billingWebhookHandler,
);

app.use(generalLimiter);

// 10 MB cap on JSON bodies. The API never legitimately receives larger payloads
Expand Down Expand Up @@ -196,6 +211,7 @@ app.use("/single-documents", documentsRouter);
app.use("/library", libraryRouter);
app.use("/tabular-review", tabularRouter);
app.use("/workflows", workflowsRouter);
app.use("/billing", billingRouter);
app.use("/user", userRouter);
app.use("/users", userRouter);
app.use("/download", downloadsRouter);
Expand Down
143 changes: 143 additions & 0 deletions apps/api/src/lib/billing/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
creditLimitForTier,
getPlan,
getPlans,
isBillingEnabled,
normalizeTier,
priceIdForTier,
tierForPriceId,
SELF_HOST_CREDIT_LIMIT,
} from "../plans";

// The plan catalogue reads process.env at call time, so each test sets exactly
// the env it needs and restores the original afterwards.
const ENV_KEYS = [
"STRIPE_SECRET_KEY",
"STRIPE_PRICE_PRO",
"STRIPE_PRICE_ENTERPRISE",
"FREE_TIER_CREDITS",
"PRO_TIER_CREDITS",
"ENTERPRISE_TIER_CREDITS",
] as const;

let saved: Record<string, string | undefined>;

beforeEach(() => {
saved = {};
for (const key of ENV_KEYS) {
saved[key] = process.env[key];
delete process.env[key];
}
});

afterEach(() => {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});

describe("normalizeTier", () => {
it("passes through known tiers", () => {
expect(normalizeTier("Pro")).toBe("Pro");
expect(normalizeTier("Enterprise")).toBe("Enterprise");
expect(normalizeTier("Free")).toBe("Free");
});

it("defaults unknown / empty values to Free", () => {
expect(normalizeTier("platinum")).toBe("Free");
expect(normalizeTier(null)).toBe("Free");
expect(normalizeTier(undefined)).toBe("Free");
});
});

describe("isBillingEnabled", () => {
it("is false when STRIPE_SECRET_KEY is unset (self-host default)", () => {
expect(isBillingEnabled()).toBe(false);
});

it("is true once STRIPE_SECRET_KEY is set", () => {
process.env.STRIPE_SECRET_KEY = "sk_test_123";
expect(isBillingEnabled()).toBe(true);
});
});

describe("creditLimitForTier — billing disabled", () => {
it("returns the generous self-host default for every tier", () => {
// No STRIPE_SECRET_KEY -> billing disabled -> tier is irrelevant.
expect(creditLimitForTier("Free")).toBe(SELF_HOST_CREDIT_LIMIT);
expect(creditLimitForTier("Pro")).toBe(SELF_HOST_CREDIT_LIMIT);
expect(creditLimitForTier("Enterprise")).toBe(SELF_HOST_CREDIT_LIMIT);
expect(creditLimitForTier("anything")).toBe(SELF_HOST_CREDIT_LIMIT);
});
});

describe("creditLimitForTier — billing enabled", () => {
beforeEach(() => {
process.env.STRIPE_SECRET_KEY = "sk_test_123";
});

it("uses per-tier allowances from the catalogue", () => {
expect(creditLimitForTier("Free")).toBe(50);
expect(creditLimitForTier("Pro")).toBe(1_500);
expect(creditLimitForTier("Enterprise")).toBe(10_000);
});

it("maps unknown tiers to the Free allowance", () => {
expect(creditLimitForTier("mystery")).toBe(50);
});

it("honours per-tier credit overrides from the environment", () => {
process.env.PRO_TIER_CREDITS = "2222";
expect(creditLimitForTier("Pro")).toBe(2222);
});
});

describe("priceIdForTier", () => {
it("returns null for Free even when Stripe is configured", () => {
process.env.STRIPE_SECRET_KEY = "sk_test_123";
expect(priceIdForTier("Free")).toBeNull();
});

it("returns the configured price id for a paid tier", () => {
process.env.STRIPE_PRICE_PRO = "price_pro_abc";
expect(priceIdForTier("Pro")).toBe("price_pro_abc");
});

it("returns null for a paid tier with no configured price", () => {
expect(priceIdForTier("Enterprise")).toBeNull();
});
});

describe("tierForPriceId — reverse lookup used by the webhook", () => {
beforeEach(() => {
process.env.STRIPE_PRICE_PRO = "price_pro_abc";
process.env.STRIPE_PRICE_ENTERPRISE = "price_ent_xyz";
});

it("maps a known price id back to its tier", () => {
expect(tierForPriceId("price_pro_abc")).toBe("Pro");
expect(tierForPriceId("price_ent_xyz")).toBe("Enterprise");
});

it("falls back to Free for unknown / null price ids", () => {
expect(tierForPriceId("price_unknown")).toBe("Free");
expect(tierForPriceId(null)).toBe("Free");
expect(tierForPriceId(undefined)).toBe("Free");
});
});

describe("getPlans / getPlan", () => {
it("never marks Free as purchasable (no price id)", () => {
process.env.STRIPE_SECRET_KEY = "sk_test_123";
process.env.STRIPE_PRICE_PRO = "price_pro_abc";
const plans = getPlans();
expect(plans.Free.priceId).toBeNull();
expect(plans.Pro.priceId).toBe("price_pro_abc");
});

it("getPlan defaults to the Free plan for unknown tiers", () => {
expect(getPlan("nope").tier).toBe("Free");
});
});
106 changes: 106 additions & 0 deletions apps/api/src/lib/billing/__tests__/stripe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type Stripe from "stripe";
import { snapshotFromSubscription, tierFromSubscription } from "../stripe";

// tierFromSubscription -> tierForPriceId reads process.env at call time.
let savedPro: string | undefined;
let savedEnt: string | undefined;

beforeEach(() => {
savedPro = process.env.STRIPE_PRICE_PRO;
savedEnt = process.env.STRIPE_PRICE_ENTERPRISE;
process.env.STRIPE_PRICE_PRO = "price_pro_abc";
process.env.STRIPE_PRICE_ENTERPRISE = "price_ent_xyz";
});

afterEach(() => {
if (savedPro === undefined) delete process.env.STRIPE_PRICE_PRO;
else process.env.STRIPE_PRICE_PRO = savedPro;
if (savedEnt === undefined) delete process.env.STRIPE_PRICE_ENTERPRISE;
else process.env.STRIPE_PRICE_ENTERPRISE = savedEnt;
});

// Minimal subscription factory — only the fields the mappers read.
function sub(opts: {
status: string;
priceId?: string;
periodEnd?: number;
id?: string;
}): Stripe.Subscription {
return {
id: opts.id ?? "sub_123",
status: opts.status,
current_period_end: opts.periodEnd,
items: {
data: [
{
price: { id: opts.priceId },
current_period_end: opts.periodEnd,
},
],
},
} as unknown as Stripe.Subscription;
}

describe("tierFromSubscription", () => {
it("maps an active Pro-price subscription to the Pro tier", () => {
expect(tierFromSubscription(sub({ status: "active", priceId: "price_pro_abc" }))).toBe(
"Pro",
);
});

it("maps an active Enterprise-price subscription to Enterprise", () => {
expect(
tierFromSubscription(sub({ status: "active", priceId: "price_ent_xyz" })),
).toBe("Enterprise");
});

it("treats trialing as a live, paid tier", () => {
expect(
tierFromSubscription(sub({ status: "trialing", priceId: "price_pro_abc" })),
).toBe("Pro");
});

it("demotes non-live statuses to Free", () => {
for (const status of ["canceled", "past_due", "unpaid", "incomplete"]) {
expect(
tierFromSubscription(sub({ status, priceId: "price_pro_abc" })),
).toBe("Free");
}
});

it("falls back to Free for an unrecognised price", () => {
expect(
tierFromSubscription(sub({ status: "active", priceId: "price_other" })),
).toBe("Free");
});
});

describe("snapshotFromSubscription", () => {
it("projects the persisted columns from a subscription", () => {
const periodEnd = 1_900_000_000; // seconds
const snapshot = snapshotFromSubscription(
sub({
id: "sub_abc",
status: "active",
priceId: "price_pro_abc",
periodEnd,
}),
);
expect(snapshot).toEqual({
tier: "Pro",
subscription_status: "active",
stripe_subscription_id: "sub_abc",
subscription_current_period_end: new Date(
periodEnd * 1000,
).toISOString(),
});
});

it("tolerates a missing current_period_end", () => {
const snapshot = snapshotFromSubscription(
sub({ status: "active", priceId: "price_pro_abc" }),
);
expect(snapshot.subscription_current_period_end).toBeNull();
});
});
Loading
Loading