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
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
79 changes: 79 additions & 0 deletions apps/api/src/db/actions.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
firedAt: string; // ISO 8601
status: ActionStatus;
externalId?: string | undefined;
error?: string | undefined;
}

export interface ActionStore {
insert(action: ActionRecord): Promise<void>;
}

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<void> {
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;
}
3 changes: 3 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof EnvSchema>;
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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
Expand Down
32 changes: 32 additions & 0 deletions apps/api/src/lib/entitlements.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
102 changes: 102 additions & 0 deletions apps/api/src/providers/stripe.ts
Original file line number Diff line number Diff line change
@@ -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<StripeState> {
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;
}
Loading