From 2bdb368f921d4c2cdf5b700ed02b5422c8014032 Mon Sep 17 00:00:00 2001 From: AakashFrexy Date: Sat, 23 May 2026 13:37:34 -0400 Subject: [PATCH] Stripe Compliance Pack purchase flow (closes #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the demo monetization beat: products + PaymentIntent + signature- verified webhook + Stripe Elements modal + the Runbook F5 simulate-success fallback. Reuses the SSE bus, error envelope, ClickHouse Cloud, and shared schemas scaffolded in #2. - GET /v1/billing/products — single Compliance Pack at $999 one-time, with the org's current entitlement. - POST /v1/billing/payment-intents — creates a real Stripe PaymentIntent in test mode, returns clientSecret + publishableKey. Verified live against acct_1TaIsMJXawLng5dj (pi_3TaJSxJXawLng5dj03B8WmHX returned by the live smoke). - POST /webhooks/stripe — raw-body signature verification via stripe.webhooks.constructEvent. payment_intent.succeeded flips org.compliancePack, persists Action(kind:"payment", status:"delivered"), and emits org.entitlements.changed SSE. payment_intent.payment_failed persists a failed Action without changing entitlements. Unknown event types ack 200 no-op (handoff/API §07). - POST /v1/billing/simulate-success — dev-only (404 in production) fallback the modal calls on Shift+Enter when Elements fails on stage. Same observable side effects as a real webhook. - apps/web Stripe modal — @stripe/react-stripe-js + Elements + PaymentElement. States: loading-product → ready-to-pay → creating-intent → elements-ready → processing → success (driven by SSE org.entitlements.changed) → failure with retry. Already-entitled orgs short-circuit straight to success. - apps/api/src/providers/stripe.ts — singleton SDK + idempotent boot-time ensureStripeProducts() that retrieves-or-creates the compliance-pack product and price. - apps/api/src/db/actions.ts — ActionStore interface + ClickHouseActionStore writing the existing actions table; in-memory test double in tests/helpers. - apps/api/src/lib/entitlements.ts — Org cache mutation + bus publish. - packages/shared — EntitlementsChangedEvent added to the SseEvent union. Smoke verified end-to-end against live Stripe (test mode) and live ClickHouse Cloud: - POST products → 200 with the locked SKU + current entitlement - POST payment-intents → real Stripe pi_3TaJSxJXawLng5dj03B8WmHX - POST simulate-success → entitlement flips, Action row persisted to CH with payload.simulated:true Tests: 30/30 passing across 6 suites. Webhook tests use the real Stripe SDK to sign synthetic events (no network), so signature handling is exercised against the actual constructEvent path. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/package.json | 1 + apps/api/src/db/actions.ts | 79 +++++++ apps/api/src/env.ts | 3 + apps/api/src/index.ts | 16 ++ apps/api/src/lib/entitlements.ts | 32 +++ apps/api/src/providers/stripe.ts | 102 +++++++++ apps/api/src/routes/billing.ts | 138 +++++++++++++ apps/api/src/routes/webhooks-stripe.ts | 115 +++++++++++ apps/api/src/server.ts | 4 + apps/web/package.json | 2 + apps/web/src/App.tsx | 11 + apps/web/src/lib/api.ts | 44 +++- apps/web/src/lib/stream.ts | 40 ++++ apps/web/src/screens/StripeModal.tsx | 261 +++++++++++++++++++++++ apps/web/src/styles.css | 41 ++++ package.json | 1 + packages/shared/src/types.ts | 9 +- pnpm-lock.yaml | 128 ++++++++++++ tests/api/billing.test.ts | 137 ++++++++++++ tests/api/stripe-webhook.test.ts | 198 ++++++++++++++++++ tests/helpers/in-memory-action-store.ts | 13 ++ tests/setup.ts | 17 +- tests/web/stripe-modal.test.tsx | 263 ++++++++++++++++++++++++ 23 files changed, 1649 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/db/actions.ts create mode 100644 apps/api/src/lib/entitlements.ts create mode 100644 apps/api/src/providers/stripe.ts create mode 100644 apps/api/src/routes/billing.ts create mode 100644 apps/api/src/routes/webhooks-stripe.ts create mode 100644 apps/web/src/screens/StripeModal.tsx create mode 100644 tests/api/billing.test.ts create mode 100644 tests/api/stripe-webhook.test.ts create mode 100644 tests/helpers/in-memory-action-store.ts create mode 100644 tests/web/stripe-modal.test.tsx diff --git a/apps/api/package.json b/apps/api/package.json index 4c254a7..0436447 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,7 @@ "@hono/node-server": "^1.13.0", "@redline/shared": "workspace:*", "hono": "^4.6.0", + "stripe": "^17.4.0", "ulid": "^2.3.0", "zod": "^3.23.0" }, diff --git a/apps/api/src/db/actions.ts b/apps/api/src/db/actions.ts new file mode 100644 index 0000000..4b727d7 --- /dev/null +++ b/apps/api/src/db/actions.ts @@ -0,0 +1,79 @@ +import { clickhouse } from "./client.js"; + +// Action persistence. Per handoff/Data Model §03, ChangeReport has many Actions; +// Stripe payments persist with kind:"payment" and no change_report_id (the +// purchase isn't tied to a change). Schema lives in the existing `actions` +// ClickHouse table (DDL applied in #2). + +export type ActionKind = "slack" | "jira" | "email" | "calendar" | "draft" | "payment"; +export type ActionStatus = "queued" | "delivered" | "failed" | "acknowledged"; + +export interface ActionRecord { + id: string; + orgId: string; + changeReportId?: string | undefined; + kind: ActionKind; + target: string; + payload: Record; + firedAt: string; // ISO 8601 + status: ActionStatus; + externalId?: string | undefined; + error?: string | undefined; +} + +export interface ActionStore { + insert(action: ActionRecord): Promise; +} + +interface ActionRow { + id: string; + org_id: string; + change_report_id: string | null; + kind: string; + target: string; + payload: string; + fired_at: string; + status: string; + external_id: string | null; + error: string | null; +} + +function isoToChDate(iso: string): string { + return iso.replace("T", " ").replace("Z", ""); +} + +function toRow(a: ActionRecord): ActionRow { + return { + id: a.id, + org_id: a.orgId, + change_report_id: a.changeReportId ?? null, + kind: a.kind, + target: a.target, + payload: JSON.stringify(a.payload), + fired_at: isoToChDate(a.firedAt), + status: a.status, + external_id: a.externalId ?? null, + error: a.error ?? null, + }; +} + +export class ClickHouseActionStore implements ActionStore { + async insert(action: ActionRecord): Promise { + await clickhouse().insert({ + table: "actions", + values: [toRow(action)], + format: "JSONEachRow", + }); + } +} + +let cached: ActionStore | undefined; + +export function actionStore(): ActionStore { + if (!cached) cached = new ClickHouseActionStore(); + return cached; +} + +export function setActionStore(store: ActionStore): void { + cached = store; +} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 758f381..bc0b510 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -45,6 +45,9 @@ const EnvSchema = z.object({ CLICKHOUSE_DATABASE: z.string().min(1).default("default"), ADMIN_TOKEN: z.string().min(1).default("demo_admin_2026"), SCAN_INTERVAL_SEC: z.coerce.number().int().positive().default(60), + STRIPE_SECRET_KEY: z.string().startsWith("sk_").optional(), + STRIPE_PUBLISHABLE_KEY: z.string().startsWith("pk_").optional(), + STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), }); export type Env = z.infer; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 62cf109..1a6ea37 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,11 +3,27 @@ import { buildApp } from "./server.js"; import { env } from "./env.js"; import { loadSeeds } from "./seed/loader.js"; import { migrate } from "./db/migrate.js"; +import { ensureStripeProducts } from "./providers/stripe.js"; async function main(): Promise { const e = env(); loadSeeds(); await migrate(); + if (e.STRIPE_SECRET_KEY) { + try { + const state = await ensureStripeProducts(); + // eslint-disable-next-line no-console + console.log( + `Stripe ready · product=${state.productId} · price=${state.priceId}`, + ); + } catch (err) { + // eslint-disable-next-line no-console + console.warn("Stripe bootstrap failed (billing routes will 502):", err); + } + } else { + // eslint-disable-next-line no-console + console.log("Stripe not configured (set STRIPE_SECRET_KEY to enable billing)"); + } const app = buildApp(); serve({ fetch: app.fetch, port: e.PORT }); // eslint-disable-next-line no-console diff --git a/apps/api/src/lib/entitlements.ts b/apps/api/src/lib/entitlements.ts new file mode 100644 index 0000000..ea62f47 --- /dev/null +++ b/apps/api/src/lib/entitlements.ts @@ -0,0 +1,32 @@ +import type { Org } from "@redline/shared"; +import { bus } from "./bus.js"; +import { getOrg } from "../seed/loader.js"; + +// Orgs are in-memory per handoff/Data Model §05. The Stripe webhook flips +// the compliancePack flag and broadcasts via the bus so the UI's Stripe modal +// can switch to the success state. + +export type EntitlementKey = keyof Org["entitlements"]; + +export function setEntitlement( + orgId: string, + key: EntitlementKey, + value: boolean, +): { changed: boolean; org: Org } { + const org = getOrg(orgId); + if (!org) throw new Error(`Org ${orgId} not found`); + const before = org.entitlements[key]; + if (before === value) { + return { changed: false, org }; + } + org.entitlements[key] = value; + bus.publish(orgId, { + event: "org.entitlements.changed", + data: { + compliancePack: org.entitlements.compliancePack, + auditorPortal: org.entitlements.auditorPortal, + changedAt: new Date().toISOString(), + }, + }); + return { changed: true, org }; +} diff --git a/apps/api/src/providers/stripe.ts b/apps/api/src/providers/stripe.ts new file mode 100644 index 0000000..cd8ee56 --- /dev/null +++ b/apps/api/src/providers/stripe.ts @@ -0,0 +1,102 @@ +import Stripe from "stripe"; +import { env } from "../env.js"; + +// Handoff/Decisions §09 locks: one product, one price, one PaymentIntent, +// one webhook. We mirror that here exactly — no subscriptions, no extra SKUs. + +export const COMPLIANCE_PACK = { + id: "compliance-pack", + name: "Compliance Pack", + description: "Evidence Bundles · Auditor portal · Vanta/Drata push", + amountUsdCents: 99_900, + currency: "usd", +} as const; + +let cached: Stripe | undefined; + +export function stripe(): Stripe { + if (cached) return cached; + const e = env(); + if (!e.STRIPE_SECRET_KEY) { + throw new Error( + "STRIPE_SECRET_KEY is not set — billing/webhook routes require it", + ); + } + cached = new Stripe(e.STRIPE_SECRET_KEY, { + // Pin a known-good API version so behavior is reproducible. + apiVersion: "2025-02-24.acacia", + typescript: true, + }); + return cached; +} + +// Stripe's account state — products live, the price for the pack, etc. We +// keep the productId and priceId in module memory so the billing route +// doesn't have to query Stripe on every call. +interface StripeState { + productId: string; + priceId: string; +} + +let state: StripeState | undefined; + +export function getStripeState(): StripeState | undefined { + return state; +} + +// Idempotent boot-time bootstrap. Creates the compliance-pack product and +// its price if either is missing. Safe to call repeatedly. +export async function ensureStripeProducts(): Promise { + if (state) return state; + const s = stripe(); + + let product: Stripe.Product; + try { + product = await s.products.retrieve(COMPLIANCE_PACK.id); + } catch (err) { + if (err instanceof Stripe.errors.StripeError && err.code === "resource_missing") { + product = await s.products.create({ + id: COMPLIANCE_PACK.id, + name: COMPLIANCE_PACK.name, + description: COMPLIANCE_PACK.description, + metadata: { tier: "addon" }, + }); + } else { + throw err; + } + } + + // Find or create the $999 one-time price. + const prices = await s.prices.list({ product: product.id, active: true, limit: 100 }); + const existing = prices.data.find( + (p) => + p.unit_amount === COMPLIANCE_PACK.amountUsdCents && + p.currency === COMPLIANCE_PACK.currency && + p.type === "one_time", + ); + const price = + existing ?? + (await s.prices.create({ + product: product.id, + unit_amount: COMPLIANCE_PACK.amountUsdCents, + currency: COMPLIANCE_PACK.currency, + })); + + state = { productId: product.id, priceId: price.id }; + return state; +} + +// Test/dev seam: lets tests pre-seed state without contacting Stripe. +export function setStripeState(next: StripeState): void { + state = next; +} + +export function resetStripe(): void { + cached = undefined; + state = undefined; +} + +// Test seam: lets tests inject a fake SDK without contacting Stripe. +export function setStripeInstance(fake: Stripe): void { + cached = fake; +} diff --git a/apps/api/src/routes/billing.ts b/apps/api/src/routes/billing.ts new file mode 100644 index 0000000..6cfad22 --- /dev/null +++ b/apps/api/src/routes/billing.ts @@ -0,0 +1,138 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { ErrorCodes } from "@redline/shared"; +import { ApiError } from "../lib/errors.js"; +import { env } from "../env.js"; +import { + COMPLIANCE_PACK, + ensureStripeProducts, + stripe, +} from "../providers/stripe.js"; +import { getOrg } from "../seed/loader.js"; +import { newId } from "../lib/ids.js"; +import { actionStore } from "../db/actions.js"; +import { setEntitlement } from "../lib/entitlements.js"; + +export const billingRoute = new Hono(); + +const PaymentIntentBodySchema = z.object({ + sku: z.literal(COMPLIANCE_PACK.id), +}); + +billingRoute.get("/products", (c) => { + const orgId = c.get("orgId"); + const org = getOrg(orgId); + return c.json({ + data: [ + { + id: COMPLIANCE_PACK.id, + name: COMPLIANCE_PACK.name, + description: COMPLIANCE_PACK.description, + priceUsdCents: COMPLIANCE_PACK.amountUsdCents, + currency: COMPLIANCE_PACK.currency, + billing: "one-time", + features: [ + "Evidence Bundles", + "Auditor portal access", + "Vanta / Drata push", + "Signed PDF exports", + ], + }, + ], + orgEntitlements: { + compliancePack: org?.entitlements.compliancePack ?? false, + }, + }); +}); + +billingRoute.post("/payment-intents", async (c) => { + const orgId = c.get("orgId"); + const org = getOrg(orgId); + if (!org) { + throw new ApiError(ErrorCodes.NotFound, `Org ${orgId} not found`); + } + if (org.entitlements.compliancePack) { + throw new ApiError( + ErrorCodes.Conflict, + "Org already has the Compliance Pack entitlement", + ); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + throw new ApiError(ErrorCodes.ValidationFailed, "Body must be JSON"); + } + const parsed = PaymentIntentBodySchema.safeParse(body); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + throw new ApiError( + ErrorCodes.ValidationFailed, + issue ? `${issue.path.join(".")}: ${issue.message}` : "Invalid body", + { issues: parsed.error.issues }, + ); + } + + await ensureStripeProducts(); + const e = env(); + if (!e.STRIPE_PUBLISHABLE_KEY) { + throw new ApiError( + ErrorCodes.Internal, + "STRIPE_PUBLISHABLE_KEY not configured on server", + ); + } + + try { + const intent = await stripe().paymentIntents.create({ + amount: COMPLIANCE_PACK.amountUsdCents, + currency: COMPLIANCE_PACK.currency, + automatic_payment_methods: { enabled: true }, + metadata: { orgId, sku: COMPLIANCE_PACK.id }, + }); + return c.json({ + paymentIntentId: intent.id, + clientSecret: intent.client_secret, + amountUsdCents: COMPLIANCE_PACK.amountUsdCents, + currency: COMPLIANCE_PACK.currency, + publishableKey: e.STRIPE_PUBLISHABLE_KEY, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "stripe error"; + throw new ApiError(ErrorCodes.UpstreamFailed, message, { + provider: "stripe", + }); + } +}); + +// Runbook F5 fallback: when Stripe Elements fails on stage, the modal's hidden +// Shift+Enter handler hits this endpoint to simulate the same effect as a real +// payment_intent.succeeded webhook. Disabled in production. +billingRoute.post("/simulate-success", async (c) => { + if (env().NODE_ENV === "production") { + throw new ApiError(ErrorCodes.NotFound, "Not available in production"); + } + const orgId = c.get("orgId"); + const org = getOrg(orgId); + if (!org) throw new ApiError(ErrorCodes.NotFound, `Org ${orgId} not found`); + + const fakeIntentId = `pi_dev_${Date.now()}`; + setEntitlement(orgId, "compliancePack", true); + await actionStore().insert({ + id: newId("action"), + orgId, + kind: "payment", + target: "stripe", + payload: { + paymentIntentId: fakeIntentId, + amount: COMPLIANCE_PACK.amountUsdCents, + currency: COMPLIANCE_PACK.currency, + sku: COMPLIANCE_PACK.id, + simulated: true, + }, + firedAt: new Date().toISOString(), + status: "delivered", + externalId: fakeIntentId, + }); + return c.json({ ok: true, paymentIntentId: fakeIntentId }); +}); diff --git a/apps/api/src/routes/webhooks-stripe.ts b/apps/api/src/routes/webhooks-stripe.ts new file mode 100644 index 0000000..a9e8be9 --- /dev/null +++ b/apps/api/src/routes/webhooks-stripe.ts @@ -0,0 +1,115 @@ +import { Hono } from "hono"; +import Stripe from "stripe"; +import { ErrorCodes } from "@redline/shared"; +import { ApiError } from "../lib/errors.js"; +import { env } from "../env.js"; +import { stripe } from "../providers/stripe.js"; +import { newId } from "../lib/ids.js"; +import { actionStore } from "../db/actions.js"; +import { setEntitlement } from "../lib/entitlements.js"; + +// Stripe inbound webhook. The route is mounted at /webhooks/stripe and is +// excluded from bearer auth in src/auth.ts. Signature verification happens +// against the raw request body (handoff/API §07). + +export const stripeWebhookRoute = new Hono(); + +stripeWebhookRoute.post("/", async (c) => { + const e = env(); + if (!e.STRIPE_WEBHOOK_SECRET) { + throw new ApiError( + ErrorCodes.Internal, + "STRIPE_WEBHOOK_SECRET not configured", + ); + } + const signature = c.req.header("stripe-signature"); + if (!signature) { + throw new ApiError( + ErrorCodes.ValidationFailed, + "Missing Stripe-Signature header", + ); + } + const rawBody = await c.req.raw.text(); + + let event: Stripe.Event; + try { + event = stripe().webhooks.constructEvent( + rawBody, + signature, + e.STRIPE_WEBHOOK_SECRET, + ); + } catch (err) { + const message = + err instanceof Error ? err.message : "signature verification failed"; + throw new ApiError(ErrorCodes.ValidationFailed, message, { + provider: "stripe", + }); + } + + // Always return 200 quickly (handoff/API §07: "Stripe retries on non-2xx + // for 3 days. Process async if heavy work."). Our work is light; we do it + // inline. + switch (event.type) { + case "payment_intent.succeeded": { + await handleSucceeded(event.data.object); + break; + } + case "payment_intent.payment_failed": { + await handleFailed(event.data.object); + break; + } + default: + // Unknown / out-of-scope events still get a 200 so Stripe doesn't retry. + break; + } + + return c.json({ received: true }); +}); + +async function handleSucceeded(intent: Stripe.PaymentIntent): Promise { + const orgId = intent.metadata?.orgId; + if (!orgId) { + // Without an orgId we can't scope the entitlement. Log and accept. + // eslint-disable-next-line no-console + console.warn(`Stripe payment_intent.succeeded missing metadata.orgId`, intent.id); + return; + } + setEntitlement(orgId, "compliancePack", true); + + await actionStore().insert({ + id: newId("action"), + orgId, + kind: "payment", + target: "stripe", + payload: { + paymentIntentId: intent.id, + amount: intent.amount, + currency: intent.currency, + sku: intent.metadata?.sku ?? null, + }, + firedAt: new Date().toISOString(), + status: "delivered", + externalId: intent.id, + }); +} + +async function handleFailed(intent: Stripe.PaymentIntent): Promise { + const orgId = intent.metadata?.orgId ?? ""; + await actionStore().insert({ + id: newId("action"), + orgId, + kind: "payment", + target: "stripe", + payload: { + paymentIntentId: intent.id, + amount: intent.amount, + currency: intent.currency, + sku: intent.metadata?.sku ?? null, + lastPaymentError: intent.last_payment_error?.message ?? null, + }, + firedAt: new Date().toISOString(), + status: "failed", + externalId: intent.id, + error: intent.last_payment_error?.message ?? "payment_failed", + }); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index b517ffb..1ae3fce 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -6,6 +6,8 @@ import { bearerAuth } from "./auth.js"; import { ErrorCodes } from "@redline/shared"; import { vendorsRoute } from "./routes/vendors.js"; import { streamRoute } from "./routes/stream.js"; +import { billingRoute } from "./routes/billing.js"; +import { stripeWebhookRoute } from "./routes/webhooks-stripe.js"; export function buildApp(): Hono { const app = new Hono(); @@ -26,6 +28,8 @@ export function buildApp(): Hono { app.route("/v1/vendors", vendorsRoute); app.route("/v1/stream", streamRoute); + app.route("/v1/billing", billingRoute); + app.route("/webhooks/stripe", stripeWebhookRoute); app.notFound((c) => c.json( diff --git a/apps/web/package.json b/apps/web/package.json index 10fc144..0eaa405 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,8 @@ "dependencies": { "@hookform/resolvers": "^3.9.0", "@redline/shared": "workspace:*", + "@stripe/react-stripe-js": "^3.0.0", + "@stripe/stripe-js": "^4.10.0", "react": "^18.3.0", "react-dom": "^18.3.0", "react-hook-form": "^7.53.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 327332b..f9cae55 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,13 +1,24 @@ +import { useState } from "react"; import { Onboard } from "./screens/Onboard.js"; +import { StripeModal } from "./screens/StripeModal.js"; export function App(): JSX.Element { + const [showUpgrade, setShowUpgrade] = useState(false); return (
Redline Add Vendor +
+ {showUpgrade && setShowUpgrade(false)} />}
); } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 3950b1a..37e3dac 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -51,8 +51,7 @@ async function request( return json as T; } -// Issue #2 — Add Vendor. #3 will add createPaymentIntent and getBillingProducts -// below this export. +// Issue #2 — Add Vendor. export function createVendor( body: VendorCreateBody, ): Promise { @@ -61,3 +60,44 @@ export function createVendor( body: JSON.stringify(body), }); } + +// Issue #3 — Billing. + +export interface BillingProduct { + id: string; + name: string; + description: string; + priceUsdCents: number; + currency: string; + billing: "one-time" | "subscription"; + features: string[]; +} + +export interface BillingProductsResponse { + data: BillingProduct[]; + orgEntitlements: { compliancePack: boolean }; +} + +export interface PaymentIntentResponse { + paymentIntentId: string; + clientSecret: string; + amountUsdCents: number; + currency: string; + publishableKey: string; +} + +export function getBillingProducts(): Promise { + return request("/v1/billing/products"); +} + +export function createPaymentIntent(sku: string): Promise { + return request("/v1/billing/payment-intents", { + method: "POST", + body: JSON.stringify({ sku }), + }); +} + +// Runbook F5 — dev-only fallback path. Server returns 404 in production. +export function simulateSuccess(): Promise<{ ok: true; paymentIntentId: string }> { + return request("/v1/billing/simulate-success", { method: "POST" }); +} diff --git a/apps/web/src/lib/stream.ts b/apps/web/src/lib/stream.ts index e241a34..52fe7a5 100644 --- a/apps/web/src/lib/stream.ts +++ b/apps/web/src/lib/stream.ts @@ -3,6 +3,7 @@ import type { RunStageEvent, RunCompletedEvent, SchedulerTickEvent, + EntitlementsChangedEvent, } from "@redline/shared"; import { DEMO_BEARER_TOKEN } from "./api.js"; @@ -113,3 +114,42 @@ export function useFirstScan( return state; } + +// Issue #3 — subscribe to entitlement changes. The Stripe modal flips to its +// success state when this fires with compliancePack:true. + +export interface UseEntitlementsOptions { + active: boolean; + eventSourceFactory?: (url: string) => EventSource; +} + +export function useEntitlements( + options: UseEntitlementsOptions, +): EntitlementsChangedEvent | undefined { + const [latest, setLatest] = useState(); + const factoryRef = useRef(options.eventSourceFactory); + factoryRef.current = options.eventSourceFactory; + + useEffect(() => { + if (!options.active) return; + const url = `/v1/stream?token=${encodeURIComponent(DEMO_BEARER_TOKEN)}`; + const factory = + factoryRef.current ?? ((u: string) => new EventSource(u)); + const es = factory(url); + const handle = (e: MessageEvent) => { + try { + const data = JSON.parse(e.data) as EntitlementsChangedEvent; + setLatest(data); + } catch { + // ignore malformed + } + }; + es.addEventListener("org.entitlements.changed", handle as EventListener); + return () => { + es.removeEventListener("org.entitlements.changed", handle as EventListener); + es.close(); + }; + }, [options.active]); + + return latest; +} diff --git a/apps/web/src/screens/StripeModal.tsx b/apps/web/src/screens/StripeModal.tsx new file mode 100644 index 0000000..87ff3da --- /dev/null +++ b/apps/web/src/screens/StripeModal.tsx @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { loadStripe, type Stripe, type StripeElementsOptions } from "@stripe/stripe-js"; +import { + Elements, + PaymentElement, + useElements, + useStripe, +} from "@stripe/react-stripe-js"; +import { + ApiError, + createPaymentIntent, + getBillingProducts, + simulateSuccess, + type BillingProduct, + type PaymentIntentResponse, +} from "../lib/api.js"; +import { useEntitlements } from "../lib/stream.js"; + +// Issue #3 · Stripe modal. States: idle → loading → ready (Elements mounted) +// → processing → success (driven by SSE org.entitlements.changed) → failure +// (with retry). Hidden Shift+Enter handler runs the Runbook F5 fallback. + +type ModalStatus = + | "loading-product" + | "ready-to-pay" + | "creating-intent" + | "elements-ready" + | "processing" + | "success" + | "failure"; + +interface StripeModalProps { + onClose: () => void; +} + +export function StripeModal({ onClose }: StripeModalProps): JSX.Element { + const [status, setStatus] = useState("loading-product"); + const [product, setProduct] = useState(); + const [intent, setIntent] = useState(); + const [stripePromise, setStripePromise] = useState | undefined>(); + const [errorMessage, setErrorMessage] = useState(); + const [alreadyEntitled, setAlreadyEntitled] = useState(false); + + // Listen for the entitlement flip. Active once we've started the payment flow. + const entitlements = useEntitlements({ + active: status !== "loading-product" && status !== "success", + }); + + useEffect(() => { + if (entitlements?.compliancePack) { + setStatus("success"); + } + }, [entitlements]); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const resp = await getBillingProducts(); + if (cancelled) return; + const first = resp.data[0]; + if (!first) { + setErrorMessage("No products available"); + setStatus("failure"); + return; + } + setProduct(first); + if (resp.orgEntitlements.compliancePack) { + setAlreadyEntitled(true); + setStatus("success"); + } else { + setStatus("ready-to-pay"); + } + } catch (err) { + if (cancelled) return; + setErrorMessage(err instanceof Error ? err.message : "load failed"); + setStatus("failure"); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const beginPayment = useCallback(async () => { + if (!product) return; + setStatus("creating-intent"); + setErrorMessage(undefined); + try { + const created = await createPaymentIntent(product.id); + setIntent(created); + setStripePromise(loadStripe(created.publishableKey)); + setStatus("elements-ready"); + } catch (err) { + if (err instanceof ApiError && err.code === "conflict") { + setAlreadyEntitled(true); + setStatus("success"); + return; + } + setErrorMessage(err instanceof Error ? err.message : "failed to start checkout"); + setStatus("failure"); + } + }, [product]); + + const runFallback = useCallback(async () => { + setStatus("processing"); + setErrorMessage(undefined); + try { + await simulateSuccess(); + // SSE handler will flip status to success. + } catch (err) { + setErrorMessage(err instanceof Error ? err.message : "fallback failed"); + setStatus("failure"); + } + }, []); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.shiftKey && e.key === "Enter") { + e.preventDefault(); + void runFallback(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [runFallback]); + + return ( +
+
+
+

