Files
zeekay 9594b6dcc9 chore: rip stripe → @hanzoai/commerce + @hanzoai/pay (no stripe period)
Per CTO directive: zero Stripe across all our repos. Migration mirrors
hanzoai/gui@8d05cf6fb3.

- lib/commerce.ts: new hand-rolled Hanzo Commerce REST client + minimal
  CommerceTypes namespace covering only what the dataroom touches
  (Subscription, Invoice, Customer, CheckoutSession, PortalSession,
  Coupon, Event, Checkout.Session). HMAC-SHA256 webhook signature
  verification matches the gui pattern.
- ee/stripe/index.ts: now a thin shim — `stripeInstance(...)` returns
  the Commerce client; `cancelSubscription(...)` delegates to it.
- ee/stripe/client.ts: Stripe.js loadStripe + redirectToCheckout
  replaced with a redirect helper that bounces the browser to
  pay.hanzo.ai (@hanzoai/pay). No more card UI in the dataroom.
- ee/stripe/utils.ts: drop `import Stripe from "stripe"`; use
  CommerceTypes.Subscription. Stamped TODO(stripe-rip) on the PLANS
  const — the Stripe price IDs embedded there still need re-keying
  to Commerce plan IDs (5 plans × monthly+yearly × test+prod × new+old
  account; coordinate with Commerce admin).
- ee/stripe/webhooks/{checkout-session-completed,customer-subscription-
  {updated,deleted},invoice-upcoming}.ts: drop stripe SDK imports;
  rebind types to CommerceTypes.
- ee/features/security/lib/fraud-prevention.ts: addEmailToStripeRadar
  becomes a no-op with TODO(stripe-rip). Commerce does fraud at the
  gateway and does not yet expose a Radar-equivalent endpoint; the
  Edge Config blocklist remains the effective block surface.
- ee/features/billing/cancellation/api/*.ts: error message
  "No Stripe customer ID" -> "No billing customer ID"; the local
  `stripe` variable is now a Commerce instance via stripeInstance().
- pages/api/stripe/webhook.ts + webhook-old.ts: DELETED. Commerce
  owns webhook termination; the new dispatcher is at
  pages/api/commerce/webhook.ts and reuses the existing per-event
  business-logic handlers in ee/stripe/webhooks/.
- pages/api/commerce/webhook.ts: NEW. Accepts hanzo-commerce-signature
  (falls back to stripe-signature header so a side-by-side rollout
  works), verifies HMAC with HANZO_COMMERCE_WEBHOOK_SECRET, dispatches
  to the same handlers as before.
- package.json: removed `stripe` (^16.12.0), `@stripe/stripe-js`
  (^4.10.0), and the `stripe:webhook` pkgx script. No new deps — the
  Commerce REST client is hand-rolled per spec.

TODO(stripe-rip) markers flag the remaining deep work:
- PLANS const price IDs need re-keying to Commerce plan IDs
- ee/stripe/ directory should be renamed to ee/commerce/
- Local `stripeInstance` identifier should be renamed `commerce`
- Subscription.pause_collection / proration_behavior contract needs
  to land in the Commerce OpenAPI spec
- Commerce fraud-blocklist API needs to ship to replace the Radar
  call we just stubbed out

`tsc --noEmit` shows zero new errors in ee/, lib/commerce.ts,
pages/api/commerce/, components/billing/. Pre-existing TS errors in
redis-job-store, lib/auth, app routes are unchanged.
2026-05-28 02:03:38 -07:00

355 lines
12 KiB
TypeScript

/**
* Hanzo Commerce REST client and minimal type surface that replaces the
* Stripe SDK across the dataroom codebase.
*
* Talks to commerce.hanzo.ai/api/v1/billing. The hand-rolled client exposes
* only the surface the dataroom uses (subscriptions, checkout, billing
* portal, invoices, coupons, customers, and webhook signature verification);
* the full Hanzo Commerce OpenAPI surface lives at
* ~/work/hanzo/commerce/api/openapi.yaml.
*
* Auth: server-side API key (HANZO_COMMERCE_API_KEY) — org is resolved from
* the JWT `owner` claim or X-IAM-Org header by commerce itself.
*
* Webhook events: commerce emits the same logical event names that Stripe
* did, so the existing dispatch in pages/api/commerce/webhook.ts keeps the
* `checkout.session.completed | customer.subscription.{updated,deleted} |
* invoice.upcoming | payment_intent.payment_failed` taxonomy. Payload shape
* mirrors Stripe closely; the `CommerceTypes` namespace here is the minimal
* subset the dataroom touches.
*/
import { createHmac, timingSafeEqual } from "node:crypto";
const COMMERCE_URL =
process.env.HANZO_COMMERCE_URL ?? "https://commerce.hanzo.ai/api/v1/billing";
const COMMERCE_API_KEY = process.env.HANZO_COMMERCE_API_KEY ?? "";
const COMMERCE_API_KEY_OLD = process.env.HANZO_COMMERCE_API_KEY_OLD ?? "";
const COMMERCE_WEBHOOK_SECRET =
process.env.HANZO_COMMERCE_WEBHOOK_SECRET ?? "";
// ---------------------------------------------------------------------------
// Type surface — mirrors enough of Stripe's shape that the existing dataroom
// call sites compile unchanged. These types intentionally use the legacy
// snake_case field names (current_period_start, amount_paid, ...) so we
// avoid a cascading rename across ~30 server files.
// TODO(stripe-rip): once every caller goes through `commerce()`, flatten to
// camelCase and drop the legacy aliases.
// ---------------------------------------------------------------------------
export namespace CommerceTypes {
export interface Price {
id: string;
product?: string;
unit_amount?: number | null;
recurring?: {
interval?: "month" | "year";
interval_count?: number;
usage_type?: "metered" | "licensed";
} | null;
}
export interface SubscriptionItem {
id: string;
price: Price;
quantity?: number | null;
}
export interface Coupon {
id: string;
percent_off?: number | null;
amount_off?: number | null;
duration: "once" | "repeating" | "forever";
duration_in_months?: number | null;
valid: boolean;
}
export interface Discount {
coupon: Coupon;
end?: number | null;
}
export interface Subscription {
id: string;
customer: string | { id: string };
status: string;
items: { data: SubscriptionItem[] };
current_period_start: number;
current_period_end: number;
cancel_at_period_end: boolean;
default_payment_method?: string | null;
discount?: Discount | null;
pause_collection?: { behavior: string; resumes_at?: number | null } | null;
metadata?: Record<string, string>;
}
export interface Invoice {
id: string;
number: string | null;
status: string | null;
amount_paid: number;
currency: string;
created: number;
invoice_pdf: string | null;
hosted_invoice_url: string | null;
period_start: number;
period_end: number;
lines: {
data: Array<{
description: string | null;
price?: Price | null;
}>;
};
customer_email?: string | null;
}
export interface Customer {
id: string;
email?: string | null;
name?: string | null;
metadata?: Record<string, string>;
}
export interface CheckoutSession {
id: string;
url: string | null;
customer: string | null;
customer_email?: string | null;
client_reference_id: string | null;
subscription: string | null;
amount_total?: number | null;
metadata?: Record<string, string> | null;
}
export interface PortalSession {
id: string;
url: string;
}
export interface Event<T = unknown> {
id: string;
type: string;
created: number;
data: { object: T; previous_attributes?: Record<string, unknown> };
}
export namespace Checkout {
export type Session = CheckoutSession;
}
}
// ---------------------------------------------------------------------------
// HTTP helper
// ---------------------------------------------------------------------------
class CommerceError extends Error {
constructor(public readonly status: number, public readonly detail: unknown) {
super(`commerce ${status}`);
}
}
async function commerceFetch<T>(
apiKey: string,
path: string,
init: RequestInit = {},
): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${apiKey}`);
if (init.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const res = await fetch(`${COMMERCE_URL}${path}`, { ...init, headers });
if (!res.ok) {
let detail: unknown;
try {
detail = await res.json();
} catch {
detail = await res.text();
}
throw new CommerceError(res.status, detail);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
// ---------------------------------------------------------------------------
// Commerce instance — replaces Stripe SDK surface used by the dataroom.
// Returned object exposes subscriptions/customers/invoices/coupons/checkout/
// billingPortal/webhooks namespaces with the methods the codebase touches.
// ---------------------------------------------------------------------------
export interface CommerceInstance {
subscriptions: {
retrieve(id: string, opts?: { expand?: string[] }): Promise<CommerceTypes.Subscription>;
list(params: { customer: string }): Promise<{ data: CommerceTypes.Subscription[] }>;
update(
id: string,
params: {
cancel_at_period_end?: boolean;
cancellation_details?: { comment?: string; feedback?: string };
discounts?: Array<{ coupon: string }>;
pause_collection?:
| { behavior: "void" | "keep_as_draft" | "mark_uncollectible"; resumes_at?: number }
| ""
| null;
items?: Array<{ id?: string; price?: string; quantity?: number; deleted?: boolean }>;
proration_behavior?: "create_prorations" | "none" | "always_invoice";
billing_cycle_anchor?: "now" | "unchanged" | number;
metadata?: Record<string, string>;
},
): Promise<CommerceTypes.Subscription>;
deleteDiscount(id: string): Promise<void>;
};
customers: {
retrieve(id: string): Promise<CommerceTypes.Customer>;
update(id: string, params: Partial<CommerceTypes.Customer>): Promise<CommerceTypes.Customer>;
list(params: { email?: string; limit?: number }): Promise<{ data: CommerceTypes.Customer[] }>;
};
invoices: {
list(params: { customer: string; limit?: number }): Promise<{ data: CommerceTypes.Invoice[] }>;
};
coupons: {
retrieve(id: string): Promise<CommerceTypes.Coupon>;
};
checkout: {
sessions: {
create(params: Record<string, unknown>): Promise<CommerceTypes.CheckoutSession>;
};
};
billingPortal: {
sessions: {
create(params: Record<string, unknown>): Promise<CommerceTypes.PortalSession>;
};
};
webhooks: {
constructEvent(body: Buffer | string, signature: string | string[] | undefined, secret: string): CommerceTypes.Event;
};
}
function buildInstance(apiKey: string): CommerceInstance {
const enc = (s: string) => encodeURIComponent(s);
return {
subscriptions: {
async retrieve(id, opts) {
const qs = opts?.expand?.length
? "?" + opts.expand.map((e) => `expand[]=${enc(e)}`).join("&")
: "";
return commerceFetch<CommerceTypes.Subscription>(apiKey, `/subscriptions/${enc(id)}${qs}`);
},
async list(params) {
return commerceFetch<{ data: CommerceTypes.Subscription[] }>(
apiKey,
`/subscriptions?customer=${enc(params.customer)}`,
);
},
async update(id, params) {
return commerceFetch<CommerceTypes.Subscription>(apiKey, `/subscriptions/${enc(id)}`, {
method: "PATCH",
body: JSON.stringify(params),
});
},
async deleteDiscount(id) {
await commerceFetch<void>(apiKey, `/subscriptions/${enc(id)}/discount`, { method: "DELETE" });
},
},
customers: {
retrieve(id) {
return commerceFetch<CommerceTypes.Customer>(apiKey, `/customers/${enc(id)}`);
},
update(id, params) {
return commerceFetch<CommerceTypes.Customer>(apiKey, `/customers/${enc(id)}`, {
method: "PATCH",
body: JSON.stringify(params),
});
},
list(params) {
const q = new URLSearchParams();
if (params.email) q.set("email", params.email);
if (params.limit) q.set("limit", String(params.limit));
return commerceFetch<{ data: CommerceTypes.Customer[] }>(apiKey, `/customers?${q.toString()}`);
},
},
invoices: {
list(params) {
const q = new URLSearchParams({ customer: params.customer });
if (params.limit) q.set("limit", String(params.limit));
return commerceFetch<{ data: CommerceTypes.Invoice[] }>(apiKey, `/invoices?${q.toString()}`);
},
},
coupons: {
retrieve(id) {
return commerceFetch<CommerceTypes.Coupon>(apiKey, `/coupons/${enc(id)}`);
},
},
checkout: {
sessions: {
create(params) {
return commerceFetch<CommerceTypes.CheckoutSession>(apiKey, `/checkout/sessions`, {
method: "POST",
body: JSON.stringify(params),
});
},
},
},
billingPortal: {
sessions: {
create(params) {
return commerceFetch<CommerceTypes.PortalSession>(apiKey, `/billing_portal/sessions`, {
method: "POST",
body: JSON.stringify(params),
});
},
},
},
webhooks: {
constructEvent(body, signature, secret) {
const sig = Array.isArray(signature) ? signature[0] : signature;
if (!sig) throw new CommerceError(400, "missing signature");
return verifyWebhookEvent(body, sig, secret);
},
},
};
}
/**
* Return a Commerce instance. `oldAccount=true` selects the legacy API key
* (HANZO_COMMERCE_API_KEY_OLD) for tenants migrated from the pre-2024 Stripe
* account. When the legacy account is fully drained, drop the parameter.
* TODO(stripe-rip): merge the two key paths once tenants are unified.
*/
export function commerce(oldAccount = false): CommerceInstance {
return buildInstance(oldAccount ? COMMERCE_API_KEY_OLD || COMMERCE_API_KEY : COMMERCE_API_KEY);
}
// ---------------------------------------------------------------------------
// Webhook signature verification — HMAC-SHA256 over `${timestamp}.${payload}`
// with the configured secret; signature header is `t=<ts>,v1=<sig>`. Same
// shape as the gui migration so we have one and only one verifier.
// ---------------------------------------------------------------------------
function parseSignatureHeader(header: string): Record<string, string> {
const out: Record<string, string> = {};
for (const part of header.split(",")) {
const [k, ...rest] = part.split("=");
if (k && rest.length > 0) out[k.trim()] = rest.join("=").trim();
}
return out;
}
function verifyWebhookEvent(
payload: Buffer | string,
signature: string,
secret: string,
): CommerceTypes.Event {
if (!secret) throw new CommerceError(500, "commerce webhook secret not configured");
const raw = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
const parts = parseSignatureHeader(signature);
const ts = parts["t"];
const sig = parts["v1"];
if (!ts || !sig) throw new CommerceError(400, "invalid signature header");
const signed = `${ts}.${raw}`;
const expected = createHmac("sha256", secret).update(signed).digest("hex");
const a = Buffer.from(sig, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new CommerceError(400, "signature mismatch");
}
if (Math.floor(Date.now() / 1000) - parseInt(ts, 10) > 300) {
throw new CommerceError(400, "timestamp out of tolerance");
}
return JSON.parse(raw) as CommerceTypes.Event;
}
export { COMMERCE_WEBHOOK_SECRET };