+ {product?.name ?? "Compliance Pack"} +

+ +
+ + {status === "loading-product" &&

Loading product…

} + + {alreadyEntitled && ( +
+ This org already has the Compliance Pack. +
+ )} + + {product && status === "ready-to-pay" && !alreadyEntitled && ( + <> +

+ ${(product.priceUsdCents / 100).toLocaleString()}{" "} + one-time +

+
    + {product.features.map((f) => ( +
  • {f}
  • + ))} +
+ + + )} + + {status === "creating-intent" && ( +

Creating payment intent…

+ )} + + {(status === "elements-ready" || status === "processing") && intent && stripePromise && ( + + setStatus("processing")} + onError={(msg) => { + setErrorMessage(msg); + setStatus("failure"); + }} + disabled={status === "processing"} + /> + + )} + + {status === "success" && ( +
+ Compliance Pack unlocked. Evidence bundles, auditor + portal, and Vanta/Drata push are now available. +
+ )} + + {status === "failure" && ( + <> +
+ {errorMessage ?? "Payment failed"} +
+ + + )} + +

+ On-stage fallback: press Shift + Enter to simulate success. +

+
+
+ ); +} + +interface PaymentFormProps { + intent: PaymentIntentResponse; + onProcessing: () => void; + onError: (message: string) => void; + disabled: boolean; +} + +function PaymentForm({ intent, onProcessing, onError, disabled }: PaymentFormProps): JSX.Element { + const stripe = useStripe(); + const elements = useElements(); + const submitting = useRef(false); + + async function submit(): Promise { + if (!stripe || !elements || submitting.current) return; + submitting.current = true; + onProcessing(); + const { error } = await stripe.confirmPayment({ + elements, + clientSecret: intent.clientSecret, + confirmParams: { return_url: window.location.href }, + redirect: "if_required", + }); + submitting.current = false; + if (error) { + onError(error.message ?? "Payment failed"); + } + // On success, we don't flip UI here — we wait for the SSE org.entitlements.changed + // event from the webhook to confirm server-side state. + } + + return ( +
{ + e.preventDefault(); + void submit(); + }} + > + + + + ); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index e1b735d..f77b9a5 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -116,3 +116,44 @@ form select[aria-invalid="true"] { border-color: var(--err); } .kv { display: grid; grid-template-columns: 140px 1fr; gap: 4px 12px; font-size: 13px; } .kv dt { color: var(--muted); } .kv dd { margin: 0; word-break: break-all; } + +/* --- Stripe modal --- */ +.btn--ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--line); +} +.app__upgrade { margin-left: auto; } +.app__header { align-items: center; } + +.modal-backdrop { + position: fixed; inset: 0; + background: rgba(0,0,0,0.55); + display: flex; align-items: center; justify-content: center; + z-index: 50; +} +.modal { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + width: 100%; max-width: 480px; + padding: 24px; +} +.modal__header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; } +.modal__title { margin: 0; font-size: 18px; font-weight: 600; } +.modal__close { + appearance: none; border: none; background: transparent; + color: var(--muted); font-size: 24px; cursor: pointer; line-height: 1; +} +.modal__price { font-size: 28px; font-weight: 600; margin: 12px 0 8px; } +.modal__hint { margin-top: 16px; font-size: 11px; } +.modal__hint kbd { + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: 4px; + padding: 1px 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} +.features { margin: 0 0 16px; padding-left: 20px; color: var(--muted); font-size: 13px; } +.features li { margin: 4px 0; } diff --git a/package.json b/package.json index 2b3abb3..150d952 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "jsdom": "^25.0.0", "react": "^18.3.0", "react-dom": "^18.3.0", + "stripe": "^17.4.0", "tsx": "^4.19.0", "typescript": "^5.5.0", "vitest": "^2.1.0" diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 909b2c4..689df8c 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -117,10 +117,17 @@ export interface RunCompletedEvent { changeReportId?: string; } +export interface EntitlementsChangedEvent { + compliancePack: boolean; + auditorPortal: boolean; + changedAt: Iso8601; +} + export type SseEvent = | { event: "scheduler.tick"; data: SchedulerTickEvent } | { event: "run.stage"; data: RunStageEvent } - | { event: "run.completed"; data: RunCompletedEvent }; + | { event: "run.completed"; data: RunCompletedEvent } + | { event: "org.entitlements.changed"; data: EntitlementsChangedEvent }; // API response shapes used by both server and client. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd147ca..dd6ea2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: react-dom: specifier: ^18.3.0 version: 18.3.1(react@18.3.1) + stripe: + specifier: ^17.4.0 + version: 17.7.0 tsx: specifier: ^4.19.0 version: 4.22.3 @@ -62,6 +65,9 @@ importers: hono: specifier: ^4.6.0 version: 4.12.22 + stripe: + specifier: ^17.4.0 + version: 17.7.0 ulid: specifier: ^2.3.0 version: 2.4.0 @@ -87,6 +93,12 @@ importers: '@redline/shared': specifier: workspace:* version: link:../../packages/shared + '@stripe/react-stripe-js': + specifier: ^3.0.0 + version: 3.10.0(@stripe/stripe-js@4.10.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@stripe/stripe-js': + specifier: ^4.10.0 + version: 4.10.0 react: specifier: ^18.3.0 version: 18.3.1 @@ -705,6 +717,17 @@ packages: cpu: [x64] os: [win32] + '@stripe/react-stripe-js@3.10.0': + resolution: {integrity: sha512-UPqHZwMwDzGSax0ZI7XlxR3tZSpgIiZdk3CiwjbTK978phwR/fFXeAXQcN/h8wTAjR4ZIAzdlI9DbOqJhuJdeg==} + peerDependencies: + '@stripe/stripe-js': '>=1.44.1 <8.0.0' + react: '>=16.8.0 <20.0.0' + react-dom: '>=16.8.0 <20.0.0' + + '@stripe/stripe-js@4.10.0': + resolution: {integrity: sha512-KrMOL+sH69htCIXCaZ4JluJ35bchuCCznyPyrbN8JXSGQfwBI1SuIEMZNwvy8L8ykj29t6sa5BAAiL7fNoLZ8A==} + engines: {node: '>=12.16'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -848,6 +871,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} @@ -1101,6 +1128,14 @@ packages: nwsapi@2.2.23: resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -1122,10 +1157,17 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -1137,6 +1179,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -1177,6 +1222,22 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1194,6 +1255,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + stripe@17.7.0: + resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==} + engines: {node: '>=12.*'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -1768,6 +1833,15 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true + '@stripe/react-stripe-js@3.10.0(@stripe/stripe-js@4.10.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@stripe/stripe-js': 4.10.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@stripe/stripe-js@4.10.0': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -1929,6 +2003,11 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + caniuse-lite@1.0.30001793: {} chai@5.3.3: @@ -2210,6 +2289,10 @@ snapshots: nwsapi@2.2.23: {} + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -2232,8 +2315,18 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + punycode@2.3.1: {} + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -2244,6 +2337,8 @@ snapshots: dependencies: react: 18.3.1 + react-is@16.13.1: {} + react-is@17.0.2: {} react-refresh@0.17.0: {} @@ -2304,6 +2399,34 @@ snapshots: semver@6.3.1: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -2316,6 +2439,11 @@ snapshots: dependencies: min-indent: 1.0.1 + stripe@17.7.0: + dependencies: + '@types/node': 20.19.41 + qs: 6.15.2 + symbol-tree@3.2.4: {} tinybench@2.9.0: {} diff --git a/tests/api/billing.test.ts b/tests/api/billing.test.ts new file mode 100644 index 0000000..c583f48 --- /dev/null +++ b/tests/api/billing.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { resolve } from "node:path"; +import { buildApp } from "../../apps/api/src/server.js"; +import { loadSeeds, getOrg } from "../../apps/api/src/seed/loader.js"; +import { + setStripeInstance, + setStripeState, + resetStripe, + COMPLIANCE_PACK, +} from "../../apps/api/src/providers/stripe.js"; + +const BEARER = "Bearer demo_token_acme_corp_2026"; +const SEED_DIR = resolve(__dirname, "../../seed"); + +function buildFakeStripe(opts: { + paymentIntentResult?: { id: string; client_secret: string }; + throwOnPaymentIntent?: Error; +} = {}) { + return { + paymentIntents: { + create: vi.fn(async () => { + if (opts.throwOnPaymentIntent) throw opts.throwOnPaymentIntent; + return ( + opts.paymentIntentResult ?? { + id: "pi_test_123", + client_secret: "pi_test_123_secret_abc", + } + ); + }), + }, + // Stubbed but unused in these tests because state is pre-seeded. + products: { retrieve: vi.fn(), create: vi.fn() }, + prices: { list: vi.fn(), create: vi.fn() }, + } as unknown as import("stripe").default; +} + +beforeEach(() => { + loadSeeds({ seedDir: SEED_DIR }); + // Ensure org starts without entitlement. + const org = getOrg("org_acme"); + if (org) org.entitlements.compliancePack = false; + resetStripe(); + setStripeState({ productId: "compliance-pack", priceId: "price_test_seeded" }); +}); + +describe("GET /v1/billing/products", () => { + it("returns the Compliance Pack with the org's current entitlement", async () => { + setStripeInstance(buildFakeStripe()); + const resp = await buildApp().request("/v1/billing/products", { + headers: { Authorization: BEARER }, + }); + expect(resp.status).toBe(200); + const body = (await resp.json()) as { + data: Array<{ id: string; priceUsdCents: number; billing: string; features: string[] }>; + orgEntitlements: { compliancePack: boolean }; + }; + expect(body.data).toHaveLength(1); + expect(body.data[0]?.id).toBe("compliance-pack"); + expect(body.data[0]?.priceUsdCents).toBe(99_900); + expect(body.data[0]?.billing).toBe("one-time"); + expect(body.data[0]?.features.length).toBeGreaterThan(0); + expect(body.orgEntitlements.compliancePack).toBe(false); + }); + + it("reflects a flipped entitlement", async () => { + const org = getOrg("org_acme"); + if (org) org.entitlements.compliancePack = true; + setStripeInstance(buildFakeStripe()); + const resp = await buildApp().request("/v1/billing/products", { + headers: { Authorization: BEARER }, + }); + const body = (await resp.json()) as { orgEntitlements: { compliancePack: boolean } }; + expect(body.orgEntitlements.compliancePack).toBe(true); + }); +}); + +describe("POST /v1/billing/payment-intents", () => { + it("creates a PaymentIntent and returns clientSecret + publishableKey", async () => { + setStripeInstance(buildFakeStripe()); + const resp = await buildApp().request("/v1/billing/payment-intents", { + method: "POST", + headers: { Authorization: BEARER, "Content-Type": "application/json" }, + body: JSON.stringify({ sku: COMPLIANCE_PACK.id }), + }); + expect(resp.status).toBe(200); + const body = (await resp.json()) as { + paymentIntentId: string; + clientSecret: string; + publishableKey: string; + amountUsdCents: number; + }; + expect(body.paymentIntentId).toBe("pi_test_123"); + expect(body.clientSecret).toBe("pi_test_123_secret_abc"); + expect(body.amountUsdCents).toBe(99_900); + expect(body.publishableKey).toMatch(/^pk_/); + }); + + it("returns 409 when the org already has the entitlement", async () => { + const org = getOrg("org_acme"); + if (org) org.entitlements.compliancePack = true; + setStripeInstance(buildFakeStripe()); + const resp = await buildApp().request("/v1/billing/payment-intents", { + method: "POST", + headers: { Authorization: BEARER, "Content-Type": "application/json" }, + body: JSON.stringify({ sku: COMPLIANCE_PACK.id }), + }); + expect(resp.status).toBe(409); + const body = (await resp.json()) as { error: { code: string } }; + expect(body.error.code).toBe("conflict"); + }); + + it("returns 400 when the sku is wrong", async () => { + setStripeInstance(buildFakeStripe()); + const resp = await buildApp().request("/v1/billing/payment-intents", { + method: "POST", + headers: { Authorization: BEARER, "Content-Type": "application/json" }, + body: JSON.stringify({ sku: "platinum-pack" }), + }); + expect(resp.status).toBe(400); + const body = (await resp.json()) as { error: { code: string } }; + expect(body.error.code).toBe("validation-failed"); + }); + + it("returns 502 when Stripe errors", async () => { + setStripeInstance( + buildFakeStripe({ throwOnPaymentIntent: new Error("network down") }), + ); + const resp = await buildApp().request("/v1/billing/payment-intents", { + method: "POST", + headers: { Authorization: BEARER, "Content-Type": "application/json" }, + body: JSON.stringify({ sku: COMPLIANCE_PACK.id }), + }); + expect(resp.status).toBe(502); + const body = (await resp.json()) as { error: { code: string } }; + expect(body.error.code).toBe("upstream-failed"); + }); +}); diff --git a/tests/api/stripe-webhook.test.ts b/tests/api/stripe-webhook.test.ts new file mode 100644 index 0000000..6550f63 --- /dev/null +++ b/tests/api/stripe-webhook.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import Stripe from "stripe"; +import { resolve } from "node:path"; +import { buildApp } from "../../apps/api/src/server.js"; +import { loadSeeds, getOrg } from "../../apps/api/src/seed/loader.js"; +import { setActionStore } from "../../apps/api/src/db/actions.js"; +import { bus, type BusEnvelope } from "../../apps/api/src/lib/bus.js"; +import { + resetStripe, + setStripeInstance, +} from "../../apps/api/src/providers/stripe.js"; +import { InMemoryActionStore } from "../helpers/in-memory-action-store.js"; + +const SEED_DIR = resolve(__dirname, "../../seed"); +const WHSEC = process.env.STRIPE_WEBHOOK_SECRET!; + +// We use the real Stripe SDK for signature generation/verification. It needs +// a "secret key" at instantiation but never hits the network in this test — +// `webhooks.constructEvent` and `webhooks.generateTestHeaderString` are +// pure-crypto helpers. +const realStripe = new Stripe("sk_test_dummy_for_tests", { + apiVersion: "2025-02-24.acacia", +}); + +function signedRequest( + payload: Record, + opts: { tamperSignature?: boolean } = {}, +): { body: string; signature: string } { + const body = JSON.stringify(payload); + let signature = realStripe.webhooks.generateTestHeaderString({ + payload: body, + secret: WHSEC, + }); + if (opts.tamperSignature) { + signature = signature.replace(/,v1=[a-f0-9]+/, ",v1=deadbeef"); + } + return { body, signature }; +} + +function paymentIntentSucceededEvent( + intent: Partial & { id: string; metadata: Record }, +): Record { + return { + id: "evt_test", + object: "event", + type: "payment_intent.succeeded", + api_version: "2025-02-24.acacia", + data: { + object: { + id: intent.id, + object: "payment_intent", + amount: 99900, + currency: "usd", + status: "succeeded", + metadata: intent.metadata, + }, + }, + }; +} + +function paymentIntentFailedEvent( + intent: { id: string; metadata: Record; errorMessage?: string }, +): Record { + return { + id: "evt_test_failed", + object: "event", + type: "payment_intent.payment_failed", + api_version: "2025-02-24.acacia", + data: { + object: { + id: intent.id, + object: "payment_intent", + amount: 99900, + currency: "usd", + status: "requires_payment_method", + metadata: intent.metadata, + last_payment_error: { message: intent.errorMessage ?? "card declined" }, + }, + }, + }; +} + +let actions: InMemoryActionStore; +let captured: BusEnvelope[]; +let unsub: () => void; + +beforeEach(() => { + loadSeeds({ seedDir: SEED_DIR }); + const org = getOrg("org_acme"); + if (org) org.entitlements.compliancePack = false; + + actions = new InMemoryActionStore(); + setActionStore(actions); + resetStripe(); + setStripeInstance(realStripe); + + captured = []; + unsub = bus.subscribe("org_acme", (e) => captured.push(e)); + return () => { + unsub(); + }; +}); + +describe("POST /webhooks/stripe", () => { + it("rejects unsigned requests with 400", async () => { + const resp = await buildApp().request("/webhooks/stripe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(resp.status).toBe(400); + }); + + it("rejects forged signatures with 400", async () => { + const { body, signature } = signedRequest( + paymentIntentSucceededEvent({ id: "pi_test", metadata: { orgId: "org_acme" } }), + { tamperSignature: true }, + ); + const resp = await buildApp().request("/webhooks/stripe", { + method: "POST", + headers: { "Content-Type": "application/json", "Stripe-Signature": signature }, + body, + }); + expect(resp.status).toBe(400); + }); + + it("on payment_intent.succeeded: flips entitlement, persists Action(payment), emits SSE", async () => { + const { body, signature } = signedRequest( + paymentIntentSucceededEvent({ + id: "pi_success", + metadata: { orgId: "org_acme", sku: "compliance-pack" }, + }), + ); + + const resp = await buildApp().request("/webhooks/stripe", { + method: "POST", + headers: { "Content-Type": "application/json", "Stripe-Signature": signature }, + body, + }); + expect(resp.status).toBe(200); + const env = (await resp.json()) as { received: boolean }; + expect(env.received).toBe(true); + + // Entitlement flipped. + expect(getOrg("org_acme")?.entitlements.compliancePack).toBe(true); + + // Action row persisted. + expect(actions.actions).toHaveLength(1); + expect(actions.actions[0]?.kind).toBe("payment"); + expect(actions.actions[0]?.status).toBe("delivered"); + expect(actions.actions[0]?.externalId).toBe("pi_success"); + + // SSE emitted. + const events = captured.map((e) => e.payload.event); + expect(events).toContain("org.entitlements.changed"); + }); + + it("on payment_intent.payment_failed: writes failed Action, leaves entitlement alone, no SSE", async () => { + const { body, signature } = signedRequest( + paymentIntentFailedEvent({ + id: "pi_failure", + metadata: { orgId: "org_acme" }, + errorMessage: "Your card was declined.", + }), + ); + const resp = await buildApp().request("/webhooks/stripe", { + method: "POST", + headers: { "Content-Type": "application/json", "Stripe-Signature": signature }, + body, + }); + expect(resp.status).toBe(200); + expect(getOrg("org_acme")?.entitlements.compliancePack).toBe(false); + expect(actions.actions).toHaveLength(1); + expect(actions.actions[0]?.status).toBe("failed"); + expect(actions.actions[0]?.error).toBe("Your card was declined."); + expect(captured.map((e) => e.payload.event)).not.toContain( + "org.entitlements.changed", + ); + }); + + it("acks unknown event types with 200 (no entitlement change, no action)", async () => { + const { body, signature } = signedRequest({ + id: "evt_unknown", + object: "event", + type: "charge.refunded", + api_version: "2025-02-24.acacia", + data: { object: { id: "ch_test" } }, + }); + const resp = await buildApp().request("/webhooks/stripe", { + method: "POST", + headers: { "Content-Type": "application/json", "Stripe-Signature": signature }, + body, + }); + expect(resp.status).toBe(200); + expect(actions.actions).toHaveLength(0); + expect(getOrg("org_acme")?.entitlements.compliancePack).toBe(false); + }); +}); diff --git a/tests/helpers/in-memory-action-store.ts b/tests/helpers/in-memory-action-store.ts new file mode 100644 index 0000000..3878a6a --- /dev/null +++ b/tests/helpers/in-memory-action-store.ts @@ -0,0 +1,13 @@ +import type { + ActionRecord, + ActionStore, +} from "../../apps/api/src/db/actions.js"; + +export class InMemoryActionStore implements ActionStore { + public readonly actions: ActionRecord[] = []; + + // eslint-disable-next-line @typescript-eslint/require-await + async insert(action: ActionRecord): Promise { + this.actions.push({ ...action }); + } +} diff --git a/tests/setup.ts b/tests/setup.ts index 980ccfc..793532b 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -2,10 +2,21 @@ import "@testing-library/jest-dom/vitest"; import { afterEach, beforeEach } from "vitest"; import { cleanup } from "@testing-library/react"; +// Set env at module-load time so the lazy env() cache in apps/api picks these +// up on first read. +process.env.CLICKHOUSE_URL ??= "https://example.test:8443"; +process.env.CLICKHOUSE_USER ??= "test"; +process.env.CLICKHOUSE_PASSWORD ??= "test"; +process.env.NODE_ENV = "test"; +// Vite sets BASE_URL="/" at vitest runtime; force a valid URL so env() passes. +process.env.BASE_URL = "http://localhost:8787"; +process.env.PORT ??= "8787"; +process.env.STRIPE_SECRET_KEY ??= "sk_test_dummy_for_tests"; +process.env.STRIPE_PUBLISHABLE_KEY ??= "pk_test_dummy_for_tests"; +process.env.STRIPE_WEBHOOK_SECRET ??= "whsec_test_signing_secret_used_by_unit_tests"; + beforeEach(() => { - process.env.CLICKHOUSE_URL = "https://example.test:8443"; - process.env.CLICKHOUSE_USER = "test"; - process.env.CLICKHOUSE_PASSWORD = "test"; + // Same — re-assert in case a test mutated process.env. process.env.NODE_ENV = "test"; }); diff --git a/tests/web/stripe-modal.test.tsx b/tests/web/stripe-modal.test.tsx new file mode 100644 index 0000000..482e981 --- /dev/null +++ b/tests/web/stripe-modal.test.tsx @@ -0,0 +1,263 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// Mock @stripe/stripe-js so loadStripe never tries to talk to js.stripe.com. +vi.mock("@stripe/stripe-js", () => ({ + loadStripe: vi.fn(async () => ({})), +})); + +// Mock @stripe/react-stripe-js with createElement (no JSX) so the hoisted +// factory has no transform-order surprises. The real Elements is a Context +// Provider that doesn't wrap its children in a div, which made the mock +// detection unreliable when written as JSX. +const confirmPayment = vi.fn(async () => ({ error: undefined })); +vi.mock("@stripe/react-stripe-js", () => { + return { + Elements: ({ children }: { children: React.ReactNode }) => + React.createElement("div", { "data-testid": "elements" }, children), + PaymentElement: () => + React.createElement("div", { "data-testid": "payment-element" }), + useStripe: () => ({ confirmPayment }), + useElements: () => ({}), + }; +}); + +import { StripeModal } from "../../apps/web/src/screens/StripeModal.js"; + +class MockEventSource { + static instances: MockEventSource[] = []; + readonly url: string; + public readyState = 1; + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSED = 2; + private listeners = new Map void>>(); + constructor(url: string) { + this.url = url; + MockEventSource.instances.push(this); + } + addEventListener(name: string, fn: (e: MessageEvent) => void): void { + const set = this.listeners.get(name) ?? new Set(); + set.add(fn); + this.listeners.set(name, set); + } + removeEventListener(name: string, fn: (e: MessageEvent) => void): void { + this.listeners.get(name)?.delete(fn); + } + close(): void { + this.readyState = MockEventSource.CLOSED; + } + dispatch(event: string, data: unknown): void { + const fns = this.listeners.get(event); + if (!fns) return; + const ev = new MessageEvent(event, { data: JSON.stringify(data) }); + for (const fn of fns) fn(ev); + } +} + +function mockFetch(handlers: Record Response>) { + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const path = new URL(url, "http://test").pathname; + const h = handlers[path]; + if (!h) throw new Error(`No mock for ${path}`); + return h(); + }) as typeof fetch; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(() => { + // @ts-expect-error — inject global EventSource for jsdom + globalThis.EventSource = MockEventSource; + MockEventSource.instances = []; + confirmPayment.mockClear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("StripeModal", () => { + it("loads the product and shows the upgrade CTA", async () => { + mockFetch({ + "/v1/billing/products": () => + jsonResponse(200, { + data: [ + { + id: "compliance-pack", + name: "Compliance Pack", + description: "test", + priceUsdCents: 99900, + currency: "usd", + billing: "one-time", + features: ["A", "B"], + }, + ], + orgEntitlements: { compliancePack: false }, + }), + }); + render( undefined} />); + expect(await screen.findByTestId("begin-payment")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /Compliance Pack/i })).toBeInTheDocument(); + }); + + it("short-circuits to success when the org is already entitled", async () => { + mockFetch({ + "/v1/billing/products": () => + jsonResponse(200, { + data: [ + { + id: "compliance-pack", + name: "Compliance Pack", + description: "", + priceUsdCents: 99900, + currency: "usd", + billing: "one-time", + features: [], + }, + ], + orgEntitlements: { compliancePack: true }, + }), + }); + render( undefined} />); + await waitFor(() => { + expect(screen.getByText(/already has the Compliance Pack/i)).toBeInTheDocument(); + }); + }); + + it("creates a PaymentIntent, mounts Elements, and flips to success on entitlement SSE", async () => { + const user = userEvent.setup(); + mockFetch({ + "/v1/billing/products": () => + jsonResponse(200, { + data: [ + { + id: "compliance-pack", + name: "Compliance Pack", + description: "", + priceUsdCents: 99900, + currency: "usd", + billing: "one-time", + features: [], + }, + ], + orgEntitlements: { compliancePack: false }, + }), + "/v1/billing/payment-intents": () => + jsonResponse(200, { + paymentIntentId: "pi_test", + clientSecret: "pi_test_secret_abc", + amountUsdCents: 99900, + currency: "usd", + publishableKey: "pk_test_x", + }), + }); + + render( undefined} />); + await user.click(await screen.findByTestId("begin-payment")); + + expect(await screen.findByTestId("confirm-payment")).toBeInTheDocument(); + + // Push the entitlement event via the SSE mock. + const es = MockEventSource.instances[0]; + expect(es).toBeDefined(); + es!.dispatch("org.entitlements.changed", { + compliancePack: true, + auditorPortal: false, + changedAt: new Date().toISOString(), + }); + + await waitFor(() => { + expect(screen.getByText(/Compliance Pack unlocked/i)).toBeInTheDocument(); + }); + }); + + it("Shift+Enter triggers the simulate-success fallback", async () => { + const user = userEvent.setup(); + const simulateCall = vi.fn(() => + jsonResponse(200, { ok: true, paymentIntentId: "pi_dev_fake" }), + ); + mockFetch({ + "/v1/billing/products": () => + jsonResponse(200, { + data: [ + { + id: "compliance-pack", + name: "Compliance Pack", + description: "", + priceUsdCents: 99900, + currency: "usd", + billing: "one-time", + features: [], + }, + ], + orgEntitlements: { compliancePack: false }, + }), + "/v1/billing/simulate-success": simulateCall, + }); + + render( undefined} />); + await screen.findByTestId("begin-payment"); + + // Press Shift+Enter at the document level. + await user.keyboard("{Shift>}{Enter}{/Shift}"); + + await waitFor(() => { + expect(simulateCall).toHaveBeenCalledTimes(1); + }); + }); + + it("renders a Retry button and re-tries the PaymentIntent when payment-intent creation fails", async () => { + const user = userEvent.setup(); + let intentCallCount = 0; + mockFetch({ + "/v1/billing/products": () => + jsonResponse(200, { + data: [ + { + id: "compliance-pack", + name: "Compliance Pack", + description: "", + priceUsdCents: 99900, + currency: "usd", + billing: "one-time", + features: [], + }, + ], + orgEntitlements: { compliancePack: false }, + }), + "/v1/billing/payment-intents": () => { + intentCallCount += 1; + if (intentCallCount === 1) { + return jsonResponse(502, { + error: { code: "upstream-failed", message: "stripe is down" }, + }); + } + return jsonResponse(200, { + paymentIntentId: "pi_test_2", + clientSecret: "pi_test_2_secret", + amountUsdCents: 99900, + currency: "usd", + publishableKey: "pk_test_x", + }); + }, + }); + + render( undefined} />); + await user.click(await screen.findByTestId("begin-payment")); + + const retry = await screen.findByTestId("retry"); + await user.click(retry); + + expect(await screen.findByTestId("confirm-payment")).toBeInTheDocument(); + expect(intentCallCount).toBe(2); + }); +});