From 6604d6c43a54bef57799e0e61203050d29da1125 Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 9 May 2026 00:44:17 +0900 Subject: [PATCH 01/21] feat(security): introduce GRIDA-SEC convention + ingest route group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SECURITY.md as the registry for prevented-vulnerability ids - Allocate GRIDA-SEC-001 for the ingest trust boundary (webhook receivers exposed on a static URL must HMAC-verify before any business logic) - Add (ingest) route group with README codifying the rules - Move Stripe webhook receiver from (api)/private/webhooks/stripe to (ingest)/webhooks/stripe; URL changes to /webhooks/stripe - Add /webhooks/* bypass in proxy.ts (skips tenant routing + session refresh) - Add .agents/skills/security/SKILL.md — auto-loads on any GRIDA-SEC mention, mandates security review before commit on tagged files - Update E2E fixtures and .env.example to the new path --- .agents/skills/security/SKILL.md | 105 ++++++++++++++ SECURITY.md | 136 ++++++++++++++++++ editor/.env.example | 20 ++- editor/app/(ingest)/README.md | 76 ++++++++++ .../webhooks/stripe/route.ts | 11 +- .../__tests__/e2e/fixtures/deliver-event.ts | 2 +- .../billing/__tests__/e2e/fixtures/safety.ts | 2 +- editor/proxy.ts | 22 +++ 8 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 .agents/skills/security/SKILL.md create mode 100644 SECURITY.md create mode 100644 editor/app/(ingest)/README.md rename editor/app/{(api)/private => (ingest)}/webhooks/stripe/route.ts (87%) diff --git a/.agents/skills/security/SKILL.md b/.agents/skills/security/SKILL.md new file mode 100644 index 000000000..2dd9b5f50 --- /dev/null +++ b/.agents/skills/security/SKILL.md @@ -0,0 +1,105 @@ +--- +name: security +description: > + How to handle `GRIDA-SEC-` security boundaries in the Grida repo. + Triggers when you encounter a `GRIDA-SEC` tag in source/docs, when + modifying files under any tagged path, or when adding a new prevented- + vulnerability record. Each `GRIDA-SEC-` identifies a structural + trust boundary documented in `/SECURITY.md`. This skill explains the + contract, mandates a security review before committing changes to any + tagged file, and shows how to register a new id. Use whenever + "GRIDA-SEC" appears in context. +--- + +# Security boundaries — `GRIDA-SEC` + +## What `GRIDA-SEC-` means + +Each `GRIDA-SEC-` is a **prevented vulnerability** — a class of +attack that would exist by default but the codebase structurally +forecloses. Unlike a CVE (which describes something that _was_ broken), +a GRIDA-SEC id is a contract: **this specific class of attack is +impossible because of these specific files, and we keep it that way.** + +`/SECURITY.md` is the canonical registry. Every id has a section there +with: + +- **What it protects** — the boundary in plain English. +- **Vulnerable scenario** — the attack that would exist without the + boundary. +- **How the code prevents it** — the enforcement mechanism, file by file. +- **Files bound by this id** — the exact files whose contents make up + the contract. + +## Working with tagged code + +When you see `GRIDA-SEC-` in a file you're touching: + +1. **Read the entry in `/SECURITY.md` for that id.** Don't act on the + tag alone — the rules are spelled out there. +2. **Run `grep -rn GRIDA-SEC- .`** to find every other file in the + contract. Changes that look local often aren't — a tagged file is + load-bearing for the boundary. +3. **Don't remove a tag** without removing the entry from `/SECURITY.md` + in the same change, with a written justification. + +## Mandatory security review before commit + +If your change touches any file containing a `GRIDA-SEC-` tag, you +**must** complete a security review before committing. The review is +brief but explicit: + +1. **Re-read the entry in `/SECURITY.md`** for every `GRIDA-SEC-` + that appears in your diff. Confirm the prevented scenario is still + prevented after your change. +2. **Walk the enforcement mechanism**. For each numbered "How the code + prevents it" step in the entry, point at the line in your diff (or + confirm it's untouched) that satisfies that step. +3. **Verify all tagged files are still tagged.** A rename, refactor, + or move that drops the tag is a contract violation, even if behavior + looks identical. +4. **If you added or removed a file from the boundary**, update the + "Files bound by this id" list in `/SECURITY.md` in the same commit. +5. **Run any tests adjacent to the boundary.** The SECURITY.md entry + for the id may list specific tests; use `grep -rn GRIDA-SEC- +--include='*test*' --include='*spec*'` to find any others. + +If you cannot satisfy steps 1–4, do not commit. Either revert the +change, or explicitly amend the SECURITY.md entry to reflect a +deliberate update of the contract — and surface that to the user. + +## Adding a new `GRIDA-SEC` id + +When you introduce a new structural prevention worth tracking: + +1. **Allocate the next sequential id.** Look at `/SECURITY.md`, find the + highest existing `GRIDA-SEC-NNN`, use `NNN+1`. Don't reuse retired + ids; don't renumber. +2. **Write the entry in `/SECURITY.md`** under "Active boundaries". Use + the same four-section shape as existing ids: What it protects / + Vulnerable scenario / How the code prevents it / Files bound. +3. **Tag every relevant file.** Header comment in source files, callout + block in READMEs, comment in scripts. Use the literal string + `GRIDA-SEC-NNN`. Brief inline tags at specific code locations are + fine too (e.g. `// GRIDA-SEC-NNN: rule 2 — fail closed`). +4. **Verify the grep works.** `grep -rn GRIDA-SEC-NNN .` should return + the entry in `/SECURITY.md` plus every tagged file. + +This skill auto-loads on any "GRIDA-SEC" mention via its description. +You don't need to register a new id with the skill. + +## When NOT to use this convention + +- **Implementation bugs that were once exploitable.** Those are CVE + territory. GRIDA-SEC is for **prevented-by-structure** classes — if a + bug happened, write a postmortem, not a GRIDA-SEC. +- **Generic best practices** (input validation, authn/authz on user + routes, etc.). Those are baseline and don't need an id. Reserve + GRIDA-SEC for specific structural decisions where misunderstanding + the design would re-open a class of attack. +- **Per-feature security notes** that aren't structural contracts. + Those go in the feature's own docs. + +A good test: if you can reasonably write "this attack class is +impossible because…" in one paragraph and grep returns ≥2 files that +together make it true, it's a candidate for GRIDA-SEC. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..5c8c9c686 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,136 @@ +# Security + +Trust-boundary tracking for Grida. Every prevented vulnerability gets a +stable id, the id appears in every file the boundary depends on, and +this document is the central registry. + +## Convention: `GRIDA-SEC-` + +We use `GRIDA-SEC-001`, `GRIDA-SEC-002`, … as canonical ids for +**security boundaries we have prevented**. The format is deliberately +unlike CVE: + +- A **CVE** describes a vulnerability that was discovered, often after + exposure. The id implies "this was a problem." +- A **GRIDA-SEC** id describes a vulnerability that was structurally + prevented from existing — and a contract with the codebase that it + must stay prevented. The id is "this is a thing we keep safe." + +Every GRIDA-SEC id has: + +- An entry in this file with the threat model and the enforcement + mechanism. +- A grep tag in every file bound by the contract — comments in source, + callouts in READMEs, ingress filters in scripts. +- An auto-loaded skill ([.agents/skills/security/SKILL.md](.agents/skills/security/SKILL.md)) + that triggers when an agent encounters the tag. + +> **The grep is the index.** `grep -r GRIDA-SEC-001 .` returns every +> file in that contract. `grep -r GRIDA-SEC .` returns every security +> boundary in the repo. + +## Philosophy: transparent tracking + +Grida is open source. The threat model is public; the URLs an attacker +might find are public; the fact that webhooks exist is public. Security +in this repo is therefore **structural**, not secret. We make every +boundary loud, named, and grep-able so that future work doesn't drift +into opening new attack surface by accident. + +A developer touching tagged code can't miss the marker; a code review +of any tagged file naturally surfaces the others; an agent picks up the +[security skill](.agents/skills/security/SKILL.md) the moment it sees +"GRIDA-SEC" anywhere in context. + +> If you're adding a new boundary, allocate the next sequential id, add +> an entry below, and tag the relevant files. Don't reuse ids; don't +> renumber. + +--- + +## Active boundaries + +### `GRIDA-SEC-001` — Ingest trust boundary + +**What it protects.** Webhook receivers — endpoints invoked by external +machines on a publicly-reachable URL — are the only HTTP surface in +this app intentionally exposed to the public internet without +cookie-based authentication. Authority is established via the +provider's signed payload. The boundary is the rule that **everything +reachable on `/webhooks/*` must verify a provider signature before +doing anything else.** This applies to every current provider (Stripe, +Metronome, …) and every future one (Replicate, GitHub, etc.). + +**Vulnerable scenario (prevented).** A developer adds an unsigned +endpoint under the same path prefix — or removes the signature check +from an existing receiver — and that path becomes reachable from the +public internet (directly in production, via dev tunnel locally) with +no authentication. An attacker who finds the URL triggers whatever +logic lives there. State-changing endpoints (entitlement flips, record +mutations, tenant-scoped queries) become open APIs. + +**Why it's specifically risky here.** Webhook URLs in an open-source +repo eventually leak — into docs, scripts, screenshots, dashboards +that get linked, examples in PRs. Local dev typically uses a tunnel +(cloudflared, ngrok, etc.) to expose the dev server so external +providers can deliver webhooks; a naïvely-configured tunnel forwards +every path on the local server. If the tunnel URL becomes public — +and on an open-source project it does — every route including +`/insiders/*` becomes reachable on whatever box is currently tunneled. +The boundary contains the blast radius even when the URL is treated +as public. + +**How the code prevents it.** + +1. **Dedicated route group** — `editor/app/(ingest)/`. Every webhook + receiver lives here. Nothing else does. The route group's + [README]() is the authoritative ruleset. +2. **Path-based proxy bypass** — [editor/proxy.ts](editor/proxy.ts) + short-circuits `/webhooks/*` _before_ tenant routing or session + refresh runs. This makes the receivers reachable on arbitrary hosts + (dev tunnels, future direct routes); it also makes the trust + boundary path-aligned with the file system. +3. **HMAC verification at the receiver** — every receiver verifies a + provider signature before any business logic. Fails closed (5xx) + when the signing secret is missing in production. +4. **Replay protection** — receivers dedup on event id and reject + events older than 5 minutes (where applicable). +5. **(Optional) tunnel path filter at the edge** — when local dev uses + a tunnel, the tunnel itself is configured to forward only + `/webhooks/*` and reject everything else with 404. Defense-in-depth + at the network layer: even if app code drifts, the tunnel cannot + expose non-webhook paths. Lands with the dev-tunnel commit. + +**Files bound by this id.** Run `grep -rn GRIDA-SEC-001 .` to enumerate. +Today: + +- [editor/app/(ingest)/README.md]() — rules. +- [editor/app/(ingest)/webhooks/stripe/route.ts]() — Stripe receiver. +- [editor/proxy.ts](editor/proxy.ts) — path bypass. + +**What does NOT belong under `(ingest)/`.** Admin tools, internal RPC, +anything that authenticates via cookie/session/bearer-token — those go +under `(api)/private/**`. Anything user-facing goes under +`(api)/(public)/v1/**`. Mixing categories breaks the trust contract. + +--- + +## Adding a new GRIDA-SEC entry + +1. Allocate the next sequential id (`GRIDA-SEC-002` for the next one). +2. Add an "Active boundaries" subsection here with the same shape as + GRIDA-SEC-001: what it protects, vulnerable scenario, why it's risky + here, how the code prevents it, files bound. +3. Tag every relevant file with the new id (header comment for source, + callout block for docs, comment in scripts). +4. The skill at [.agents/skills/security/SKILL.md](.agents/skills/security/SKILL.md) + auto-loads on any "GRIDA-SEC" mention; no need to register + per-id with the skill. + +## Reporting a vulnerability + +Please email security@grida.co. We respond within 48 hours. + +If you find a way to reach a non-webhook route via the cloudflared +tunnel, that is in scope and considered a real bug — the tunnel filter +is supposed to block it. diff --git a/editor/.env.example b/editor/.env.example index 6dd98ec25..bc5b9a625 100644 --- a/editor/.env.example +++ b/editor/.env.example @@ -81,4 +81,22 @@ SENTRY_PROJECT_ID="" # tosspayments is a pg provider in south korea. if you want to test this feature, use this public test key INTEGRATIONS_TEST_TOSSPAYMENTS_CUSTOMER_KEY="test_ck_ORzdMaqN3w92ng0ALboPr5AkYXQG" -INTEGRATIONS_TEST_TOSSPAYMENTS_SECRET_KEY="test_sk_6BYq7GWPVvyJpbAv24nw3NE5vbo1" \ No newline at end of file +INTEGRATIONS_TEST_TOSSPAYMENTS_SECRET_KEY="test_sk_6BYq7GWPVvyJpbAv24nw3NE5vbo1" + + +# ============================================================================ +# Billing — Stripe +# ============================================================================ +# Stripe is the system of record for money (payment intents, charges, refunds, +# subscription state). Webhooks land at /webhooks/stripe — see /SECURITY.md +# (GRIDA-SEC-001) for the trust boundary. + +# Use a test key (sk_test_...) when BILLING_TEST_MODE=true. +STRIPE_SECRET_KEY="sk_test_..." +# Signing secret for the /webhooks/stripe endpoint. +# `stripe listen --forward-to localhost:3000/webhooks/stripe` prints one. +STRIPE_WEBHOOK_SECRET="whsec_..." +# Set to "true" in dev/test. Refuses to run if STRIPE_SECRET_KEY is a live key. +BILLING_TEST_MODE="true" +# Set to "1" to opt into the live billing E2E suite. Off by default. +BILLING_E2E="0" \ No newline at end of file diff --git a/editor/app/(ingest)/README.md b/editor/app/(ingest)/README.md new file mode 100644 index 000000000..e70e9891a --- /dev/null +++ b/editor/app/(ingest)/README.md @@ -0,0 +1,76 @@ +# `(ingest)` — externally-callable, signature-authenticated endpoints + +> **`GRIDA-SEC-001`** — +> grep this tag across the repo to find every file that depends on this contract. + +This route group exists for **one reason**: to receive HTTP requests from +**external machines** (Stripe, Metronome, future providers) where authority +comes from a **signed payload**, not from a user session. + +It is the only category of route in this app that is intentionally exposed +to the public internet via tunneled or direct delivery, with **no cookie +auth, no tenant routing, and no RLS context**. + +## The rules + +Every file under `editor/app/(ingest)/**/route.ts` MUST: + +1. **Verify a provider signature before any business logic.** Read the raw + request body. Compute / verify the provider's HMAC. Reject mismatches with 400. +2. **Fail closed on missing signing secret in production.** If the env var + for the secret is unset and `NODE_ENV === "production"`, return 500. Do + not "trust" unsigned requests. +3. **Dedup on a provider-issued event id.** Replays must be idempotent. +4. **Take no action that could be triggered by an attacker without the + signing secret.** No reads of user-scoped data based on payload contents + alone — verify the event's authenticity before treating its payload as + authoritative input. + +## What does NOT belong here + +- Anything that reads cookies / browser session. +- Anything that runs business logic before signature verification. +- Anything that "auth"s by IP allowlist alone — IP filtering is at most a + belt-and-braces layer at the edge (Cloudflare Access). HMAC is the source + of truth for authenticity. +- Admin debug tools, internal RPC, or anything with a non-machine caller. + Those go under `(api)/private/**`. + +## Why a dedicated route group + +The proxy at [editor/proxy.ts](../../proxy.ts) **bypasses** tenant routing +and Supabase session refresh for any path under `/webhooks/*`. The bypass +is what makes these endpoints reachable on arbitrary hosts (including +cloudflared dev tunnels). Without the route group + naming, that bypass +would be a foot-gun: a developer could add an admin endpoint under +`/webhooks/...` and it would inherit "no auth" without realizing. + +The route group makes the contract physically visible: + +- **File system**: every webhook receiver lives under `(ingest)/`. A grep + for the GREPME tag returns every file in the trust contract. +- **URL**: paths under `/webhooks/` are the only externally + reachable surface, by design. +- **Proxy**: the bypass is keyed off `/webhooks/*`, named exactly to match. + +## Adding a new receiver + +1. Create `editor/app/(ingest)/webhooks//route.ts`. +2. Implement HMAC verification per the provider's documented algorithm. +3. Add the `GRIDA-SEC-001` tag to the file's header + docblock. +4. Add the env var (`_WEBHOOK_SECRET`) to + [editor/.env.example](../../.env.example). +5. Document the URL + signing secret setup in + [editor/scripts/billing/README.md](../../scripts/billing/README.md) (or + wherever the provider's setup lives). +6. Reference the new file in [/SECURITY.md](../../../SECURITY.md) under + the inventory. + +## Inventory + +- [`webhooks/stripe/route.ts`](./webhooks/stripe/route.ts) — Stripe webhooks. Verifies `Stripe-Signature` via the SDK. +- [`webhooks/metronome/route.ts`](./webhooks/metronome/route.ts) — Metronome webhooks. HMAC-SHA256 over `Date + "\n" + body`. + +For the full security philosophy and threat model, see +[/SECURITY.md](../../../SECURITY.md). diff --git a/editor/app/(api)/private/webhooks/stripe/route.ts b/editor/app/(ingest)/webhooks/stripe/route.ts similarity index 87% rename from editor/app/(api)/private/webhooks/stripe/route.ts rename to editor/app/(ingest)/webhooks/stripe/route.ts index 534138cbe..ae9b7fb0c 100644 --- a/editor/app/(api)/private/webhooks/stripe/route.ts +++ b/editor/app/(ingest)/webhooks/stripe/route.ts @@ -1,9 +1,13 @@ /** * Stripe webhook receiver — single endpoint for all event types. * - * Effective URL: `/private/webhooks/stripe`. The `(api)` route group is - * URL-invisible, but `private/` is a normal path segment (no parens). - * Configure `stripe listen --forward-to localhost:3000/private/webhooks/stripe`. + * `GRIDA-SEC-001` — + * see `editor/app/(ingest)/README.md` for the trust contract this file + * is bound by, and `/SECURITY.md` for the threat model. + * + * Effective URL: `/webhooks/stripe`. The `(ingest)` route group is + * URL-invisible. + * Configure: `stripe listen --forward-to localhost:3000/webhooks/stripe`. * * Pipeline: * 1. Read raw body (signature verification needs raw bytes). @@ -42,6 +46,7 @@ export async function POST(req: NextRequest) { const secret = process.env.STRIPE_WEBHOOK_SECRET; if (!secret) { + // GRIDA-SEC-001: rule 2 — fail closed when secret is missing. console.error("[webhook/stripe] STRIPE_WEBHOOK_SECRET not configured"); return NextResponse.json( { error: "webhook secret not configured" }, diff --git a/editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts b/editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts index 362bece3e..6c5db1a57 100644 --- a/editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts +++ b/editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts @@ -64,7 +64,7 @@ export async function deliverEvent( ? sig.replace(/v1=[a-f0-9]+/, "v1=" + "0".repeat(64)) : sig; - const res = await fetch(`${appUrl()}/private/webhooks/stripe`, { + const res = await fetch(`${appUrl()}/webhooks/stripe`, { method: "POST", headers: { "content-type": "application/json", diff --git a/editor/lib/billing/__tests__/e2e/fixtures/safety.ts b/editor/lib/billing/__tests__/e2e/fixtures/safety.ts index 4f38beed7..63b97c550 100644 --- a/editor/lib/billing/__tests__/e2e/fixtures/safety.ts +++ b/editor/lib/billing/__tests__/e2e/fixtures/safety.ts @@ -62,7 +62,7 @@ export function assertSuiteSafety(): void { issues.push("SUPABASE_SECRET_KEY is required (service-role)"); } - // App URL must also be local — the test signs and POSTs to APP_URL/private/webhooks/stripe. + // App URL must also be local — the test signs and POSTs to APP_URL/webhooks/stripe. const appUrl = process.env.APP_URL; if (!appUrl) { issues.push("APP_URL is required (e.g. http://localhost:3000)"); diff --git a/editor/proxy.ts b/editor/proxy.ts index f48f6ed46..1c0cf956f 100644 --- a/editor/proxy.ts +++ b/editor/proxy.ts @@ -30,6 +30,28 @@ export async function proxy(req: NextRequest) { return new NextResponse("Not Found", { status: 404 }); } + // GRIDA-SEC-001 — + // see editor/app/(ingest)/README.md and /SECURITY.md. + // + // Webhook receivers under `/webhooks/*` are signed, machine-callable + // endpoints invoked by external services on whatever URL was registered + // (e.g. a cloudflared dev tunnel). Skip every host-based pipeline + // downstream — tenant routing, Supabase session refresh, maintenance + // redirects — and let the receiver respond on the canonical path + // regardless of host. Authenticity is enforced by the receiver itself + // via the provider's HMAC signature header (see (ingest)/webhooks/*/route.ts). + // + // The route group `(ingest)` (file-system) and the URL prefix `/webhooks/` + // (this bypass + the cloudflared ingress filter) are intentionally + // matched: anything under `(ingest)/webhooks//route.ts` is + // exposed; nothing else is. + { + const p = req.nextUrl.pathname; + if (p === "/webhooks" || p.startsWith("/webhooks/")) { + return NextResponse.next({ request: req }); + } + } + // #region maintenance mode if (IS_PROD) { try { From 8dfdfd82cecd3a80e803deb45c69086fa6fe1665 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 11 May 2026 03:57:25 +0900 Subject: [PATCH 02/21] docs(skills): add database skill for migrations / RLS / schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three subcommands under one entry point: `/database compact local migration` for safely consolidating only unshipped migrations, `/database rls scenarios` for writing pgTAP coverage where the implementation mirrors the test (not the reverse), and `/database align` for keeping `schemas/*.sql` in sync with the migrated state. Encodes the discipline that applied migrations are immutable history — classification heuristics are signals, not authority; user confirmation is the only ground truth. --- .agents/skills/database/SKILL.md | 267 +++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 .agents/skills/database/SKILL.md diff --git a/.agents/skills/database/SKILL.md b/.agents/skills/database/SKILL.md new file mode 100644 index 000000000..7c6f3f2e5 --- /dev/null +++ b/.agents/skills/database/SKILL.md @@ -0,0 +1,267 @@ +--- +name: database +description: > + Use BEFORE editing any file in `supabase/migrations/` or + `supabase/schemas/`, OR when the user runs `/database ` + (`compact local migration`, `rls scenarios`, `align`). Encodes the + three contracts that protect the Grida database layer: applied + migrations are immutable, RLS implementation mirrors tests (never + the reverse), `schemas/*.sql` is the human-readable end-state. + Companion to `supabase/AGENTS.md` (RLS, grants, security boundaries). +--- + +# Database — operating contracts + +Three contracts this skill protects: + +1. **Applied migrations are immutable.** +2. **The RLS spec is the source of truth — implementations follow tests, not the reverse.** +3. **`schemas/*.sql` is the human-friendly description of the final shape.** + +`supabase/AGENTS.md` is the harder rule layer (RLS, grants, security +boundaries). This skill covers the recurring workflow tasks. Read both. + +--- + +## Common mistake (read this first) + +When asked to "merge migrations" or "consolidate", the temptation is to +collapse every `supabase/migrations/*` into a single canonical file. +**This is wrong if any of those files have already been applied to a +deployed environment.** Rewriting an applied migration: + +- Diverges file content from what `supabase_migrations.schema_migrations` + records as already-run. +- Makes future `db reset` produce a different starting state for new + contributors than what the existing environment holds. +- Can silently drop columns, policies, or grants the live system needs. + +**Before merging anything, classify each migration:** + +| Class | Signal | Allowed action | +| ----------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------- | +| **Applied** (production) | User confirms it's in production, OR file has been on `main` long enough to have shipped. | Read-only. Never edit, never delete. | +| **Applied** (committed peers) | Tracked on the current branch but originated upstream (already on `main` / `canary`). | Read-only. Never edit, never delete. | +| **Local-only** (this PR) | Newly added on the current working tree / branch, not yet merged to a deployed branch. | Free to merge, rename, delete, rewrite. | + +> **None of these signals replace user confirmation.** The only ground +> truth is the deployed `schema_migrations` table on staging/prod, which +> the agent cannot read. `git log` and `git status` indicate likelihood, +> not certainty. **Default to asking.** + +Old timestamps don't mean "applied" — they can be brand-new files +added to fix an ordering bug. + +--- + +## `/database compact local migration` + +Merge multiple local-only migration files into one (or a few) before +the PR ships. Local development naturally accumulates many small +migrations for fast iteration without `db reset`; production prefers +one coherent migration per feature. + +### When to invoke + +- Before opening a PR that adds 2+ migration files for the same feature. +- User says "merge migrations", "consolidate migrations", "clean up + the migration directory". +- Reviewing a feature branch where migrations outnumber logical chunks. + +### Procedure + +1. **Classify every migration** in the working tree. Output: two lists, + _applied_ (leave alone) and _local-only_ (candidates). +2. **Verify the classification with the user when uncertain.** One + confirmation message is cheaper than touching a shipped file. +3. **Plan the merge.** For each local-only migration, record: + - what schema/table it touches, + - dependencies on earlier local-only migrations, + - whether a later local-only migration _supersedes_ it (`ADD COLUMN` + then `DROP COLUMN` — both vanish in the merged file). +4. **Pick the consolidated filename.** Use the timestamp of the _latest_ + local-only file in the chain so ordering relative to applied + migrations stays intact. Rename if the merged content makes a + different name more honest. +5. **Write the consolidated SQL** as if it were the only file in the + chain — no `ADD … then DROP` churn, no superseded function defs. + Idempotent forms (`CREATE TABLE IF NOT EXISTS`, `CREATE OR REPLACE +FUNCTION`, `ADD COLUMN IF NOT EXISTS`) make local re-runs safe. +6. **Delete the superseded local-only files.** Only those — never + touch the applied list. +7. **`supabase db reset`** locally. Run `supabase db test` if pgTAP + covers the affected tables. +8. **Note the fold in the PR description.** Reviewers shouldn't have + to diff timestamps to figure it out. + +### Worked example + +Local-only (mergeable): + +``` +20260508120000_grida_billing_account_provisioning_uid.sql +20260508130000_grida_billing_metronome.sql +20260509120000_grida_billing_debit_cache.sql +20260509130000_grida_billing_alerts_multi_tier.sql +``` + +Applied (untouchable): + +``` +20260506132900_grida_billing.sql +20260507000000_grida_billing_backfill_provision.sql +20260507223000_grida_billing_security_invoker.sql +``` + +Right move: write one consolidated `20260508130000_grida_billing_metronome.sql` +(latest timestamp; v2 projector from `alerts_multi_tier` replaces v1 +from `metronome.sql`), delete the other three local files, leave the +applied trio untouched. **Wrong move:** cat all seven into one. + +--- + +## `/database rls scenarios` + +Write or review RLS test scenarios for a tenant-scoped surface. Output +is pgTAP coverage proving who can read/write what across personas. +**Not** a description of the current implementation. + +### When to invoke + +- New tenant-scoped table, view, or RPC. +- Existing RLS policies changing. +- Reviewing a security-sensitive PR ("what could go wrong here?"). +- User runs `/database rls scenarios `. + +### The non-negotiable inversion + +> **Implementation mirrors the test, not the other way around.** + +In RLS, the user journey **is** the spec. If a test says "a member of +org A cannot read org B's `project` rows", that is a fact about how +the product must behave. The implementation's job is to satisfy that +fact. If the implementation currently leaks org B's rows, that's a +security bug — fix the implementation, do not weaken the test. + +**Resist** any pressure (including from yourself, mid-implementation): + +- Soften an assertion because the policy doesn't quite cover it yet. +- Add `SET LOCAL ROLE service_role` to make a test pass. +- Drop a "no-membership cannot read" case because seeding is inconvenient. +- Replace `is(count, 0)` with `ok(count >= 0)`. + +A test failing because the policy is wrong is the test doing its job. +A test failing because the _test_ is wrong (mis-seeded fixture, typo'd +UUID) gets fixed mechanically — never relax the assertion. + +### The skill's job + +You are the database/security expert helping the user lock down the spec: + +1. **Listen for the user journey.** Translate prose ("members read, + owners edit, outsiders see nothing") into the persona matrix: + insider-member, insider-owner, other-tenant-member, no-membership, + anon. Each persona × each operation is a row in the test plan. +2. **Spell out the silent edges.** Every product description has gaps. + Always test: + - **Anon (no JWT)** — `auth.uid()` returns `NULL`. A policy reading + `auth.uid() = owner_id` becomes `NULL = …` (always false-ish); + `WITH CHECK` must fail closed independently. + - Cross-tenant member of _the same role_ (different org, same plan). + - **`RETURNING` clause leaks** — an `INSERT … RETURNING *` or + `UPDATE … RETURNING *` may emit columns from rows a peer SELECT + policy hides. Test that the writer doesn't leak fields they + can't read back via SELECT. + - "Soft-deleted" or `archived_at`-set rows — visibility differs. + - Foreign-key rows whose policies depend on a parent's tenant + boundary (joining policies that "widen" access). +3. **Polish the wording, never the meaning.** "Owner can read their + org's projects" → "row visible to `authenticated` when + `organization_id` ∈ user's owned orgs". Same fact, SQL-shaped. + If the user's words and the SQL diverge, stop and ask. +4. **Assert positive AND negative cases** for every scenario. +5. **Use seeded personas** (`supabase/seed.sql`), not ad-hoc UUIDs. + +### Output shape + +One pgTAP file per surface (or per logical persona group when large). +Skeleton + fixture/session conventions live in `supabase/AGENTS.md` +§ _RLS testing_ — point readers there rather than re-list. + +### Anti-patterns to flag in review + +- Only positive assertions ("insider can read"), no negative ones — + proves nothing about isolation. +- Authenticating as `service_role` to read tenant rows — bypasses RLS, + proves nothing. +- Assertions phrased as "≥ 0 rows" or "row count is consistent" — + accept the broken case. +- A test changed at the same commit as the policy it covers, with + the assertion weakened — almost always a tell that the impl was + wrong and the test got dragged down to match. + +--- + +## `/database align` + +Bring `supabase/schemas/*.sql` back in sync with the migrated state. +Schemas are the **human-friendly source of truth** for the final shape +of each domain schema. Migrations are the executable history; schemas +are the readable end-state. + +### When to invoke + +- After landing a feature that added/modified columns, tables, policies, + RPCs in any `grida_*` schema. +- When `schemas/*.sql` and `migrations/*` visibly disagree. +- User runs `/database align`. + +### What `schemas/*.sql` is for (and isn't) + +| Concern | `schemas/*.sql` | `migrations/*.sql` | +| ----------------------------- | ----------------------- | ----------------------------------------- | +| What runs on the DB | No | **Yes** — supabase applies these. | +| Source of truth for execution | No | Yes. | +| Source of truth for _humans_ | **Yes** — read first. | No — chronological, hard to reason about. | +| Updated | Manually, periodically. | Via `supabase migration new`. | +| Drift | Best-effort, may lag. | Never — runs against real DBs. | + +`align` is the periodic reset that keeps the human-readable layer +trustworthy. See `supabase/AGENTS.md` for the upstream policy. + +### Procedure + +1. **Pick one domain schema** (e.g. `grida_billing`). Don't align + everything in one pass — too easy to miss a divergence. +2. **Build the actual end-state from migrations.** Read every migration + that touches the schema, in order, and compose the final shape in + your head (or a scratch file). The migrations themselves are the + authoritative source — a `pg_dump` would give you the truth too, + but in the wrong shape (alphabetised, comments stripped, catalog + noise) and is harder to diff against a hand-organised schema file + than just reading the migrations. +3. **Diff against `schemas/.sql`.** Common deltas: + - Columns added by a later migration, not in the schema file. + - Function signatures changed by `CREATE OR REPLACE`, not updated. + - Policies dropped/replaced; schema still shows the old. + - Comments: migrations carry `COMMENT ON COLUMN`; schemas often + forget to mirror. +4. **Update `schemas/.sql`** to the migrated end-state. Keep + the file's existing organisation (sections by table, header + comments). Group grants and policies under the table they belong + to — not by chronology. +5. **Do NOT modify any migration as part of this task.** Schemas + follow migrations; migrations never follow schemas. If a migration + has a bug, fix it via a _new_ migration (or `compact` flow above + for unshipped local-only ones). +6. **`supabase db reset`** afterwards as a smoke check. + +### Anti-patterns to flag + +- Editing `schemas/*.sql` _instead of_ a migration to "fix a column" — + the schema file is reference, not executable. The DB won't see it. +- Editing a migration to "match the schema file" — backwards. The + migration is what ran; the schema describes what migrations produced. +- Real-time-sync tooling — reintroduces the surface-area problem the + migration model exists to solve. Manual periodic alignment is the + design. From 697185a1c15a6d656fa2e4f3300bbb659502cff8 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 11 May 2026 04:24:00 +0900 Subject: [PATCH 03/21] feat(billing): add Metronome AI credit service layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema: per-org Metronome customer/contract ids + entitlement-cache + auto-reload columns on grida_billing.account, plus metronome_event dedup table and the projector RPC. Multi-tier-alert aware (only depletion-tier flips entitlement off; warning tiers refresh balance only). Service: editor/lib/billing/metronome.ts — provisionOrg, addStripeChargedCommit, addComplimentaryCommit, setAutoReload, ingestUsageEvent[Gated], getEntitlement, getOrgBalance, getTransactions, getInvoices, refreshBalance, revokeUnusedOnCommit, getAccountView, handleAiCreditCheckoutCompleted (post-Checkout reconciler). Money model: fees.ts ships a flat 5% + \$0.30 gross-up markup envelope verified safe across every Stripe card type at \$10-\$500 (audit via cli.ts markup-sim). AI_GATE_FLOOR_CENTS is the single source for the 25c gate floor. --- database/database-generated.types.ts | 70 + editor/lib/billing/fees.ts | 99 ++ editor/lib/billing/metronome.ts | 1578 +++++++++++++++++ editor/lib/billing/plans.ts | 1 - editor/package.json | 1 + pnpm-lock.yaml | 9 + ...20260508130000_grida_billing_metronome.sql | 416 +++++ supabase/schemas/grida_billing.sql | 6 + 8 files changed, 2179 insertions(+), 1 deletion(-) create mode 100644 editor/lib/billing/fees.ts create mode 100644 editor/lib/billing/metronome.ts create mode 100644 supabase/migrations/20260508130000_grida_billing_metronome.sql diff --git a/database/database-generated.types.ts b/database/database-generated.types.ts index 225e26aea..709aa7e1a 100644 --- a/database/database-generated.types.ts +++ b/database/database-generated.types.ts @@ -4752,6 +4752,13 @@ export type Database = { } } flatten_jsonb_object_values: { Args: { obj: Json }; Returns: string } + fn_billing_apply_metronome_event: { + Args: { p_event_id: string; p_event_type: string; p_payload: Json } + Returns: { + handler: string + result: string + }[] + } fn_billing_apply_stripe_event: { Args: { p_event_id: string; p_event_type: string; p_payload: Json } Returns: { @@ -4766,6 +4773,13 @@ export type Database = { stripe_customer_id: string }[] } + fn_billing_debit_balance_cache: { + Args: { p_cents: number; p_floor_cents?: number; p_org: number } + Returns: { + cached_balance_cents: number + customer_entitled: boolean + }[] + } fn_billing_get_active_subscription: { Args: { p_org_id: number } Returns: { @@ -4789,6 +4803,61 @@ export type Database = { Args: { p_org_id: number } Returns: string } + fn_billing_get_metronome_account: { + Args: { p_org: number } + Returns: { + auto_reload_amount_cents: number + auto_reload_enabled: boolean + auto_reload_threshold_cents: number + cached_balance_at: string + cached_balance_cents: number + customer_entitled: boolean + metronome_contract_id: string + metronome_customer_id: string + organization_id: number + provisioning_uid: string + stripe_customer_id: string + }[] + } + fn_billing_list_metronome_events: { + Args: { p_limit?: number; p_org?: number } + Returns: { + customer_id: string + event_id: string + event_type: string + failure_reason: string + payment_status: string + processed_at: string + received_at: string + }[] + } + fn_billing_list_provisioned_orgs: { + Args: never + Returns: { + organization_id: number + }[] + } + fn_billing_resolve_org_by_metronome_customer: { + Args: { p_customer_id: string } + Returns: number + } + fn_billing_set_auto_reload: { + Args: { + p_amount_cents: number + p_enabled: boolean + p_org: number + p_threshold_cents: number + } + Returns: undefined + } + fn_billing_set_balance_cache: { + Args: { p_balance_cents: number; p_entitled: boolean; p_org: number } + Returns: undefined + } + fn_billing_set_metronome_ids: { + Args: { p_contract_id: string; p_customer_id: string; p_org: number } + Returns: undefined + } fn_billing_setup_product: { Args: { p_grida_billing_id: string @@ -5391,3 +5460,4 @@ export const Constants = { }, }, } as const + diff --git a/editor/lib/billing/fees.ts b/editor/lib/billing/fees.ts new file mode 100644 index 000000000..a94e1e78f --- /dev/null +++ b/editor/lib/billing/fees.ts @@ -0,0 +1,99 @@ +// Pass-through markup on AI-credit top-ups. +// +// Why: +// The AI-credits design (docs/wg/platform/ai-credits.md) commits to +// "AI is sold at cost." For the credit balance to actually be at-cost +// we need to pass payment-processing costs through to the customer — +// otherwise we lose money on every top-up. Grida is not a payment +// processor. +// +// Why a flat over-engineered rate (and not "Stripe's actual fee"): +// Stripe's effective fee depends on the card the customer ends up +// using — which we don't know until after the charge: +// • US card: 2.9% + $0.30 +// • International card: 3.9% + $0.30 +// • AmEx (US): 3.5% + $0.30 +// • International AmEx: 4.4% + $0.30 +// • + 1.0% currency conversion if the card's currency differs +// So any "exact" formula keyed on US-card rates loses money on the +// non-US cases. We instead pick a single safe envelope. +// +// The chosen formula (verified by `editor/scripts/billing/cli.ts markup-sim`): +// +// total_cents = ceil((credit_cents + 30) / 0.95) +// +// In words: gross up by 5% with a $0.30 buffer. +// +// This is the lowest-friction formula that NEVER loses money across +// all six card-type combinations, for credit amounts in [$10, $500] +// (the product-enforced range). Worst case (intl card + currency +// conversion) lands within $0.01–$0.53 of break-even; best case +// (US card) overcharges by 8.5% at $10 and ~5.3% at $500. +// +// Markup-simulator output is the audit trail. Re-run whenever +// Stripe's rates change OR the product range changes. +// +// Scope: +// Applied at user-initiated Checkout (manual top-up, first +// auto-reload setup). NOT applied to Metronome's silent +// auto-recharges via `prepaid_balance_threshold_configuration` — +// that primitive doesn't separate charged from credit amounts, so +// silent recharges run at-cost (we eat ~3% on those). Acceptable v1 +// cost; revisit if margin pressure builds. + +const MARKUP_PCT = 0.05; +const MARKUP_FIXED_CENTS = 30; + +/** + * Total to charge the customer to net `creditCents` after Stripe's + * fee, across every supported card type. Always rounds UP to the + * nearest cent so we never undercharge. + * + * Returns 0 for non-positive inputs. + */ +export function totalChargeForCredit(creditCents: number): number { + if (!Number.isFinite(creditCents) || creditCents <= 0) return 0; + return Math.ceil((creditCents + MARKUP_FIXED_CENTS) / (1 - MARKUP_PCT)); +} + +/** + * The processing-fee component of the total charge. + * `total = credit + processingFee(credit)`. + */ +export function processingFeeCents(creditCents: number): number { + return totalChargeForCredit(creditCents) - creditCents; +} + +// --------------------------------------------------------------------------- +// Product-enforced amount limits — quoted by the validators in both the +// service layer (`lib/billing/metronome.ts`) and the user-facing actions +// (`app/(site)/.../billing/_actions.ts`). Anything outside these ranges +// breaks the safety guarantee of the markup formula above. +// --------------------------------------------------------------------------- + +/** Minimum top-up purchase. Below this, the fixed $0.30 fee dominates. */ +export const TOPUP_MIN_CENTS = 1000; // $10 +/** Maximum single top-up. Above this we're underwriting more risk than the markup justifies. */ +export const TOPUP_MAX_CENTS = 50_000; // $500 + +/** Minimum auto-reload threshold. Whole dollars; $5 is enough headroom that + * the silent recharge has time to land before the gate floor (25¢) trips. */ +export const AUTO_RELOAD_THRESHOLD_MIN_CENTS = 500; // $5 +/** Minimum auto-reload recharge-to-target. Whole dollars. Larger than topup + * min so silent recharges have meaningful runway between fires. */ +export const AUTO_RELOAD_RECHARGE_MIN_CENTS = 2500; // $25 +/** Maximum auto-reload recharge-to-target. Same ceiling as a manual top-up. */ +export const AUTO_RELOAD_RECHARGE_MAX_CENTS = 50_000; // $500 + +/** + * Hard gate floor: AI is blocked when the org's balance falls below this. + * Single source of truth for the gate's floor; the DB optimistic-debit RPC + * (`fn_billing_debit_balance_cache`) takes the same value as a default + * argument — keep them in sync if changed. + */ +export const AI_GATE_FLOOR_CENTS = 25; + +/** Display helper: cents → "$X.XX". */ +export function fmtUsd(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} diff --git a/editor/lib/billing/metronome.ts b/editor/lib/billing/metronome.ts new file mode 100644 index 000000000..cef079946 --- /dev/null +++ b/editor/lib/billing/metronome.ts @@ -0,0 +1,1578 @@ +// Metronome integration — client + service layer. +// +// Metronome: source of truth for credit balance + drain order. +// Stripe: source of truth for money. Metronome facilitates the charge. +// Our DB: source of truth for the gate decision (`customer_entitled`) +// and a cached balance. Updated by webhooks; read by the gate. +// +// All service functions take `organizationId: number` and persist Metronome- +// side ids on `grida_billing.account`. Idempotent end-to-end. +// +// Architectural rationale: docs/wg/platform/ai-credits.md. + +import * as crypto from "node:crypto"; +import Metronome from "@metronome/sdk"; +import { service_role } from "../supabase/server"; +import { stripe } from "./index"; +import { + AI_GATE_FLOOR_CENTS, + AUTO_RELOAD_RECHARGE_MAX_CENTS, + AUTO_RELOAD_RECHARGE_MIN_CENTS, + AUTO_RELOAD_THRESHOLD_MIN_CENTS, + TOPUP_MAX_CENTS, + TOPUP_MIN_CENTS, +} from "./fees"; + +// --------------------------------------------------------------------------- +// lazy client (mirrors the lazy Stripe Proxy in `index.ts`) +// --------------------------------------------------------------------------- + +let _client: Metronome | null = null; + +function getClient(): Metronome { + if (_client) return _client; + const token = process.env.METRONOME_API_TOKEN; + if (!token) { + throw new Error("METRONOME_API_TOKEN is required."); + } + _client = new Metronome({ bearerToken: token }); + return _client; +} + +export const metronome: Metronome = new Proxy({} as Metronome, { + get(_target, prop) { + return Reflect.get(getClient(), prop); + }, +}) as Metronome; + +export type { Metronome }; + +// --------------------------------------------------------------------------- +// errors +// --------------------------------------------------------------------------- + +export class BillingMetronomeError extends Error { + constructor( + message: string, + public code: string, + public status: number = 500 + ) { + super(message); + this.name = "BillingMetronomeError"; + } +} + +// --------------------------------------------------------------------------- +// substrate (named resources created out-of-band by cli.ts setup:metronome) +// --------------------------------------------------------------------------- + +const SUBSTRATE_NAMES = { + metric: "Grida AI Usage", + usageProduct: "Grida AI Usage", + creditProduct: "Grida AI Credits", + rateCard: "Grida AI Sandbox", + eventType: "ai.usage", + costProperty: "cost_mills", +} as const; + +type Substrate = { + metricId: string; + usageProductId: string; + creditProductId: string; + rateCardId: string; + /** USD-cents credit type id (used by alerts API). Discovered from rate card. */ + creditTypeId: string; + eventType: string; + costProperty: string; +}; + +let _substrate: Substrate | null = null; + +export async function getSubstrate(): Promise { + if (_substrate) return _substrate; + + let metricId: string | undefined; + for await (const m of metronome.v1.billableMetrics.list()) { + if (m.name === SUBSTRATE_NAMES.metric) { + metricId = m.id; + break; + } + } + + let usageProductId: string | undefined; + let creditProductId: string | undefined; + for await (const p of metronome.v1.contracts.products.list({ + archive_filter: "NOT_ARCHIVED", + })) { + if ( + p.current?.name === SUBSTRATE_NAMES.usageProduct && + p.type === "USAGE" + ) { + usageProductId = p.id; + } else if ( + p.current?.name === SUBSTRATE_NAMES.creditProduct && + p.type === "FIXED" + ) { + creditProductId = p.id; + } + } + + let rateCardId: string | undefined; + let creditTypeId: string | undefined; + for await (const r of metronome.v1.contracts.rateCards.list({ body: {} })) { + if (r.name === SUBSTRATE_NAMES.rateCard) { + rateCardId = r.id; + // Each rate card declares a fiat credit type (USD = 2714e483-…). + // The SDK type doesn't expose this field; the API does. + const r2 = r as unknown as { + fiat_credit_type?: { id?: string }; + fiat_credit_type_id?: string; + credit_type?: { id?: string }; + }; + creditTypeId = + r2.fiat_credit_type?.id ?? r2.fiat_credit_type_id ?? r2.credit_type?.id; + break; + } + } + + if (!metricId || !usageProductId || !creditProductId || !rateCardId) { + throw new BillingMetronomeError( + "Metronome substrate missing. Run editor/scripts/billing/cli.ts setup:metronome.", + "substrate_missing" + ); + } + if (!creditTypeId) { + // USD (cents) — the universal Metronome fiat credit type. + creditTypeId = "2714e483-4ff1-48e4-9e25-ac732e8f24f2"; + } + + _substrate = { + metricId, + usageProductId, + creditProductId, + rateCardId, + creditTypeId, + eventType: SUBSTRATE_NAMES.eventType, + costProperty: SUBSTRATE_NAMES.costProperty, + }; + return _substrate; +} + +// --------------------------------------------------------------------------- +// drain-order priorities (see docs/wg/platform/ai-credits.md) +// --------------------------------------------------------------------------- + +export const COMMIT_PRIORITY = { + /** Promo / refund / manual grant. Drains first. */ + PROMO: 50, + /** Stripe-charged top-up. Drains last; never expires. */ + TOPUP: 90, +} as const; + +// --------------------------------------------------------------------------- +// Stripe Checkout `metadata.kind` discriminants. The webhook handler +// dispatches on these strings; both producers (in `_actions.ts`) and the +// consumer (`handleAiCreditCheckoutCompleted` below) MUST agree. +// --------------------------------------------------------------------------- + +export const AI_CHECKOUT_KIND = { + TOPUP: "ai_topup", + AUTO_RELOAD_ENABLE: "ai_auto_reload_enable", +} as const; +export type AiCheckoutKind = + (typeof AI_CHECKOUT_KIND)[keyof typeof AI_CHECKOUT_KIND]; + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +/** Sentinel "never expires" timestamp on commit access schedules. */ +export const FAR_FUTURE = new Date(Date.UTC(2099, 0, 1)).toISOString(); + +/** Round down to top of hour. Metronome wants schedule-item starts aligned. */ +export function hourFloor(d: Date = new Date()): string { + const t = new Date(d); + t.setUTCMinutes(0, 0, 0); + return t.toISOString(); +} + +// Metronome ingest_alias. Composing in `provisioning_uid` makes orphan +// cloud-side customers from prior `supabase db reset` runs inert. +function aliasFor(organizationId: number, provisioningUid: string): string { + return `grida-org-${organizationId}-${provisioningUid}`; +} + +// --------------------------------------------------------------------------- +// live state — read from Metronome (source of truth) +// --------------------------------------------------------------------------- + +// Live contract snapshot from Metronome. DB is never ahead of this. +export type ContractLiveState = { + contractId: string; + customerId: string; + autoReload: { + enabled: boolean; + thresholdCents: number | null; + rechargeToCents: number | null; + } | null; + balanceCents: number; + stripeBillingConfigured: boolean; +}; + +export async function readContractLive( + customerId: string, + contractId: string +): Promise { + const contract = await metronome.v2.contracts.retrieve({ + customer_id: customerId, + contract_id: contractId, + }); + // Both fields below exist on the API response but aren't in the SDK type. + const data = contract.data as unknown as { + prepaid_balance_threshold_configuration?: { + is_enabled?: boolean; + threshold_amount?: number; + recharge_to_amount?: number; + }; + customer_billing_provider_configuration?: { billing_provider?: string }; + }; + + const cfg = data.prepaid_balance_threshold_configuration; + const autoReload = cfg + ? { + enabled: !!cfg.is_enabled, + thresholdCents: + typeof cfg.threshold_amount === "number" + ? cfg.threshold_amount + : null, + rechargeToCents: + typeof cfg.recharge_to_amount === "number" + ? cfg.recharge_to_amount + : null, + } + : null; + + const stripeBillingConfigured = + data.customer_billing_provider_configuration?.billing_provider === "stripe"; + + let balanceCents = 0; + for await (const b of metronome.v1.contracts.listBalances({ + customer_id: customerId, + covering_date: new Date().toISOString(), + include_balance: true, + })) { + balanceCents += (b as unknown as { balance?: number }).balance ?? 0; + } + + return { + contractId, + customerId, + autoReload, + balanceCents, + stripeBillingConfigured, + }; +} + +// --------------------------------------------------------------------------- +// account row helpers +// --------------------------------------------------------------------------- + +type AccountRow = { + organization_id: number; + stripe_customer_id: string | null; + metronome_customer_id: string | null; + metronome_contract_id: string | null; + customer_entitled: boolean; + cached_balance_cents: number; + cached_balance_at: string | null; + auto_reload_enabled: boolean; + auto_reload_threshold_cents: number | null; + auto_reload_amount_cents: number | null; + provisioning_uid: string; +}; + +// `grida_billing` is intentionally not REST-exposed (see supabase/config.toml). +// All read/write goes through `public.fn_billing_*` RPCs, mirroring how +// `grida_billing.account` is otherwise accessed in this repo. + +export async function getAccount( + organizationId: number +): Promise { + const { data, error } = await service_role.workspace.rpc( + "fn_billing_get_metronome_account" as never, + { p_org: organizationId } as never + ); + if (error) { + throw new BillingMetronomeError(`getAccount: ${error.message}`, "db_error"); + } + const rows = (data ?? []) as AccountRow[]; + return rows[0] ?? null; +} + +async function rpcOrThrow( + label: string, + fn: string, + params: Record +): Promise { + const { error } = await service_role.workspace.rpc( + fn as never, + params as never + ); + if (error) { + throw new BillingMetronomeError(`${label}: ${error.message}`, "db_error"); + } +} + +const setMetronomeIds = (org: number, customer: string, contract: string) => + rpcOrThrow("setMetronomeIds", "fn_billing_set_metronome_ids", { + p_org: org, + p_customer_id: customer, + p_contract_id: contract, + }); + +const setBalanceCache = (org: number, cents: number, entitled: boolean) => + rpcOrThrow("setBalanceCache", "fn_billing_set_balance_cache", { + p_org: org, + p_balance_cents: cents, + p_entitled: entitled, + }); + +const setAutoReloadCache = ( + org: number, + enabled: boolean, + thresholdCents: number | null, + amountCents: number | null +) => + rpcOrThrow("setAutoReloadCache", "fn_billing_set_auto_reload", { + p_org: org, + p_enabled: enabled, + p_threshold_cents: thresholdCents, + p_amount_cents: amountCents, + }); + +// --------------------------------------------------------------------------- +// provisioning +// --------------------------------------------------------------------------- + +export type ProvisionResult = { + customerId: string; + contractId: string; + alias: string; + created: { customer: boolean; contract: boolean }; +}; + +// Match-or-create the Metronome customer + contract for an org. Idempotent. +// If the org has a stripe_customer_id (or one is passed), it's wired into the +// Metronome customer's `customer_billing_provider_configurations`. +export async function provisionOrg( + organizationId: number, + opts: { stripeCustomerId?: string } = {} +): Promise { + const sub = await getSubstrate(); + const account = await getAccount(organizationId); + if (!account) { + throw new BillingMetronomeError( + `No grida_billing.account row for org ${organizationId}.`, + "account_missing" + ); + } + const alias = aliasFor(organizationId, account.provisioning_uid); + + const stripeCustomerId = + opts.stripeCustomerId ?? account.stripe_customer_id ?? undefined; + + let customerId = account.metronome_customer_id ?? undefined; + let createdCustomer = false; + if (!customerId) { + // Look up by alias before create — survives partial-state from earlier runs. + for await (const c of metronome.v1.customers.list({ + ingest_alias: alias, + })) { + customerId = c.id; + break; + } + } + if (!customerId) { + const created = await metronome.v1.customers.create({ + name: alias, + ingest_aliases: [alias], + ...(stripeCustomerId + ? { + customer_billing_provider_configurations: [ + { + billing_provider: "stripe", + delivery_method: "direct_to_billing_provider", + configuration: { + stripe_customer_id: stripeCustomerId, + stripe_collection_method: "charge_automatically", + }, + }, + ], + } + : {}), + }); + customerId = created.data.id; + createdCustomer = true; + } + + let contractId = account.metronome_contract_id ?? undefined; + let createdContract = false; + if (!contractId) { + const list = await metronome.v2.contracts.list({ customer_id: customerId }); + const contracts = (list.data ?? []) as Array<{ + id: string; + archived_at?: string | null; + }>; + const open = contracts.find((c) => !c.archived_at); + if (open) contractId = open.id; + } + if (!contractId) { + const now = hourFloor(); + const created = await metronome.v1.contracts.create({ + customer_id: customerId, + rate_card_id: sub.rateCardId, + starting_at: now, + name: `Grida AI L1 contract`, + ...(stripeCustomerId + ? { + billing_provider_configuration: { + billing_provider: "stripe", + delivery_method: "direct_to_billing_provider", + }, + } + : {}), + }); + contractId = created.data.id; + createdContract = true; + } + + if ( + customerId !== account.metronome_customer_id || + contractId !== account.metronome_contract_id + ) { + await setMetronomeIds(organizationId, customerId, contractId); + } + + // Backfill the Stripe billing-provider config for customers + contracts + // created before Stripe was linked. Both are required for Stripe-gated + // commits. setBillingConfigurations + add_billing_provider_configuration_update + // are both idempotent on existing config (treated as "already exists"). + if (stripeCustomerId) { + try { + await metronome.v1.customers.setBillingConfigurations({ + data: [ + { + customer_id: customerId, + billing_provider: "stripe", + delivery_method: "direct_to_billing_provider", + configuration: { + stripe_customer_id: stripeCustomerId, + stripe_collection_method: "charge_automatically", + }, + }, + ], + }); + } catch (err) { + const msg = (err as Error).message; + if (!/already|exists|configured/i.test(msg)) throw err; + } + + if (!createdContract) { + try { + await metronome.v2.contracts.edit({ + customer_id: customerId, + contract_id: contractId, + add_billing_provider_configuration_update: { + billing_provider_configuration: { + billing_provider: "stripe", + delivery_method: "direct_to_billing_provider", + }, + schedule: { effective_at: "START_OF_CURRENT_PERIOD" }, + }, + }); + } catch (err) { + const msg = (err as Error).message; + // Doc: "Currently only supports adding a billing provider + // configuration to a contract that does not already have one." + if (!/already|exists|configured/i.test(msg)) throw err; + } + } + } + + // Cookbook-mandated $0 depletion alert. Best-effort — don't fail + // provision if the alert provision errors (we can re-attempt manually). + try { + await provisionLowBalanceAlert(organizationId, 0); + } catch (err) { + console.warn( + `[billing.provisionOrg] depletion alert provisioning failed: ${(err as Error).message}` + ); + } + + return { + customerId, + contractId, + alias, + created: { customer: createdCustomer, contract: createdContract }, + }; +} + +// --------------------------------------------------------------------------- +// commits — top-up flows +// --------------------------------------------------------------------------- + +// PREPAID commit with no Stripe charge — dev / promo / refund / manual grant. +export async function addComplimentaryCommit( + organizationId: number, + amountCents: number, + opts: { name?: string; priority?: number } = {} +): Promise<{ contractId: string }> { + const sub = await getSubstrate(); + const { customerId, contractId } = await provisionOrg(organizationId); + const now = hourFloor(); + + await metronome.v2.contracts.edit({ + customer_id: customerId, + contract_id: contractId, + add_commits: [ + { + product_id: sub.creditProductId, + applicable_product_ids: [sub.usageProductId], + type: "PREPAID", + name: opts.name ?? `Promo $${(amountCents / 100).toFixed(2)}`, + priority: opts.priority ?? COMMIT_PRIORITY.PROMO, + access_schedule: { + schedule_items: [ + { + amount: amountCents, + starting_at: now, + ending_before: FAR_FUTURE, + }, + ], + }, + }, + ], + }); + + return { contractId }; +} + +// Stripe-charged PREPAID commit. Metronome charges the customer's default +// payment method; on success the commit balance becomes available. Webhook +// fires the outcome. Default priority is TOPUP (drains last after promos). +export async function addStripeChargedCommit( + organizationId: number, + amountCents: number, + opts: { name?: string; priority?: number } = {} +): Promise<{ contractId: string }> { + if (amountCents < TOPUP_MIN_CENTS || amountCents > TOPUP_MAX_CENTS) { + throw new BillingMetronomeError( + `Top-up must be between $${TOPUP_MIN_CENTS / 100} and $${TOPUP_MAX_CENTS / 100}.`, + "invalid_amount", + 400 + ); + } + const sub = await getSubstrate(); + const account = await getAccount(organizationId); + if (!account?.stripe_customer_id) { + throw new BillingMetronomeError( + "Org has no linked Stripe customer; cannot Stripe-charge a commit.", + "stripe_customer_missing", + 409 + ); + } + const { customerId, contractId } = await provisionOrg(organizationId, { + stripeCustomerId: account.stripe_customer_id, + }); + const now = hourFloor(); + + await metronome.v2.contracts.edit({ + customer_id: customerId, + contract_id: contractId, + add_commits: [ + { + product_id: sub.creditProductId, + applicable_product_ids: [sub.usageProductId], + type: "PREPAID", + name: opts.name ?? `Top-up $${(amountCents / 100).toFixed(2)}`, + priority: opts.priority ?? COMMIT_PRIORITY.TOPUP, + access_schedule: { + schedule_items: [ + { + amount: amountCents, + starting_at: now, + ending_before: FAR_FUTURE, + }, + ], + }, + invoice_schedule: { + schedule_items: [{ amount: amountCents, timestamp: now }], + }, + payment_gate_config: { + payment_gate_type: "STRIPE", + stripe_config: { payment_type: "PAYMENT_INTENT" }, + tax_type: "NONE", + }, + }, + ], + }); + + return { contractId }; +} + +// --------------------------------------------------------------------------- +// auto-reload (threshold-based recharge) +// --------------------------------------------------------------------------- + +// Enable auto-reload. Write → read → reconcile pattern so the DB cache is +// never ahead of Metronome. Returns the live state for the caller to verify. +// +// Whole-dollar amounts: smaller recharges are dominated by the markup +// formula's fixed-cost component; larger ones exceed the safe envelope of +// `lib/billing/fees.ts > totalChargeForCredit`. +export async function setAutoReload( + organizationId: number, + thresholdCents: number, + rechargeAmountCents: number +): Promise { + if ( + thresholdCents < AUTO_RELOAD_THRESHOLD_MIN_CENTS || + thresholdCents % 100 !== 0 + ) { + throw new BillingMetronomeError( + `Auto-reload threshold must be a whole number of dollars and at least $${AUTO_RELOAD_THRESHOLD_MIN_CENTS / 100}.`, + "invalid_amount", + 400 + ); + } + if ( + rechargeAmountCents < AUTO_RELOAD_RECHARGE_MIN_CENTS || + rechargeAmountCents > AUTO_RELOAD_RECHARGE_MAX_CENTS || + rechargeAmountCents % 100 !== 0 + ) { + throw new BillingMetronomeError( + `Auto-reload recharge target must be a whole number of dollars between $${AUTO_RELOAD_RECHARGE_MIN_CENTS / 100} and $${AUTO_RELOAD_RECHARGE_MAX_CENTS / 100}.`, + "invalid_amount", + 400 + ); + } + if (rechargeAmountCents <= thresholdCents) { + throw new BillingMetronomeError( + "Recharge target must be greater than the threshold.", + "invalid_amount", + 400 + ); + } + const sub = await getSubstrate(); + const account = await getAccount(organizationId); + if (!account?.stripe_customer_id) { + throw new BillingMetronomeError( + "Org has no linked Stripe customer; cannot enable auto-reload.", + "stripe_customer_missing", + 409 + ); + } + const { customerId, contractId } = await provisionOrg(organizationId, { + stripeCustomerId: account.stripe_customer_id, + }); + + // First call uses `add_*`; re-enable after disable must use `update_*`. + // Both branches set `payment_type: "INVOICE"` so Stripe creates a real + // Invoice object for each silent recharge — without it, the recharge + // routes via raw PaymentIntent and never appears in `stripe.invoices.list`. + // The edit branch also (re-)pushes payment_gate_config so any pre-existing + // contract still on PAYMENT_INTENT migrates the next time the user touches + // their auto-reload settings. + const stripePaymentGate = { + payment_gate_type: "STRIPE" as const, + stripe_config: { payment_type: "INVOICE" as const }, + tax_type: "NONE" as const, + }; + const before = await readContractLive(customerId, contractId); + + if (before.autoReload) { + await metronome.v2.contracts.edit({ + customer_id: customerId, + contract_id: contractId, + update_prepaid_balance_threshold_configuration: { + is_enabled: true, + threshold_amount: thresholdCents, + recharge_to_amount: rechargeAmountCents, + payment_gate_config: stripePaymentGate, + }, + }); + } else { + // SDK type for `add_prepaid_balance_threshold_configuration.commit` is + // narrower than the API accepts (priority/name on a threshold-recharge + // commit are valid but not in the type). Cast through unknown. + type EditParams = Parameters[0]; + await metronome.v2.contracts.edit({ + customer_id: customerId, + contract_id: contractId, + add_prepaid_balance_threshold_configuration: { + threshold_amount: thresholdCents, + recharge_to_amount: rechargeAmountCents, + is_enabled: true, + payment_gate_config: stripePaymentGate, + commit: { + product_id: sub.creditProductId, + applicable_product_ids: [sub.usageProductId], + priority: COMMIT_PRIORITY.TOPUP, + name: "Auto-reload top-up", + }, + } as unknown as EditParams["add_prepaid_balance_threshold_configuration"], + }); + } + + const after = await readContractLive(customerId, contractId); + await reconcileAccountFromLive(organizationId, after); + return after; +} + +export async function disableAutoReload( + organizationId: number +): Promise { + const account = await getAccount(organizationId); + if (!account?.metronome_contract_id || !account?.metronome_customer_id) { + // Nothing on Metronome side; keep local cache as-is (already null/false). + return null; + } + try { + await metronome.v2.contracts.edit({ + customer_id: account.metronome_customer_id, + contract_id: account.metronome_contract_id, + update_prepaid_balance_threshold_configuration: { + is_enabled: false, + }, + }); + } catch { + // Best-effort: if there's no config to update, fall through. + } + const after = await readContractLive( + account.metronome_customer_id, + account.metronome_contract_id + ); + await reconcileAccountFromLive(organizationId, after); + return after; +} + +// Single place that projects Metronome's view of the contract into the DB +// cache. Called after every mutation and from the webhook projector. +export async function reconcileAccountFromLive( + organizationId: number, + live: ContractLiveState +): Promise { + await setBalanceCache( + organizationId, + live.balanceCents, + live.balanceCents >= AI_GATE_FLOOR_CENTS + ); + await setAutoReloadCache( + organizationId, + live.autoReload?.enabled ?? false, + live.autoReload?.thresholdCents ?? null, + live.autoReload?.rechargeToCents ?? null + ); +} + +// --------------------------------------------------------------------------- +// alerts ($0 balance signal — drives entitlement-flip-to-false) +// --------------------------------------------------------------------------- + +// Configure a low-balance alert at the given threshold. Idempotent — skips +// create if an alert with the same threshold already exists. The projector +// flips entitlement off only on threshold ≤ 0 (depletion). +export async function provisionLowBalanceAlert( + organizationId: number, + thresholdCents: number = 0, + opts: { name?: string } = {} +): Promise<{ created: boolean; alertId?: string }> { + const account = await getAccount(organizationId); + if (!account?.metronome_customer_id) { + throw new BillingMetronomeError( + "Org has no Metronome customer; provision first.", + "customer_missing", + 409 + ); + } + // `customers.alerts.list` returns wrapper objects of the form + // `{ alert: {id, threshold, type, ...}, customer_status, ... }`. The SDK's + // public type doesn't expose the customers.alerts namespace yet. + type AlertWrapper = { + alert?: { id: string; threshold: number; type: string; name?: string }; + customer_status?: "ok" | "in_alarm" | "evaluating"; + triggered_by?: string | null; + }; + type AlertsList = { + list(p: { customer_id: string }): AsyncIterable; + }; + const customerAlerts = ( + metronome as unknown as { v1: { customers: { alerts: AlertsList } } } + ).v1.customers.alerts; + + const existing: Array<{ id: string; threshold: number; type: string }> = []; + try { + for await (const ca of customerAlerts.list({ + customer_id: account.metronome_customer_id, + })) { + const a = ca.alert; + if (!a) continue; + existing.push({ id: a.id, threshold: a.threshold, type: a.type }); + if (existing.length >= 50) break; + } + } catch { + // tolerate listing failures; we'll just create + } + const match = existing.find( + (a) => + a.threshold === thresholdCents && + a.type?.includes("low_remaining") && + a.type === "low_remaining_contract_credit_and_commit_balance_reached" + ); + if (match) return { created: false, alertId: match.id }; + + const name = + opts.name ?? + (thresholdCents === 0 + ? "AI credit balance depleted" + : `AI credit balance below $${(thresholdCents / 100).toFixed(2)}`); + const sub = await getSubstrate(); + const created = await metronome.v1.alerts.create({ + name, + alert_type: "low_remaining_contract_credit_and_commit_balance_reached", + threshold: thresholdCents, + customer_id: account.metronome_customer_id, + credit_type_id: sub.creditTypeId, + }); + return { + created: true, + alertId: (created as unknown as { data?: { id?: string } })?.data?.id, + }; +} + +// --------------------------------------------------------------------------- +// alert status read (display) +// --------------------------------------------------------------------------- + +export type AlertStatus = { + id: string; + name: string; + /** Numeric threshold in cents (for low_remaining alerts). */ + thresholdCents: number; + /** Metronome's alert_type string. */ + alertType: string; + /** Whether the alert is currently above (ok) or below (in_alarm) threshold. */ + status: "ok" | "in_alarm" | "evaluating" | null; + /** Reason text from Metronome when triggered. */ + triggeredBy?: string | null; +}; + +// Low-balance alerts attached to the org with current evaluation status. +// Used by the insiders dev page to show which alerts have fired. +export async function getAlertsStatus( + organizationId: number +): Promise { + const account = await getAccount(organizationId); + if (!account?.metronome_customer_id) return []; + + // SDK gap — same as in provisionLowBalanceAlert. + type AlertWrapper = { + alert?: { id: string; threshold: number; type: string; name: string }; + customer_status?: "ok" | "in_alarm" | "evaluating"; + triggered_by?: string | null; + }; + type AlertsList = { + list(p: { customer_id: string }): AsyncIterable; + }; + const customerAlerts = ( + metronome as unknown as { v1: { customers: { alerts: AlertsList } } } + ).v1.customers.alerts; + + const out: AlertStatus[] = []; + try { + for await (const ca of customerAlerts.list({ + customer_id: account.metronome_customer_id, + })) { + const a = ca.alert; + if (!a) continue; + // Only surface low-remaining-balance alerts (the ones we provision). + if (!a.type?.includes("low_remaining")) continue; + out.push({ + id: a.id, + name: a.name, + thresholdCents: a.threshold, + alertType: a.type, + status: ca.customer_status ?? null, + triggeredBy: ca.triggered_by ?? null, + }); + if (out.length >= 50) break; + } + } catch { + // tolerate listing failures + } + out.sort((a, b) => a.thresholdCents - b.thresholdCents); + return out; +} + +// --------------------------------------------------------------------------- +// gate primitive — the seam will call this before every AI request +// --------------------------------------------------------------------------- + +export type Entitlement = { + /** Permitted to call AI. False below floor or before any commit. */ + allowed: boolean; + reason?: "no_balance" | "below_floor" | "not_provisioned"; + cachedBalanceCents: number; + cachedAt: string | null; +}; + +// Sub-100ms gate primitive. Reads grida_billing.account; never calls +// Metronome. Cache is updated by webhooks + the refreshBalance cron. +export async function getEntitlement( + organizationId: number +): Promise { + const account = await getAccount(organizationId); + if (!account?.metronome_customer_id) { + return { + allowed: false, + reason: "not_provisioned", + cachedBalanceCents: 0, + cachedAt: null, + }; + } + if (account.cached_balance_cents < AI_GATE_FLOOR_CENTS) { + return { + allowed: false, + reason: "below_floor", + cachedBalanceCents: account.cached_balance_cents, + cachedAt: account.cached_balance_at, + }; + } + if (!account.customer_entitled) { + return { + allowed: false, + reason: "no_balance", + cachedBalanceCents: account.cached_balance_cents, + cachedAt: account.cached_balance_at, + }; + } + return { + allowed: true, + cachedBalanceCents: account.cached_balance_cents, + cachedAt: account.cached_balance_at, + }; +} + +// Force-sync the local cache from Metronome. Called from webhook handlers +// and from the UI's "refresh" affordance. +export async function refreshBalance( + organizationId: number +): Promise<{ cents: number; live: ContractLiveState | null }> { + const account = await getAccount(organizationId); + if (!account?.metronome_customer_id || !account?.metronome_contract_id) { + return { cents: 0, live: null }; + } + const live = await readContractLive( + account.metronome_customer_id, + account.metronome_contract_id + ); + await reconcileAccountFromLive(organizationId, live); + return { cents: live.balanceCents, live }; +} + +// --------------------------------------------------------------------------- +// account view (UI primitive — DB cache + live merge + drift) +// --------------------------------------------------------------------------- + +export type AccountView = { + /** DB cache — what the gate reads. */ + db: AccountRow | null; + /** Live Metronome state — what the UI displays. */ + live: ContractLiveState | null; + /** Field-level diff. Any `true` = dropped webhook. */ + drift: { + autoReloadEnabled: boolean; + autoReloadThresholdCents: boolean; + autoReloadAmountCents: boolean; + balanceCents: boolean; + }; + cacheAgeSeconds: number | null; +}; + +// UI should prefer `live.*` over `db.*`. The `db` half is exposed for +// diagnostics and to surface drift. +export async function getAccountView( + organizationId: number +): Promise { + const db = await getAccount(organizationId); + let live: ContractLiveState | null = null; + if (db?.metronome_customer_id && db.metronome_contract_id) { + try { + live = await readContractLive( + db.metronome_customer_id, + db.metronome_contract_id + ); + } catch { + // Surface as null live; UI shows DB cache + a warning. + live = null; + } + } + + const drift = { + autoReloadEnabled: false, + autoReloadThresholdCents: false, + autoReloadAmountCents: false, + balanceCents: false, + }; + if (db && live) { + drift.autoReloadEnabled = + db.auto_reload_enabled !== (live.autoReload?.enabled ?? false); + drift.autoReloadThresholdCents = + (db.auto_reload_threshold_cents ?? null) !== + (live.autoReload?.thresholdCents ?? null); + drift.autoReloadAmountCents = + (db.auto_reload_amount_cents ?? null) !== + (live.autoReload?.rechargeToCents ?? null); + drift.balanceCents = db.cached_balance_cents !== live.balanceCents; + } + + let cacheAgeSeconds: number | null = null; + if (db?.cached_balance_at) { + cacheAgeSeconds = Math.max( + 0, + Math.round((Date.now() - new Date(db.cached_balance_at).getTime()) / 1000) + ); + } + + return { db, live, drift, cacheAgeSeconds }; +} + +// --------------------------------------------------------------------------- +// ingest (called from the AI seam after a successful provider call) +// --------------------------------------------------------------------------- + +export type IngestResult = { transactionId: string }; + +// Gate-checked ingest — what the AI seam will call. Throws on gate refusal. +export async function ingestUsageEventGated( + organizationId: number, + costMills: number, + opts: { transactionId?: string } = {} +): Promise { + const e = await getEntitlement(organizationId); + if (!e.allowed) { + throw new BillingMetronomeError( + `gate: ${e.reason ?? "blocked"} (cache=${e.cachedBalanceCents}¢)`, + "blocked", + 402 + ); + } + return ingestUsageEvent(organizationId, costMills, opts); +} + +// Ingest a usage event. Metronome dedupes on transactionId for 34 days; the +// seam should pass the provider request id. Optimistic local debit narrows +// the cache-staleness window from "until next webhook" to sub-second. The +// debit RPC also flips entitlement off when crossing the floor. +export async function ingestUsageEvent( + organizationId: number, + costMills: number, + opts: { transactionId?: string } = {} +): Promise { + if (costMills < 0) { + throw new BillingMetronomeError( + "cost_mills must be non-negative.", + "invalid_cost", + 400 + ); + } + const sub = await getSubstrate(); + const account = await getAccount(organizationId); + if (!account?.metronome_customer_id) { + throw new BillingMetronomeError( + `Org ${organizationId} has no Metronome customer; provision first.`, + "not_provisioned", + 409 + ); + } + const transactionId = opts.transactionId ?? crypto.randomUUID(); + await metronome.v1.usage.ingest({ + usage: [ + { + transaction_id: transactionId, + // Send the canonical UUID rather than an alias — robust to alias + // namespace changes and one fewer string lookup server-side. + customer_id: account.metronome_customer_id, + event_type: sub.eventType, + timestamp: new Date().toISOString(), + properties: { [sub.costProperty]: costMills }, + }, + ], + }); + + // Optimistic local debit. mills → cents (round up to avoid under-debiting + // sub-cent costs that aggregate to real money). + const costCents = Math.ceil(costMills / 10); + if (costCents > 0) { + try { + await service_role.workspace.rpc( + "fn_billing_debit_balance_cache" as never, + { + p_org: organizationId, + p_cents: costCents, + p_floor_cents: AI_GATE_FLOOR_CENTS, + } as never + ); + } catch (err) { + // Don't fail the ingest if the cache debit fails. Webhook reconcile + // will catch up. Log so we notice if it becomes systemic. + console.warn( + `[billing.ingestUsageEvent] cache debit failed for org=${organizationId}: ${(err as Error).message}` + ); + } + } + + return { transactionId }; +} + +// --------------------------------------------------------------------------- +// refund / revoke +// --------------------------------------------------------------------------- + +// Shrink a commit's access schedule to already-consumed. For voluntary refunds. +export async function revokeUnusedOnCommit( + organizationId: number, + commitId: string +): Promise<{ shrunkTo: number }> { + const account = await getAccount(organizationId); + if (!account?.metronome_customer_id) { + throw new BillingMetronomeError( + "Org not provisioned in Metronome.", + "not_provisioned", + 409 + ); + } + const customerId = account.metronome_customer_id; + + let scheduleItemId: string | undefined; + let consumed = 0; + for await (const b of metronome.v1.contracts.listBalances({ + customer_id: customerId, + include_balance: true, + })) { + if (b.id !== commitId) continue; + const bx = b as unknown as { + balance?: number; + access_schedule?: { + schedule_items?: Array<{ id?: string; amount?: number }>; + }; + }; + const item = bx.access_schedule?.schedule_items?.[0]; + scheduleItemId = item?.id; + const initial = item?.amount ?? 0; + consumed = initial - (bx.balance ?? 0); + break; + } + if (!scheduleItemId) { + throw new BillingMetronomeError( + `commit ${commitId} not found`, + "commit_not_found", + 404 + ); + } + + await metronome.v2.contracts.editCommit({ + customer_id: customerId, + commit_id: commitId, + access_schedule: { + update_schedule_items: [{ id: scheduleItemId, amount: consumed }], + }, + }); + + return { shrunkTo: consumed }; +} + +// --------------------------------------------------------------------------- +// balance read (display) +// --------------------------------------------------------------------------- + +export type Balance = { + totalCents: number; + commits: Array<{ + id: string; + name?: string; + priority?: number; + balance: number; + initial?: number; + /** Commit creation timestamp (ISO). The user-facing "when did this + * happen" — distinct from `startingAt` which is the schedule-item + * start (we hour-floor that for billing alignment). */ + createdAt?: string; + /** Schedule-item start (ISO). Hour-floored. */ + startingAt?: string; + /** Schedule-item end (ISO). Far-future for top-ups. */ + endingBefore?: string; + }>; +}; + +export async function getOrgBalance(organizationId: number): Promise { + const account = await getAccount(organizationId); + if (!account?.metronome_customer_id) { + return { totalCents: 0, commits: [] }; + } + // SDK type for listBalances is a union (Commit | Credit) that doesn't + // expose `balance`, `name`, `priority`, `created_at` consistently. + type BalanceRow = { + id: string; + balance?: number; + name?: string; + priority?: number; + created_at?: string; + access_schedule?: { + schedule_items?: Array<{ + amount?: number; + starting_at?: string; + ending_before?: string; + }>; + }; + }; + const commits: Balance["commits"] = []; + for await (const b of metronome.v1.contracts.listBalances({ + customer_id: account.metronome_customer_id, + covering_date: new Date().toISOString(), + include_balance: true, + })) { + const bx = b as unknown as BalanceRow; + const item = bx.access_schedule?.schedule_items?.[0]; + commits.push({ + id: bx.id, + name: bx.name, + priority: bx.priority, + balance: bx.balance ?? 0, + initial: item?.amount, + createdAt: bx.created_at, + startingAt: item?.starting_at, + endingBefore: item?.ending_before, + }); + } + const totalCents = commits.reduce((sum, c) => sum + (c.balance ?? 0), 0); + return { totalCents, commits }; +} + +// --------------------------------------------------------------------------- +// user-facing translations — Metronome primitives → customer vocabulary +// --------------------------------------------------------------------------- + +export type TransactionKind = + | "topup" // Stripe-charged top-up commit + | "auto_reload" // Stripe-charged auto-reload commit + | "promo" // Complimentary / refund / manual grant + | "unknown"; + +export type Transaction = { + /** Source commit id (Metronome). Internal — UI may show or hide. */ + sourceId: string; + kind: TransactionKind; + at: string | null; + /** Money-in amount in cents. Always positive. */ + amountCents: number; + /** Remaining balance on this transaction's bucket. For "money in" entries. */ + remainingCents: number; + /** User-facing label, e.g., "Top-up", "Auto-reload", "Promo". */ + description: string; + /** Whether the customer paid for this entry (true for top-up/auto-reload). */ + paid: boolean; +}; + +function classifyCommit( + name: string | undefined, + priority: number | undefined +): TransactionKind { + const n = (name ?? "").toLowerCase(); + if (n.includes("auto-reload") || n.includes("auto reload")) + return "auto_reload"; + if (priority !== undefined) { + if (priority >= 80) return "topup"; + return "promo"; + } + if (n.includes("top-up") || n.includes("topup")) return "topup"; + return "unknown"; +} + +function descriptionFor(kind: TransactionKind, name?: string): string { + switch (kind) { + case "topup": + return "Top-up"; + case "auto_reload": + return "Auto-reload"; + case "promo": + return name ?? "Promo"; + default: + return name ?? "Credit"; + } +} + +// Money-in feed derived from commits. Most-recent first. +export async function getTransactions( + organizationId: number +): Promise { + const { commits } = await getOrgBalance(organizationId); + const txns = commits.map((c) => { + const kind = classifyCommit(c.name, c.priority); + return { + sourceId: c.id, + kind, + // Prefer the commit's actual `created_at`; fall back to schedule-item + // start. The latter is hour-floored for Metronome billing alignment + // and would render every fresh transaction as up to 59m old. + at: c.createdAt ?? c.startingAt ?? null, + amountCents: c.initial ?? 0, + remainingCents: c.balance, + description: descriptionFor(kind, c.name), + paid: kind === "topup" || kind === "auto_reload", + }; + }); + txns.sort((a, b) => { + const ta = a.at ? Date.parse(a.at) : 0; + const tb = b.at ? Date.parse(b.at) : 0; + return tb - ta; + }); + return txns; +} + +// --------------------------------------------------------------------------- +// invoices (Metronome — payer side; carries Stripe linkage in external_invoice) +// --------------------------------------------------------------------------- + +export type InvoiceView = { + id: string; + status: string; + type: string; + totalCents: number; + subtotalCents: number | null; + issuedAt: string | null; + startTimestamp: string | null; + endTimestamp: string | null; + /** Top line items (truncated). */ + lineItems: Array<{ name: string; totalCents: number }>; + /** Stripe link if Metronome routed this invoice to Stripe. */ + external?: { + provider: string; + status?: string; + paymentId?: string; + /** PDF from the billing provider (rare for PaymentIntent flow). */ + pdfUrl?: string; + /** Public, signed Stripe receipt URL (downloadable PDF for the user). */ + receiptUrl?: string; + /** Stripe Dashboard deep link (admin/dev only). */ + dashboardUrl?: string; + }; +}; + +export async function getInvoices( + organizationId: number, + limit: number = 25 +): Promise { + const account = await getAccount(organizationId); + if (!account?.metronome_customer_id) return []; + // Public SDK type misses several fields the API does return. + type InvoiceExtra = { + external_invoice?: { + billing_provider_type: string; + external_status?: string; + external_payment_id?: string; + pdf_url?: string; + }; + start_timestamp?: string; + end_timestamp?: string; + }; + type LineItemExtra = { name: string; total: number }; + + const out: InvoiceView[] = []; + for await (const inv of metronome.v1.customers.invoices.list({ + customer_id: account.metronome_customer_id, + })) { + const invx = inv as unknown as InvoiceExtra; + const ext = invx.external_invoice; + out.push({ + id: inv.id, + status: inv.status, + type: inv.type, + totalCents: inv.total ?? 0, + subtotalCents: inv.subtotal ?? null, + issuedAt: inv.issued_at ?? null, + startTimestamp: invx.start_timestamp ?? null, + endTimestamp: invx.end_timestamp ?? null, + lineItems: (inv.line_items ?? []).slice(0, 10).map((li) => { + const lx = li as unknown as LineItemExtra; + return { name: lx.name, totalCents: lx.total }; + }), + external: ext + ? { + provider: ext.billing_provider_type, + status: ext.external_status, + paymentId: ext.external_payment_id, + pdfUrl: ext.pdf_url, + } + : undefined, + }); + if (out.length >= limit) break; + } + + // Enrich Stripe-routed invoices with the receipt URL (public PDF) and + // a Dashboard deep link. Stripe round-trips in parallel; failures are + // swallowed so a single bad invoice doesn't break the listing. + const isLive = !(process.env.STRIPE_SECRET_KEY ?? "").startsWith("sk_test_"); + const dashRoot = isLive + ? "https://dashboard.stripe.com" + : "https://dashboard.stripe.com/test"; + + await Promise.all( + out.map(async (v) => { + const ext = v.external; + if (!ext || ext.provider !== "stripe" || !ext.paymentId) return; + try { + if (ext.paymentId.startsWith("pi_")) { + const pi = await stripe.paymentIntents.retrieve(ext.paymentId, { + expand: ["latest_charge"], + }); + // `latest_charge` is `string | Charge` after expansion. + const ch = pi.latest_charge as + | { receipt_url?: string | null } + | string + | null + | undefined; + ext.receiptUrl = + ch && typeof ch !== "string" + ? (ch.receipt_url ?? undefined) + : undefined; + ext.dashboardUrl = `${dashRoot}/payments/${ext.paymentId}`; + } else if (ext.paymentId.startsWith("ch_")) { + const ch = await stripe.charges.retrieve(ext.paymentId); + ext.receiptUrl = ch.receipt_url ?? undefined; + ext.dashboardUrl = `${dashRoot}/payments/${ext.paymentId}`; + } else if (ext.paymentId.startsWith("in_")) { + // Stripe Invoice (Metronome with payment_type=INVOICE). + const sinv = await stripe.invoices.retrieve(ext.paymentId); + ext.receiptUrl = sinv.hosted_invoice_url ?? undefined; + ext.pdfUrl = ext.pdfUrl ?? sinv.invoice_pdf ?? undefined; + ext.dashboardUrl = `${dashRoot}/invoices/${ext.paymentId}`; + } + } catch { + // Best-effort enrichment; leave as-is on failure. + } + }) + ); + + return out; +} + +// Metronome-rendered invoice PDF as base64. Server-action friendly: caller +// decodes and triggers the download client-side. +export async function getInvoicePdfBase64( + organizationId: number, + invoiceId: string +): Promise<{ filename: string; dataB64: string }> { + const account = await getAccount(organizationId); + if (!account?.metronome_customer_id) { + throw new BillingMetronomeError( + "Org not provisioned in Metronome.", + "not_provisioned", + 409 + ); + } + const res = await metronome.v1.customers.invoices.retrievePdf({ + customer_id: account.metronome_customer_id, + invoice_id: invoiceId, + }); + const ab = await res.arrayBuffer(); + return { + filename: `invoice-${invoiceId.slice(0, 8)}.pdf`, + dataB64: Buffer.from(ab).toString("base64"), + }; +} + +// --------------------------------------------------------------------------- +// Stripe Checkout post-processor for AI credit flows. +// +// User-facing AI credit buys (top-up, auto-reload enable) go through Stripe +// Checkout: Stripe collects, our webhook lands the equivalent Metronome +// credit. Idempotency via event_id dedup; we name the commit with the +// Stripe payment_intent id so a manual replay is visible. +// --------------------------------------------------------------------------- + +export async function handleAiCreditCheckoutCompleted(session: { + id?: string; + payment_intent?: string | null; + payment_status?: string | null; + metadata?: Record | null; + amount_total?: number | null; +}): Promise<{ result: "applied" | "skipped" | "noop"; detail?: string }> { + const meta = session.metadata ?? {}; + const kind = meta.kind; + if ( + kind !== AI_CHECKOUT_KIND.TOPUP && + kind !== AI_CHECKOUT_KIND.AUTO_RELOAD_ENABLE + ) { + return { result: "noop", detail: `kind=${kind ?? "(none)"}` }; + } + if (session.payment_status !== "paid") { + return { + result: "skipped", + detail: `payment_status=${session.payment_status}`, + }; + } + const orgIdRaw = meta.grida_organization_id; + const orgId = orgIdRaw ? parseInt(orgIdRaw, 10) : NaN; + if (!Number.isFinite(orgId)) { + return { result: "skipped", detail: "missing org id" }; + } + + const piTag = session.payment_intent + ? ` (pi:${session.payment_intent.slice(0, 12)})` + : ""; + + if (kind === AI_CHECKOUT_KIND.TOPUP) { + const cents = parseInt(meta.cents ?? "", 10); + if (!Number.isFinite(cents) || cents <= 0) { + return { result: "skipped", detail: "invalid cents" }; + } + // Stripe already collected the money — credit Metronome with a + // priority-TOPUP commit (drains last) but no payment_gate (we + // don't want Metronome to double-charge). + await addComplimentaryCommit(orgId, cents, { + name: `Top-up $${(cents / 100).toFixed(2)}${piTag}`, + priority: COMMIT_PRIORITY.TOPUP, + }); + // Reconcile the local cache inline so the gate flips before the user + // returns from Stripe Checkout. Without this, customer_entitled stays + // false until the Metronome `commit.create` webhook propagates back — + // and the return page's polling races that delivery. + await refreshBalance(orgId); + return { result: "applied", detail: `topup $${(cents / 100).toFixed(2)}` }; + } + + // kind === AI_CHECKOUT_KIND.AUTO_RELOAD_ENABLE + const threshold = parseInt(meta.threshold_cents ?? "", 10); + const recharge = parseInt(meta.recharge_to_cents ?? "", 10); + if ( + !Number.isFinite(threshold) || + threshold < AUTO_RELOAD_THRESHOLD_MIN_CENTS || + !Number.isFinite(recharge) || + recharge < AUTO_RELOAD_RECHARGE_MIN_CENTS + ) { + return { result: "skipped", detail: "invalid auto-reload params" }; + } + // Initial $recharge credit (Stripe collected it via Checkout) + + // configure the threshold for future silent auto-recharges via the + // saved card. + await addComplimentaryCommit(orgId, recharge, { + name: `Auto-reload initial $${(recharge / 100).toFixed(2)}${piTag}`, + priority: COMMIT_PRIORITY.TOPUP, + }); + await setAutoReload(orgId, threshold, recharge); + return { + result: "applied", + detail: `auto_reload threshold=$${(threshold / 100).toFixed(2)} recharge=$${(recharge / 100).toFixed(2)}`, + }; +} diff --git a/editor/lib/billing/plans.ts b/editor/lib/billing/plans.ts index 1d6bd91b4..538f53da7 100644 --- a/editor/lib/billing/plans.ts +++ b/editor/lib/billing/plans.ts @@ -45,7 +45,6 @@ export const PAID_PLANS: Readonly> = { annual_cents: 19200, features: [ "Stripe-managed billing & invoices", - "Higher monthly AI allowance", "Cancel or switch plans anytime via the Customer Portal", ], }, diff --git a/editor/package.json b/editor/package.json index 1245536d7..3cae1a37a 100644 --- a/editor/package.json +++ b/editor/package.json @@ -53,6 +53,7 @@ "@hookform/resolvers": "^5.2.2", "@mdx-js/loader": "^3.0.1", "@mdx-js/react": "^3.1.0", + "@metronome/sdk": "^3.5.0", "@monaco-editor/react": "^4.6.0", "@next/mdx": "^16", "@next/third-parties": "^16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4b0d4c84..db88984bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -424,6 +424,9 @@ importers: '@mdx-js/react': specifier: ^3.1.0 version: 3.1.1(@types/react@19.1.3)(react@19.2.5) + '@metronome/sdk': + specifier: ^3.5.0 + version: 3.5.0 '@monaco-editor/react': specifier: ^4.6.0 version: 4.7.0(monaco-editor@0.47.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -3616,6 +3619,10 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@metronome/sdk@3.5.0': + resolution: {integrity: sha512-QKTpmHW1KddyyUl1S+//fzBg5vZVH4XqLhQq2Mkj+aJyEMN0BCfnK998mJB436XPXj/c7y5f8FnGhW6pjFzMvQ==} + hasBin: true + '@modelcontextprotocol/sdk@1.26.0': resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} engines: {node: '>=18'} @@ -17597,6 +17604,8 @@ snapshots: dependencies: langium: 3.3.1 + '@metronome/sdk@3.5.0': {} + '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.9(hono@4.11.8) diff --git a/supabase/migrations/20260508130000_grida_billing_metronome.sql b/supabase/migrations/20260508130000_grida_billing_metronome.sql new file mode 100644 index 000000000..2f32c1a21 --- /dev/null +++ b/supabase/migrations/20260508130000_grida_billing_metronome.sql @@ -0,0 +1,416 @@ +-- AI credits — Metronome integration layer. +-- +-- Adds on top of the base grida_billing schema (20260506132900): +-- 1. `account.provisioning_uid` — namespaces external billing identities +-- so orphan cloud-side customers from prior `supabase db reset` runs +-- become inert. +-- 2. `account.metronome_*` + entitlement / balance-cache / auto-reload +-- columns — the per-org state needed to gate AI calls and trigger +-- Metronome's threshold-recharge. +-- 3. `metronome_event` dedup table — at-least-once webhook delivery. +-- 4. `public.fn_billing_apply_metronome_event` — projector. Multi-tier +-- alert aware: only depletion-tier (threshold ≤ 0) flips entitlement; +-- warning tiers refresh balance only. +-- 5. Service-role RPCs for the TS layer to read/write the row, look up +-- org by metronome_customer_id, list recent webhook events, and +-- atomically debit the balance cache after a successful ingest. +-- +-- See docs/wg/platform/ai-credits.md for the architectural rationale. + +BEGIN; + +-- ============================================================================ +-- 1. account columns +-- ============================================================================ + +-- gen_random_uuid() lives in pgcrypto; standard on Supabase. Volatile, so PG +-- fills in a distinct UUID for every existing row at ALTER time. No backfill +-- needed. +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +ALTER TABLE grida_billing.account + ADD COLUMN IF NOT EXISTS provisioning_uid uuid NOT NULL DEFAULT gen_random_uuid(), + ADD COLUMN IF NOT EXISTS metronome_customer_id text UNIQUE, + ADD COLUMN IF NOT EXISTS metronome_contract_id text, + ADD COLUMN IF NOT EXISTS customer_entitled boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS cached_balance_cents bigint NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS cached_balance_at timestamptz, + ADD COLUMN IF NOT EXISTS auto_reload_enabled boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS auto_reload_threshold_cents integer, + ADD COLUMN IF NOT EXISTS auto_reload_amount_cents integer; + +COMMENT ON COLUMN grida_billing.account.provisioning_uid IS + 'Per-account UUID used to namespace identities in external billing ' + 'systems (Metronome ingest_alias, etc). Non-deterministic by design — ' + 'regenerated on every fresh row. Purpose: prevents silent reuse of ' + 'orphan cloud-side customers across local DB resets. Stable for the ' + 'lifetime of the row in production.'; + +COMMENT ON COLUMN grida_billing.account.metronome_customer_id IS + 'Metronome customer id linked to this org. Set once at first paid intent.'; + +COMMENT ON COLUMN grida_billing.account.metronome_contract_id IS + 'Metronome contract id. The contract holds commits and recurring credits.'; + +COMMENT ON COLUMN grida_billing.account.customer_entitled IS + 'Gate decision cache. True when AI calls are allowed. Flipped by webhook handlers.'; + +COMMENT ON COLUMN grida_billing.account.cached_balance_cents IS + 'Last-known credit balance from Metronome, in cents. Display + safety check.'; + +COMMENT ON COLUMN grida_billing.account.cached_balance_at IS + 'When cached_balance_cents was last refreshed. Stale readings trigger a sync.'; + +COMMENT ON COLUMN grida_billing.account.auto_reload_enabled IS + 'When true, Metronome auto-charges to refill balance once it drops below threshold.'; + + +-- ============================================================================ +-- 2. metronome_event — webhook dedup +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS grida_billing.metronome_event ( + event_id text PRIMARY KEY, + event_type text NOT NULL, + received_at timestamptz NOT NULL DEFAULT now(), + processed_at timestamptz, + failure_reason text, + payload jsonb NOT NULL +); + +CREATE INDEX IF NOT EXISTS metronome_event_type_idx + ON grida_billing.metronome_event (event_type, received_at DESC); + +ALTER TABLE grida_billing.metronome_event ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON TABLE grida_billing.metronome_event FROM anon, authenticated; +GRANT ALL ON TABLE grida_billing.metronome_event TO service_role; + +COMMENT ON TABLE grida_billing.metronome_event IS + 'Metronome webhook event log. PK on event_id is the dedup boundary.'; + + +-- ============================================================================ +-- 3. public.fn_billing_apply_metronome_event — projector +-- +-- Same idiom as fn_billing_apply_stripe_event: SECURITY DEFINER, idempotent +-- on event_id. Returns ('processed' | 'replayed' | 'unhandled', handler). +-- +-- Multi-tier-aware: a $50 warning alert mustn't block the user the same as +-- a $0 depletion alert. Reads threshold from the payload defensively (key +-- path varies by event type); missing → treat as depletion (safe default). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fn_billing_apply_metronome_event( + p_event_id text, + p_event_type text, + p_payload jsonb +) +RETURNS TABLE (result text, handler text) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + v_customer_id text; + v_org_id bigint; + v_existing_processed_at timestamptz; + v_threshold bigint; + v_remaining bigint; + v_is_depletion boolean; +BEGIN + -- Insert event row; on conflict it's a replay. + INSERT INTO grida_billing.metronome_event (event_id, event_type, payload) + VALUES (p_event_id, p_event_type, p_payload) + ON CONFLICT (event_id) DO NOTHING; + + SELECT processed_at INTO v_existing_processed_at + FROM grida_billing.metronome_event + WHERE event_id = p_event_id; + + IF v_existing_processed_at IS NOT NULL THEN + RETURN QUERY SELECT 'replayed'::text, p_event_type::text; + RETURN; + END IF; + + -- Resolve the affected org by metronome_customer_id, when present. + v_customer_id := p_payload->'properties'->>'customer_id'; + IF v_customer_id IS NOT NULL THEN + SELECT a.organization_id INTO v_org_id + FROM grida_billing.account a + WHERE a.metronome_customer_id = v_customer_id; + END IF; + + -- Dispatch. + IF p_event_type = 'payment_gate.payment_status' THEN + -- Successful Stripe charge → entitle the org. + -- Failure → leave entitlement as-is (the previous balance still holds). + IF v_org_id IS NOT NULL + AND p_payload->'properties'->>'payment_status' = 'paid' THEN + UPDATE grida_billing.account + SET customer_entitled = true, + updated_at = now() + WHERE organization_id = v_org_id; + END IF; + RETURN QUERY SELECT 'processed'::text, 'payment_gate.payment_status'::text; + + ELSIF p_event_type LIKE 'alerts.%' AND p_event_type LIKE '%balance%reached' THEN + IF v_org_id IS NOT NULL THEN + v_threshold := COALESCE( + NULLIF(p_payload->'properties'->'alert'->>'threshold', '')::bigint, + NULLIF(p_payload->'properties'->>'threshold', '')::bigint, + NULLIF(p_payload->>'threshold', '')::bigint + ); + v_remaining := COALESCE( + NULLIF(p_payload->'properties'->>'remaining_balance', '')::bigint, + NULLIF(p_payload->'properties'->>'balance', '')::bigint + ); + -- If we couldn't read threshold, treat as depletion (safe default). + v_is_depletion := COALESCE(v_threshold, 0) <= 0; + + UPDATE grida_billing.account + SET cached_balance_cents = COALESCE(v_remaining, cached_balance_cents), + cached_balance_at = now(), + customer_entitled = CASE + WHEN v_is_depletion THEN false + ELSE customer_entitled + END, + updated_at = now() + WHERE organization_id = v_org_id; + END IF; + RETURN QUERY SELECT 'processed'::text, p_event_type::text; + + ELSIF p_event_type IN ( + 'commit.create', 'commit.edit', 'commit.archive', + 'commit.segment.start', 'commit.segment.end', + 'credit.create', 'credit.edit', 'credit.archive', + 'credit.segment.start', 'credit.segment.end', + 'contract.start', 'contract.edit', 'contract.end' + ) THEN + -- Lifecycle event — no entitlement change. The TS layer handles + -- balance cache refresh via direct Metronome read after these. + RETURN QUERY SELECT 'processed'::text, 'lifecycle'::text; + + ELSIF p_event_type = 'webhooks.test' THEN + RETURN QUERY SELECT 'processed'::text, 'webhooks.test'::text; + + ELSE + RETURN QUERY SELECT 'unhandled'::text, p_event_type::text; + END IF; + + -- Stamp processed_at on the row (only when we matched a handler). + UPDATE grida_billing.metronome_event + SET processed_at = now() + WHERE event_id = p_event_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_apply_metronome_event(text, text, jsonb) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_apply_metronome_event(text, text, jsonb) TO service_role; + + +-- ============================================================================ +-- 4. Service-role RPCs (TS layer reads/writes go through these — grida_billing +-- is intentionally not REST-exposed). +-- ============================================================================ + +-- Typed read of the account row. +CREATE OR REPLACE FUNCTION public.fn_billing_get_metronome_account(p_org bigint) +RETURNS TABLE ( + organization_id bigint, + stripe_customer_id text, + metronome_customer_id text, + metronome_contract_id text, + customer_entitled boolean, + cached_balance_cents bigint, + cached_balance_at timestamptz, + auto_reload_enabled boolean, + auto_reload_threshold_cents integer, + auto_reload_amount_cents integer, + provisioning_uid uuid +) +LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public +AS $$ + SELECT a.organization_id, a.stripe_customer_id, a.metronome_customer_id, + a.metronome_contract_id, a.customer_entitled, a.cached_balance_cents, + a.cached_balance_at, a.auto_reload_enabled, + a.auto_reload_threshold_cents, a.auto_reload_amount_cents, + a.provisioning_uid + FROM grida_billing.account a + WHERE a.organization_id = p_org; +$$; +REVOKE ALL ON FUNCTION public.fn_billing_get_metronome_account(bigint) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_get_metronome_account(bigint) TO service_role; + + +-- Link Metronome customer + contract to an org. +CREATE OR REPLACE FUNCTION public.fn_billing_set_metronome_ids( + p_org bigint, p_customer_id text, p_contract_id text +) +RETURNS void +LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public +AS $$ + UPDATE grida_billing.account + SET metronome_customer_id = p_customer_id, + metronome_contract_id = p_contract_id, + updated_at = now() + WHERE organization_id = p_org; +$$; +REVOKE ALL ON FUNCTION public.fn_billing_set_metronome_ids(bigint, text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_set_metronome_ids(bigint, text, text) TO service_role; + + +-- Refresh the gate-decision cache. +CREATE OR REPLACE FUNCTION public.fn_billing_set_balance_cache( + p_org bigint, p_balance_cents bigint, p_entitled boolean +) +RETURNS void +LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public +AS $$ + UPDATE grida_billing.account + SET cached_balance_cents = p_balance_cents, + cached_balance_at = now(), + customer_entitled = p_entitled, + updated_at = now() + WHERE organization_id = p_org; +$$; +REVOKE ALL ON FUNCTION public.fn_billing_set_balance_cache(bigint, bigint, boolean) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_set_balance_cache(bigint, bigint, boolean) TO service_role; + + +-- Toggle and configure auto-reload. +CREATE OR REPLACE FUNCTION public.fn_billing_set_auto_reload( + p_org bigint, p_enabled boolean, p_threshold_cents integer, p_amount_cents integer +) +RETURNS void +LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public +AS $$ + UPDATE grida_billing.account + SET auto_reload_enabled = p_enabled, + auto_reload_threshold_cents = p_threshold_cents, + auto_reload_amount_cents = p_amount_cents, + updated_at = now() + WHERE organization_id = p_org; +$$; +REVOKE ALL ON FUNCTION public.fn_billing_set_auto_reload(bigint, boolean, integer, integer) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_set_auto_reload(bigint, boolean, integer, integer) TO service_role; + + +-- Lookup helper for the webhook receiver. +CREATE OR REPLACE FUNCTION public.fn_billing_resolve_org_by_metronome_customer(p_customer_id text) +RETURNS bigint +LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public +AS $$ + SELECT a.organization_id + FROM grida_billing.account a + WHERE a.metronome_customer_id = p_customer_id + LIMIT 1; +$$; +REVOKE ALL ON FUNCTION public.fn_billing_resolve_org_by_metronome_customer(text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_resolve_org_by_metronome_customer(text) TO service_role; + + +-- List orgs that are wired to a Metronome customer + contract. Used by the +-- hourly reconcile cron — sweeping only provisioned orgs avoids O(orgs) +-- Metronome calls/hour as the unprovisioned tail grows. +CREATE OR REPLACE FUNCTION public.fn_billing_list_provisioned_orgs() +RETURNS TABLE (organization_id bigint) +LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public +AS $$ + SELECT a.organization_id + FROM grida_billing.account a + WHERE a.metronome_customer_id IS NOT NULL + AND a.metronome_contract_id IS NOT NULL + ORDER BY a.organization_id; +$$; +REVOKE ALL ON FUNCTION public.fn_billing_list_provisioned_orgs() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_list_provisioned_orgs() TO service_role; + + +-- Recent webhook log for QA pages. Filtered by customer (resolves to org) +-- when provided. +CREATE OR REPLACE FUNCTION public.fn_billing_list_metronome_events( + p_org bigint DEFAULT NULL, p_limit integer DEFAULT 20 +) +RETURNS TABLE ( + event_id text, + event_type text, + received_at timestamptz, + processed_at timestamptz, + failure_reason text, + customer_id text, + payment_status text +) +LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public +AS $$ + SELECT e.event_id, + e.event_type, + e.received_at, + e.processed_at, + e.failure_reason, + e.payload->'properties'->>'customer_id' AS customer_id, + e.payload->'properties'->>'payment_status' AS payment_status + FROM grida_billing.metronome_event e + WHERE p_org IS NULL OR EXISTS ( + SELECT 1 FROM grida_billing.account a + WHERE a.organization_id = p_org + AND a.metronome_customer_id = e.payload->'properties'->>'customer_id' + ) + ORDER BY e.received_at DESC + LIMIT GREATEST(1, LEAST(p_limit, 200)); +$$; +REVOKE ALL ON FUNCTION public.fn_billing_list_metronome_events(bigint, integer) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_list_metronome_events(bigint, integer) TO service_role; + + +-- ============================================================================ +-- 5. Optimistic local debit RPC +-- +-- The webhook only updates the cache on alert events; during steady-state +-- usage no webhook fires per-event, so the cache lies until the next +-- reconciliation. This RPC lets the ingest path debit the cache atomically +-- right after a successful Metronome ingest. Floors at 0; proactively flips +-- entitlement off when crossing the floor so the gate refuses without +-- waiting for the alerts.low_remaining_* webhook. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fn_billing_debit_balance_cache( + p_org bigint, + p_cents bigint, + p_floor_cents bigint DEFAULT 25 +) +RETURNS TABLE (cached_balance_cents bigint, customer_entitled boolean) +LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public +AS $$ +DECLARE + v_new_balance bigint; + v_entitled boolean; +BEGIN + IF p_cents < 0 THEN + RAISE EXCEPTION 'p_cents must be non-negative (got %)', p_cents; + END IF; + + UPDATE grida_billing.account a + SET cached_balance_cents = + GREATEST(0::bigint, a.cached_balance_cents - p_cents), + customer_entitled = CASE + WHEN GREATEST(0::bigint, a.cached_balance_cents - p_cents) < p_floor_cents + THEN false + ELSE a.customer_entitled + END, + cached_balance_at = now(), + updated_at = now() + WHERE a.organization_id = p_org + RETURNING a.cached_balance_cents, a.customer_entitled + INTO v_new_balance, v_entitled; + + RETURN QUERY SELECT v_new_balance, v_entitled; +END; +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_debit_balance_cache(bigint, bigint, bigint) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_debit_balance_cache(bigint, bigint, bigint) TO service_role; + +COMMENT ON FUNCTION public.fn_billing_debit_balance_cache(bigint, bigint, bigint) IS + 'Atomic optimistic debit of the cached balance after a successful usage ingest. Webhook reconciles to ground truth later.'; + +COMMIT; diff --git a/supabase/schemas/grida_billing.sql b/supabase/schemas/grida_billing.sql index be8e2d85e..ceea52bad 100644 --- a/supabase/schemas/grida_billing.sql +++ b/supabase/schemas/grida_billing.sql @@ -49,6 +49,12 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA grida_billing REVOKE EXECUTE ON ROUTINES FROM CREATE TABLE grida_billing.account ( organization_id bigint PRIMARY KEY REFERENCES public.organization(id) ON DELETE CASCADE, stripe_customer_id text UNIQUE, + -- Per-account UUID used to namespace identities in external billing + -- systems (Metronome ingest_alias, etc). Non-deterministic by design + -- — regenerated on every fresh row. Purpose: prevents silent reuse + -- of orphan cloud-side customers across local DB resets. Stable for + -- the lifetime of the row in production. + provisioning_uid uuid NOT NULL DEFAULT gen_random_uuid(), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); From e79107b4884ae2a12afdd1e32d562d62bc6dd2d0 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 11 May 2026 04:24:18 +0900 Subject: [PATCH 04/21] feat(billing): Metronome + Stripe ai-credit webhooks and reconcile cron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Metronome receiver: HMAC-SHA256 over Date+raw-body, 5-min freshness, dedup via the projector's PK on event_id, payment_gate failure logged with reason, post-projector refreshBalance for commit-affecting events. Stripe receiver: extends the existing projector with two ai-credit branches — checkout.session.completed (kind=ai_topup or ai_auto_reload_enable) routes to handleAiCreditCheckoutCompleted; customer.subscription.deleted disables auto-reload (KI-BILL-001 mitigation: never silent-recharge a canceled org). Errors return 500 so Stripe retries instead of leaving the customer charged with no credit. Reconcile cron: hourly /internal/cron/billing-reconcile sweeps only provisioned orgs (via fn_billing_list_provisioned_orgs) with concurrency 4. --- .../internal/cron/billing-reconcile/route.ts | 94 +++++++++ .../app/(ingest)/webhooks/metronome/route.ts | 198 ++++++++++++++++++ editor/app/(ingest)/webhooks/stripe/route.ts | 72 +++++++ editor/vercel.json | 8 +- 4 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 editor/app/(api)/internal/cron/billing-reconcile/route.ts create mode 100644 editor/app/(ingest)/webhooks/metronome/route.ts diff --git a/editor/app/(api)/internal/cron/billing-reconcile/route.ts b/editor/app/(api)/internal/cron/billing-reconcile/route.ts new file mode 100644 index 000000000..2f790eeca --- /dev/null +++ b/editor/app/(api)/internal/cron/billing-reconcile/route.ts @@ -0,0 +1,94 @@ +/** + * Billing reconcile cron — sweeps every provisioned org and pulls a + * fresh balance read from Metronome into the local cache. Catches + * missed webhooks (rare; Metronome's at-least-once delivery + our HMAC + * dedup means the cache occasionally lags). + * + * Auth: requires `CRON_SECRET` to match `Authorization: Bearer ` + * (or `?secret=` for browser ad-hoc). Configure the same secret in + * Vercel cron settings. + * + * Schedule: see `editor/vercel.json` — runs hourly. + * + * Idempotent: `refreshBalance` is a write→read→reconcile against + * Metronome live state. Safe to run more often than once an hour. + */ + +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { service_role } from "@/lib/supabase/server"; +import { refreshBalance } from "@/lib/billing/metronome"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const CONCURRENCY = 4; + +function authorized(req: NextRequest): boolean { + const secret = process.env.CRON_SECRET; + if (!secret) return false; + const header = req.headers.get("authorization"); + if (header && header === `Bearer ${secret}`) return true; + const url = new URL(req.url); + if (url.searchParams.get("secret") === secret) return true; + return false; +} + +async function reconcileBatch(orgIds: number[]) { + let ok = 0; + let failed = 0; + for (let i = 0; i < orgIds.length; i += CONCURRENCY) { + const chunk = orgIds.slice(i, i + CONCURRENCY); + const results = await Promise.allSettled( + chunk.map((id) => refreshBalance(id)) + ); + for (const r of results) { + if (r.status === "fulfilled") ok++; + else failed++; + } + } + return { ok, failed }; +} + +export async function GET(req: NextRequest) { + return handle(req); +} + +export async function POST(req: NextRequest) { + return handle(req); +} + +async function handle(req: NextRequest) { + if (!authorized(req)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + // Sweep only orgs already wired to Metronome. Earlier this iterated every + // public.organization row; that worked because `refreshBalance` is a no-op + // for unprovisioned orgs, but it scaled badly (O(orgs) per hour with a + // long unprovisioned tail). + const { data, error } = await service_role.workspace.rpc( + "fn_billing_list_provisioned_orgs" as never + ); + if (error) { + console.error("[cron/billing-reconcile] list orgs:", error.message); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + const orgIds = ((data ?? []) as Array<{ organization_id: number }>) + .map((r) => r.organization_id) + .filter((id): id is number => Number.isFinite(id)); + + const startedAt = Date.now(); + const { ok, failed } = await reconcileBatch(orgIds); + const ms = Date.now() - startedAt; + + console.log( + `[cron/billing-reconcile] provisioned=${orgIds.length} ok=${ok} failed=${failed} ms=${ms}` + ); + return NextResponse.json({ + provisioned: orgIds.length, + ok, + failed, + duration_ms: ms, + }); +} diff --git a/editor/app/(ingest)/webhooks/metronome/route.ts b/editor/app/(ingest)/webhooks/metronome/route.ts new file mode 100644 index 000000000..edcb4a069 --- /dev/null +++ b/editor/app/(ingest)/webhooks/metronome/route.ts @@ -0,0 +1,198 @@ +/** + * Metronome webhook receiver. + * + * `GRIDA-SEC-001` — see `editor/app/(ingest)/README.md` for the trust + * contract this file is bound by, and `/SECURITY.md` for the threat model. + * + * Effective URL: `/webhooks/metronome`. The `(ingest)` route group is + * URL-invisible. + * + * Pipeline: + * 1. Read raw body (signature is over the raw bytes). + * 2. Verify HMAC-SHA256 of `\n` against the + * `Metronome-Webhook-Signature` header. 400 on mismatch. + * 3. Reject events older than 5 min (per Metronome's dedup guidance). + * 4. Hand the event to `public.fn_billing_apply_metronome_event` — the + * RPC handles dedup (PK on event_id) and dispatches by type. + * 5. After commit-affecting events, refresh balance for the affected org + * (best-effort; webhook still 200's if the refresh fails). + * 6. Return 200. + */ + +import * as crypto from "node:crypto"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { service_role } from "@/lib/supabase/server"; +import { refreshBalance } from "@/lib/billing/metronome"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const FIVE_MINUTES_MS = 5 * 60 * 1000; + +function timingSafeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a, "utf8"); + const bb = Buffer.from(b, "utf8"); + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} + +const REFRESH_TRIGGERS = new Set([ + "payment_gate.payment_status", + "commit.create", + "commit.edit", + "commit.archive", + "commit.segment.start", + "commit.segment.end", + "credit.create", + "credit.edit", + "credit.archive", + "contract.edit", +]); + +export async function POST(req: NextRequest) { + const dateHeader = req.headers.get("date"); + const sigHeader = req.headers.get("metronome-webhook-signature"); + const rawBody = await req.text(); + + if (!dateHeader) { + return NextResponse.json({ error: "missing date header" }, { status: 400 }); + } + + const dateMs = Date.parse(dateHeader); + if (!Number.isFinite(dateMs)) { + return NextResponse.json({ error: "invalid date header" }, { status: 400 }); + } + if (Date.now() - dateMs > FIVE_MINUTES_MS) { + return NextResponse.json({ error: "stale event" }, { status: 400 }); + } + + const secret = process.env.METRONOME_WEBHOOK_SECRET; + if (!secret) { + // GRIDA-SEC-001: fail closed in production. + if (process.env.NODE_ENV === "production") { + console.error( + "[metronome-webhook] METRONOME_WEBHOOK_SECRET unset in production — refusing" + ); + return NextResponse.json({ error: "misconfigured" }, { status: 500 }); + } + console.warn( + "[metronome-webhook] METRONOME_WEBHOOK_SECRET unset — accepting unsigned event (dev only)." + ); + } else { + if (!sigHeader) { + return NextResponse.json( + { error: "missing metronome-webhook-signature header" }, + { status: 400 } + ); + } + const expected = crypto + .createHmac("sha256", secret) + .update(`${dateHeader}\n${rawBody}`) + .digest("hex"); + if (!timingSafeEqual(expected, sigHeader)) { + return NextResponse.json({ error: "bad signature" }, { status: 400 }); + } + } + + let event: { + id?: string; + type?: string; + properties?: { + customer_id?: string; + payment_status?: string; + failure_reason?: string; + }; + }; + try { + event = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: "invalid json" }, { status: 400 }); + } + if (!event.id || !event.type) { + return NextResponse.json({ error: "missing id or type" }, { status: 400 }); + } + + // DB-backed dedup + dispatch. The RPC inserts the event row (PK on event_id), + // returns 'replayed' on conflict, otherwise dispatches by type. + // Cast through unknown because the wrapping `rpc` overloads in supabase-js + // are too strict to express our migration-defined RPC name; signature is + // checked by the migration. + type RpcFn = ( + name: string, + params: Record + ) => Promise<{ + data: + | { result: string; handler: string }[] + | { result: string; handler: string } + | null; + error: { message: string } | null; + }>; + const rpc = service_role.workspace.rpc as unknown as RpcFn; + + const { data, error } = await rpc("fn_billing_apply_metronome_event", { + p_event_id: event.id, + p_event_type: event.type, + p_payload: event as object, + }); + if (error) { + console.error( + `[metronome-webhook] rpc failed event=${event.id} type=${event.type}: ${error.message}` + ); + return NextResponse.json({ error: "rpc failed" }, { status: 500 }); + } + + const result = Array.isArray(data) ? data[0] : data; + console.log( + `[metronome-webhook] ${event.type} id=${event.id} → ${result?.result ?? "?"}` + ); + + // Loud-log silent-recharge failures. Metronome may auto-disable the + // threshold config on its side after repeated declines; the next + // refreshBalance below picks that up. The user is not yet notified — + // tracked separately. KI-BILL-001 mitigation (subscription gate) bounds + // the volume of these to paying customers. + if ( + event.type === "payment_gate.payment_status" && + event.properties?.payment_status && + event.properties.payment_status !== "paid" + ) { + console.warn( + `[metronome-webhook] payment_gate failure id=${event.id} customer=${event.properties.customer_id ?? "?"} status=${event.properties.payment_status} reason=${event.properties.failure_reason ?? "?"}` + ); + } + + // Best-effort refresh of the cached balance for commit-affecting events. + if (result?.result === "processed" && REFRESH_TRIGGERS.has(event.type)) { + const customerId = event.properties?.customer_id; + if (customerId) { + try { + type ResolveRpc = ( + name: string, + params: Record + ) => Promise<{ data: number | string | null; error: unknown }>; + const resolveRpc = service_role.workspace.rpc as unknown as ResolveRpc; + const { data: orgIdRaw } = await resolveRpc( + "fn_billing_resolve_org_by_metronome_customer", + { p_customer_id: customerId } + ); + const orgId = + typeof orgIdRaw === "number" + ? orgIdRaw + : typeof orgIdRaw === "string" + ? Number(orgIdRaw) + : null; + if (orgId !== null && Number.isFinite(orgId)) { + await refreshBalance(orgId); + } + } catch (refreshErr) { + console.warn( + `[metronome-webhook] refreshBalance failed: ${(refreshErr as Error).message}` + ); + // Don't surface — the webhook still committed the event row. + } + } + } + + return NextResponse.json({ ok: true, result: result?.result }); +} diff --git a/editor/app/(ingest)/webhooks/stripe/route.ts b/editor/app/(ingest)/webhooks/stripe/route.ts index ae9b7fb0c..49823abe5 100644 --- a/editor/app/(ingest)/webhooks/stripe/route.ts +++ b/editor/app/(ingest)/webhooks/stripe/route.ts @@ -30,6 +30,10 @@ import { stampStripeEventFailure, type Stripe, } from "@/lib/billing"; +import { + disableAutoReload, + handleAiCreditCheckoutCompleted, +} from "@/lib/billing/metronome"; // Make sure Next doesn't try to parse the body before we can verify the signature. export const runtime = "nodejs"; @@ -88,6 +92,74 @@ export async function POST(req: NextRequest) { if (result.result === "replayed") { return NextResponse.json({ received: true, replayed: true }); } + + // AI credit Checkout post-processor — `dispatchStripeEvent` handles the + // generic projector path (subscribe sessions, payment lifecycle); this + // additional pass lands the Metronome commit + threshold config for + // sessions tagged with our AI credit metadata. No-op for any session + // without the right metadata.kind. + // + // On failure: return 500 so Stripe retries. The reconcile cron only + // refreshes balances — it does NOT replay missed top-ups / auto-reload + // setup, so swallowing the error means the customer paid and got no credit. + if (event.type === "checkout.session.completed") { + try { + const session = event.data.object as Stripe.Checkout.Session; + const aiResult = await handleAiCreditCheckoutCompleted({ + id: session.id, + payment_intent: + typeof session.payment_intent === "string" + ? session.payment_intent + : (session.payment_intent?.id ?? null), + payment_status: session.payment_status, + metadata: session.metadata as Record | null, + amount_total: session.amount_total, + }); + if (aiResult.result !== "noop") { + console.log( + `[webhook/stripe] ai-credit ${aiResult.result} for ${event.id}: ${aiResult.detail ?? ""}` + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error( + `[webhook/stripe] ai-credit post-processor failed for ${event.id}:`, + msg + ); + return NextResponse.json( + { error: "ai_credit_post_processor_failed", detail: msg }, + { status: 500 } + ); + } + } + + // Subscription cancel → disable Metronome auto-reload. + // + // Auto-reload is gated behind an active paid subscription + // (KI-BILL-001 mitigation in `docs/wg/platform/billing-known-issues.md`). + // When the subscription cancels, leaving auto-reload enabled means the + // org keeps eating silent-recharge cost forever — exactly what the gate + // was meant to prevent. Best-effort: log on failure but don't fail the + // webhook (the user-side cancel already projected; this is cleanup). + if (event.type === "customer.subscription.deleted") { + try { + const sub = event.data.object as Stripe.Subscription; + const orgIdRaw = sub.metadata?.grida_organization_id; + const orgId = orgIdRaw ? parseInt(orgIdRaw, 10) : NaN; + if (Number.isFinite(orgId)) { + await disableAutoReload(orgId); + console.log( + `[webhook/stripe] disabled auto-reload for org=${orgId} (sub=${sub.id})` + ); + } + } catch (err) { + console.error( + `[webhook/stripe] disable auto-reload failed for ${event.id}:`, + err instanceof Error ? err.message : String(err) + ); + } + } + return NextResponse.json({ received: true, type: event.type, diff --git a/editor/vercel.json b/editor/vercel.json index df221253e..66ff32960 100644 --- a/editor/vercel.json +++ b/editor/vercel.json @@ -1,4 +1,10 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", - "ignoreCommand": "bash skip-build.sh" + "ignoreCommand": "bash skip-build.sh", + "crons": [ + { + "path": "/internal/cron/billing-reconcile", + "schedule": "0 * * * *" + } + ] } From 2b535c695824f4d5836d47304e684518764b5a30 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 11 May 2026 04:24:45 +0900 Subject: [PATCH 05/21] feat(billing): user-facing AI credit page (top-up + auto-reload) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Billing now hosts a Grida AI Credit panel: balance card with two-step Buy Credit dialog ($X-of-credit picker → line-item breakdown showing markup as "Payment Processing Fee"), collapsible auto-reload configuration (subscription-gated per KI-BILL-001), and a recent-activity feed. Server actions in _actions.ts: getAiCreditsSummary (lazy-provisions only when not yet wired — skips on every steady-state poll), Stripe Checkout launchers for top-up and auto-reload-enable with payment_intent_data + invoice_creation so post-payment Stripe Invoices appear in Past Invoices. Edit-in-place auto-reload mutators don't re-Checkout. Return page (post-Stripe-Checkout interstitial) settles on `entitled === true` rather than a delta vs baseline — kills a race where the webhook landed before the page mounted and polling never settled. Polling pauses when the tab is hidden and stops entirely once state is steady, so an open billing tab doesn't hammer Stripe + Metronome. --- .../settings/billing/_actions.ts | 459 ++++++++++ .../settings/billing/_view.tsx | 802 +++++++++++++++++- .../settings/billing/return/_view.tsx | 67 +- .../settings/billing/return/page.tsx | 12 +- 4 files changed, 1324 insertions(+), 16 deletions(-) diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts b/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts index 7adfe3b0a..bffc81fe4 100644 --- a/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts @@ -15,6 +15,14 @@ import { getCustomerId, assertAllowedRedirect, } from "@/lib/billing"; +import { + AUTO_RELOAD_RECHARGE_MAX_CENTS, + AUTO_RELOAD_RECHARGE_MIN_CENTS, + AUTO_RELOAD_THRESHOLD_MIN_CENTS, + TOPUP_MAX_CENTS, + TOPUP_MIN_CENTS, + totalChargeForCredit, +} from "@/lib/billing/fees"; import { price_catalogue_id, type Interval, @@ -685,3 +693,454 @@ export async function listBillingAudit( return { rows, next_cursor, limit }; } + +// =========================================================================== +// AI Credits — Metronome-backed pre-charged credit + auto-reload. +// +// Reads expose live balance + gate decision + auto-reload state. Mutations +// (top-up, auto-reload config) are owner-only and resolve a Stripe customer +// + Metronome contract on demand (idempotent). +// =========================================================================== + +import { + AI_CHECKOUT_KIND, + addStripeChargedCommit, + disableAutoReload, + getAccount, + getAccountView, + getEntitlement, + getTransactions, + provisionOrg, + refreshBalance, + setAutoReload, + type Transaction, +} from "@/lib/billing/metronome"; + +export type AiCreditsSummary = { + /** Live balance from Metronome (cents). null if substrate not provisioned. */ + balance_cents: number | null; + /** Gate decision the AI seam will read. */ + entitled: boolean; + /** Reason when blocked: "below_floor" | "no_account" | etc. */ + blocked_reason: string | null; + /** Auto-reload — null when off. */ + auto_reload: { + enabled: boolean; + threshold_cents: number; + recharge_to_cents: number; + } | null; + /** Whether a Stripe customer is on file (required for top-up). */ + has_stripe_customer: boolean; + /** True when Metronome customer + contract are wired. */ + provisioned: boolean; + /** ISO timestamp of the cache row's last update — drives "X ago" UI. */ + cached_balance_at: string | null; + /** True when local cache disagrees with the live read. */ + drifted: boolean; + /** True when the org has an active paid (Pro/Team) subscription. Drives the + * auto-reload gate — see docs/wg/platform/billing-known-issues.md "Auto-reload + * markup gap". Manual top-up is always available. */ + has_active_subscription: boolean; +}; + +/** + * One-shot read for the user-facing AI Credits panel. Lazily provisions + * the Metronome contract on first call so newly-billed orgs don't see a + * "not provisioned" empty state. + * + * Hot-path note: this is polled every 15s by the open billing tab. Skip the + * provisionOrg round-trip entirely when the account is already wired — + * `provisionOrg` does several Metronome calls even on the happy path + * (`setBillingConfigurations`, `contracts.edit`, `provisionLowBalanceAlert`) + * and would otherwise hammer the API per poll per tab. + */ +export async function getAiCreditsSummary( + org_id: number +): Promise { + const user_id = await requireUserId(); + await assertOrgMember(user_id, org_id); + + // Lazy-provision only on cold start. Best-effort: a Metronome outage + // shouldn't break the billing page. + const account = await getAccount(org_id).catch(() => null); + const needsProvision = + !account?.metronome_customer_id || !account?.metronome_contract_id; + if (needsProvision) { + try { + await provisionOrg(org_id); + } catch (e) { + console.warn( + `[ai-credits] lazy provision failed for org=${org_id}:`, + e instanceof Error ? e.message : String(e) + ); + } + } + + const [view, ent, sub] = await Promise.all([ + getAccountView(org_id).catch(() => null), + getEntitlement(org_id).catch(() => null), + getActivePaidSubscription(org_id).catch(() => null), + ]); + + const live = view?.live ?? null; + const db = view?.db ?? null; + const drift = view?.drift; + + return { + balance_cents: live?.balanceCents ?? null, + entitled: ent?.allowed ?? false, + blocked_reason: ent?.allowed ? null : (ent?.reason ?? null), + auto_reload: live?.autoReload?.enabled + ? { + enabled: true, + threshold_cents: live.autoReload.thresholdCents ?? 0, + recharge_to_cents: live.autoReload.rechargeToCents ?? 0, + } + : null, + has_stripe_customer: !!db?.stripe_customer_id, + provisioned: !!db?.metronome_customer_id && !!db?.metronome_contract_id, + cached_balance_at: db?.cached_balance_at ?? null, + drifted: !!drift && Object.values(drift).some(Boolean), + has_active_subscription: + !!sub?.stripe_subscription_id && + sub.status !== "canceled" && + sub.status !== "incomplete_expired", + }; +} + +export async function listAiCreditTransactions( + org_id: number, + limit: number = 12 +): Promise { + const user_id = await requireUserId(); + await assertOrgMember(user_id, org_id); + const all = await getTransactions(org_id); + return all.slice(0, Math.max(1, Math.min(limit, 50))); +} + +/** + * Force a live read from Metronome and update the cache. Member-callable + * because the read is non-mutating beyond syncing local cache to live. + */ +export async function refreshAiCreditsBalance(org_id: number): Promise { + const user_id = await requireUserId(); + await assertOrgMember(user_id, org_id); + await refreshBalance(org_id); +} + +/** + * Owner-only top-up via direct Metronome charge against the saved card. + * Used internally (e.g., from the post-Checkout return callback to land + * the credit when Stripe already collected). End-user Buy Credit flows + * go through `startTopUpCheckout` instead. + */ +export async function topUpAiCredits( + org_id: number, + amount_cents: number +): Promise<{ ok: true }> { + const user_id = await requireUserId(); + await assertOrgOwner(user_id, org_id); + + if (!Number.isFinite(amount_cents) || amount_cents <= 0) { + throw new BillingError("invalid amount", "invalid_amount", 400); + } + const stripeCustomerId = await resolveOrCreateStripeCustomer(org_id); + await provisionOrg(org_id, { stripeCustomerId }); + await addStripeChargedCommit(org_id, amount_cents); + return { ok: true }; +} + +/** + * Owner-only: edit-in-place auto-reload (already enabled). Lib enforces + * threshold > 0 and recharge >= $5. The card is already authorized from + * the original enable Checkout, so this is a direct apply (no Checkout). + * + * NEW enables go through `startEnableAutoReloadCheckout`, not this fn. + * + * Requires an active paid subscription — see assertAutoReloadAllowed. + */ +export async function setAiAutoReload( + org_id: number, + threshold_cents: number, + recharge_to_cents: number +): Promise<{ ok: true }> { + const user_id = await requireUserId(); + await assertOrgOwner(user_id, org_id); + await assertAutoReloadAllowed(org_id); + const stripeCustomerId = await resolveOrCreateStripeCustomer(org_id); + await provisionOrg(org_id, { stripeCustomerId }); + await setAutoReload(org_id, threshold_cents, recharge_to_cents); + return { ok: true }; +} + +/** + * Auto-reload is gated behind an active paid subscription. + * + * Why: Metronome's `prepaid_balance_threshold_configuration` runs silent + * recharges at-cost (the primitive can't separate "charged amount" from + * "credited amount", so we can't apply the markup envelope from + * `lib/billing/fees.ts`). On free orgs this would leak ~$1.75–$2.75 per + * silent fire indefinitely. Restricting to subscribers caps the loss + * surface to a population whose base-plan margin already covers it. + * + * Manual top-up does NOT need this gate — it always goes through Checkout + * and pays the full markup. See docs/wg/platform/billing-known-issues.md. + */ +async function assertAutoReloadAllowed(org_id: number): Promise { + const sub = await getActivePaidSubscription(org_id); + const ok = + !!sub?.stripe_subscription_id && + sub.status !== "canceled" && + sub.status !== "incomplete_expired"; + if (!ok) { + throw new BillingError( + "Auto-reload requires an active paid plan. Manual top-ups remain available without a subscription.", + "subscription_required", + 403, + "billing/upgrade" + ); + } +} + +export async function disableAiAutoReload( + org_id: number +): Promise<{ ok: true }> { + const user_id = await requireUserId(); + await assertOrgOwner(user_id, org_id); + await disableAutoReload(org_id); + return { ok: true }; +} + +// --------------------------------------------------------------------------- +// Checkout-based authorization flows (every NEW commitment goes through +// Stripe Checkout — see docs/wg/platform/metronome.md "card authorization"). +// +// Why: Stripe doesn't expose a perfect "PM ready for off-session?" signal, +// and any cached PM can fail tomorrow (expiry, dispute, SCA invalidation). +// Every Checkout is a fresh on-session authorization that satisfies SCA, +// (re)applies `setup_future_usage: 'off_session'`, and confirms intent. +// +// The existing Stripe webhook receiver picks up `checkout.session.completed` +// with our metadata.kind and routes to the right service-function tail. +// --------------------------------------------------------------------------- + +export type AiCheckoutResult = { checkout_url: string }; + +/** + * Owner-only. Returns a Stripe Checkout URL for buying $X of AI credit. + * Payment-mode session: charges immediately + saves card with + * `setup_future_usage: 'off_session'` for future direct charges + * (Metronome's silent auto-recharges and edit-in-place auto-reload). + */ +export async function startTopUpCheckout( + org_id: number, + params: { cents: number; success_url: string; cancel_url: string } +): Promise { + const user_id = await requireUserId(); + await assertOrgOwner(user_id, org_id); + + if ( + !Number.isFinite(params.cents) || + params.cents < TOPUP_MIN_CENTS || + params.cents > TOPUP_MAX_CENTS + ) { + throw new BillingError( + `Top-up must be between $${TOPUP_MIN_CENTS / 100} and $${TOPUP_MAX_CENTS / 100}.`, + "invalid_amount", + 400 + ); + } + + const origin = await getOrigin(); + const success_url = assertAllowedRedirect(params.success_url, origin); + const cancel_url = assertAllowedRedirect(params.cancel_url, origin); + + const stripeCustomerId = await resolveOrCreateStripeCustomer(org_id); + // Provision Metronome too — the webhook needs the contract to land the commit. + await provisionOrg(org_id, { stripeCustomerId }); + + // Pass through Stripe's processing fee — user pays $X plus the fee, + // receives exactly $X of credit. See lib/billing/fees.ts and + // docs/wg/platform/ai-credits.md "Money model". + const totalCents = totalChargeForCredit(params.cents); + const idempotencyKey = `ai_topup:${org_id}:${params.cents}:${Math.floor(Date.now() / 60000)}`; + + const session = await stripe.checkout.sessions.create( + { + mode: "payment", + customer: stripeCustomerId, + line_items: [ + { + price_data: { + currency: "usd", + unit_amount: totalCents, + product_data: { + name: "Grida AI Credit", + description: `$${(params.cents / 100).toFixed(2)} of credit · includes Stripe processing fee.`, + }, + }, + quantity: 1, + }, + ], + payment_intent_data: { + setup_future_usage: "off_session", + metadata: { + grida_organization_id: String(org_id), + kind: AI_CHECKOUT_KIND.TOPUP, + // `cents` = credit amount landed on Metronome (NOT the total + // charged via Stripe). The fee delta is already in our Stripe + // payout — we owe the customer exactly this much credit. + cents: String(params.cents), + total_cents: String(totalCents), + }, + }, + // Generate a post-payment Stripe Invoice. Without this, payment-mode + // Checkout produces only a PaymentIntent + Charge — no Invoice ever + // appears in `stripe.invoices.list`, so the "Past Invoices" panel + // misses every top-up. This flag is a no-op on payment failure. + invoice_creation: { enabled: true }, + // Session-level metadata is what `checkout.session.completed` carries. + metadata: { + grida_organization_id: String(org_id), + kind: AI_CHECKOUT_KIND.TOPUP, + cents: String(params.cents), + total_cents: String(totalCents), + }, + success_url, + cancel_url, + }, + { idempotencyKey } + ); + + if (!session.url) { + throw new BillingError( + "Stripe did not return a Checkout URL.", + "checkout_failed", + 500 + ); + } + return { checkout_url: session.url }; +} + +/** + * Owner-only. Returns a Stripe Checkout URL for enabling auto-reload. + * The Checkout charges the recharge amount upfront (= initial top-up) + * AND saves the card. Post-Checkout, the webhook applies the threshold + * config so future drains trigger silent auto-recharges against the + * saved card. + * + * Used for: first-time enable AND re-enable after disable / Metronome + * auto-disable from a failed silent charge. Edit-in-place (already + * enabled, just changing threshold/amount) goes through `setAiAutoReload`. + */ +export async function startEnableAutoReloadCheckout( + org_id: number, + params: { + threshold_cents: number; + recharge_to_cents: number; + success_url: string; + cancel_url: string; + } +): Promise { + const user_id = await requireUserId(); + await assertOrgOwner(user_id, org_id); + await assertAutoReloadAllowed(org_id); + + if ( + !Number.isFinite(params.threshold_cents) || + params.threshold_cents < AUTO_RELOAD_THRESHOLD_MIN_CENTS || + params.threshold_cents % 100 !== 0 + ) { + throw new BillingError( + `Threshold must be a whole number of dollars and at least $${AUTO_RELOAD_THRESHOLD_MIN_CENTS / 100}.`, + "invalid_amount", + 400 + ); + } + if ( + !Number.isFinite(params.recharge_to_cents) || + params.recharge_to_cents < AUTO_RELOAD_RECHARGE_MIN_CENTS || + params.recharge_to_cents > AUTO_RELOAD_RECHARGE_MAX_CENTS || + params.recharge_to_cents % 100 !== 0 + ) { + throw new BillingError( + `Recharge target must be a whole number of dollars between $${AUTO_RELOAD_RECHARGE_MIN_CENTS / 100} and $${AUTO_RELOAD_RECHARGE_MAX_CENTS / 100}.`, + "invalid_amount", + 400 + ); + } + if (params.recharge_to_cents <= params.threshold_cents) { + throw new BillingError( + "Recharge target must be greater than the threshold.", + "invalid_amount", + 400 + ); + } + + const origin = await getOrigin(); + const success_url = assertAllowedRedirect(params.success_url, origin); + const cancel_url = assertAllowedRedirect(params.cancel_url, origin); + + const stripeCustomerId = await resolveOrCreateStripeCustomer(org_id); + await provisionOrg(org_id, { stripeCustomerId }); + + // Markup is applied to the user-initiated initial recharge (this + // Checkout). Subsequent silent recharges via Metronome's + // prepaid_balance_threshold_configuration run at-cost. v1 mitigation + // is the subscription gate above; full fix is tracked as KI-BILL-001 + // in docs/wg/platform/billing-known-issues.md. + const totalCents = totalChargeForCredit(params.recharge_to_cents); + const idempotencyKey = `ai_auto_reload:${org_id}:${params.threshold_cents}:${params.recharge_to_cents}:${Math.floor(Date.now() / 60000)}`; + + const session = await stripe.checkout.sessions.create( + { + mode: "payment", + customer: stripeCustomerId, + line_items: [ + { + price_data: { + currency: "usd", + unit_amount: totalCents, + product_data: { + name: "Grida AI Credit (auto-reload setup)", + description: `Initial $${(params.recharge_to_cents / 100).toFixed(2)} of credit · includes Stripe processing fee. Auto-reload will keep your balance topped up to this level when it falls below $${(params.threshold_cents / 100).toFixed(2)}.`, + }, + }, + quantity: 1, + }, + ], + payment_intent_data: { + setup_future_usage: "off_session", + metadata: { + grida_organization_id: String(org_id), + kind: AI_CHECKOUT_KIND.AUTO_RELOAD_ENABLE, + threshold_cents: String(params.threshold_cents), + // `recharge_to_cents` = credit amount landed on Metronome. + recharge_to_cents: String(params.recharge_to_cents), + total_cents: String(totalCents), + }, + }, + // Generate a post-payment Stripe Invoice (see startTopUpCheckout). + invoice_creation: { enabled: true }, + metadata: { + grida_organization_id: String(org_id), + kind: AI_CHECKOUT_KIND.AUTO_RELOAD_ENABLE, + threshold_cents: String(params.threshold_cents), + recharge_to_cents: String(params.recharge_to_cents), + total_cents: String(totalCents), + }, + success_url, + cancel_url, + }, + { idempotencyKey } + ); + + if (!session.url) { + throw new BillingError( + "Stripe did not return a Checkout URL.", + "checkout_failed", + 500 + ); + } + return { checkout_url: session.url }; +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx b/editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx index abd43fb56..03df52934 100644 --- a/editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx @@ -3,7 +3,14 @@ import React, { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { toast } from "sonner"; -import { CreditCardIcon, ExternalLinkIcon, FileTextIcon } from "lucide-react"; +import { + ChevronDownIcon, + CoinsIcon, + CreditCardIcon, + ExternalLinkIcon, + FileTextIcon, + InfoIcon, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, @@ -16,6 +23,22 @@ import { import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Table, TableBody, @@ -31,7 +54,23 @@ import { resumeSubscription, startCancelSubscription, startPaymentMethodUpdate, + getAiCreditsSummary, + listAiCreditTransactions, + setAiAutoReload, + disableAiAutoReload, + startTopUpCheckout, + startEnableAutoReloadCheckout, + type AiCreditsSummary, } from "./_actions"; +import type { Transaction as AiCreditTransaction } from "@/lib/billing/metronome"; +import { + AUTO_RELOAD_RECHARGE_MAX_CENTS, + AUTO_RELOAD_RECHARGE_MIN_CENTS, + AUTO_RELOAD_THRESHOLD_MIN_CENTS, + TOPUP_MAX_CENTS, + TOPUP_MIN_CENTS, + totalChargeForCredit, +} from "@/lib/billing/fees"; import { PAID_PLANS, price_dollars, @@ -394,7 +433,10 @@ export default function BillingView({ - {/* 2. Past Invoices */} + {/* 2. Grida AI Credit */} + + + {/* 3. Past Invoices */} ); } + +// =========================================================================== +// Grida AI Credit — Metronome-backed pre-charged credit + auto-reload. +// =========================================================================== + +const BUY_PRESETS = [10, 50, 100, 500] as const; + +const TXN_LABEL: Record = { + topup: "Top-up", + auto_reload: "Auto-reload", + promo: "Promo", + unknown: "Credit", +}; + +function fmtCreditAge(iso: string | null): string { + if (!iso) return "—"; + const ms = Date.now() - new Date(iso).getTime(); + if (ms < 0) return "just now"; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +function UsdInput({ + value, + onChange, + className, + autoFocus, + disabled, +}: { + value: string; + onChange: (v: string) => void; + className?: string; + autoFocus?: boolean; + disabled?: boolean; +}) { + return ( +
+ onChange(e.target.value)} + className="pr-14 tabular-nums" + inputMode="decimal" + autoFocus={autoFocus} + disabled={disabled} + /> + + USD + +
+ ); +} + +function AiCreditsSection({ + orgId, + baseUrl, +}: { + orgId: number; + baseUrl: string; +}) { + const [summary, setSummary] = useState(null); + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [autoReloadOpen, setAutoReloadOpen] = useState(false); + const [activityOpen, setActivityOpen] = useState(false); + const [buyOpen, setBuyOpen] = useState(false); + + // Local form state for auto-reload — separate from `summary` so unsaved + // edits stay put across the polling refreshes. + const [autoReloadOn, setAutoReloadOn] = useState(false); + const [thresholdInput, setThresholdInput] = useState("50"); + const [rechargeInput, setRechargeInput] = useState("100"); + + const refresh = useCallback(async () => { + try { + const [s, t] = await Promise.all([ + getAiCreditsSummary(orgId), + listAiCreditTransactions(orgId, 12), + ]); + setSummary(s); + setTransactions(t); + } finally { + setLoading(false); + } + }, [orgId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // Sync local form state from summary when the user isn't actively editing. + // Editing = auto-reload form is open AND values differ from summary; in + // that case we leave the inputs alone so polling doesn't stomp the user. + useEffect(() => { + if (!summary) return; + setAutoReloadOn(summary.auto_reload !== null); + if (summary.auto_reload) { + setThresholdInput(String(summary.auto_reload.threshold_cents / 100)); + setRechargeInput(String(summary.auto_reload.recharge_to_cents / 100)); + } + // Intentionally only resync when the saved state changes, not on every + // poll — otherwise the user's in-flight edits get clobbered. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + summary?.auto_reload?.threshold_cents, + summary?.auto_reload?.recharge_to_cents, + summary?.auto_reload === null, + ]); + + // Light polling so webhook-driven balance updates appear without manual + // refresh. Each poll round-trips Stripe + Metronome + Supabase, so we + // throttle aggressively: skip when the tab is backgrounded (visibilitychange + // fires a refresh on focus), and stop entirely when state is steady — the + // user can still hit "Buy Credit" or refresh the page. + useEffect(() => { + const isSteady = + summary !== null && summary.entitled && !summary.drifted && busy === null; + if (isSteady) return; + + const tick = () => { + if (typeof document !== "undefined" && document.hidden) return; + void refresh(); + }; + const t = setInterval(tick, 15_000); + const onVisibility = () => { + if (typeof document !== "undefined" && !document.hidden) tick(); + }; + document.addEventListener("visibilitychange", onVisibility); + return () => { + clearInterval(t); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [refresh, summary, busy]); + + const runMutation = useCallback( + async (label: string, fn: () => Promise, success: string) => { + setBusy(label); + try { + await fn(); + toast.success(success); + await refresh(); + } catch (e) { + toast.error("Action failed", { + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(null); + } + }, + [refresh] + ); + + // Dirty-check: form differs from saved state. + const savedOn = + summary?.auto_reload !== null && summary?.auto_reload !== undefined; + const savedThresholdCents = summary?.auto_reload?.threshold_cents ?? null; + const savedRechargeCents = summary?.auto_reload?.recharge_to_cents ?? null; + const localThresholdCents = Math.round(parseFloat(thresholdInput) * 100); + const localRechargeCents = Math.round(parseFloat(rechargeInput) * 100); + const dirty = + autoReloadOn !== savedOn || + (autoReloadOn && + (localThresholdCents !== savedThresholdCents || + localRechargeCents !== savedRechargeCents)); + + const cancelAutoReload = () => { + setAutoReloadOn(savedOn); + if (summary?.auto_reload) { + setThresholdInput(String(summary.auto_reload.threshold_cents / 100)); + setRechargeInput(String(summary.auto_reload.recharge_to_cents / 100)); + } + }; + + const saveAutoReload = () => { + // Disable: direct (no charge). + if (!autoReloadOn) { + void runMutation( + "auto-reload-off", + () => disableAiAutoReload(orgId), + "Auto-reload disabled." + ); + return; + } + if ( + !Number.isFinite(localThresholdCents) || + localThresholdCents < AUTO_RELOAD_THRESHOLD_MIN_CENTS + ) { + toast.error( + `Threshold must be at least $${AUTO_RELOAD_THRESHOLD_MIN_CENTS / 100}.` + ); + return; + } + if ( + !Number.isFinite(localRechargeCents) || + localRechargeCents < AUTO_RELOAD_RECHARGE_MIN_CENTS + ) { + toast.error( + `Recharge amount must be at least $${AUTO_RELOAD_RECHARGE_MIN_CENTS / 100}.` + ); + return; + } + // Edit-in-place (already enabled): direct apply, card already authorized. + if (savedOn) { + void runMutation( + "auto-reload-set", + () => setAiAutoReload(orgId, localThresholdCents, localRechargeCents), + "Auto-reload updated." + ); + return; + } + // Enable from off: redirect through Stripe Checkout. Charges the + // recharge amount upfront (= initial top-up) AND saves the card; the + // webhook then applies the threshold config. + setBusy("auto-reload-checkout"); + void (async () => { + try { + const r = await startEnableAutoReloadCheckout(orgId, { + threshold_cents: localThresholdCents, + recharge_to_cents: localRechargeCents, + success_url: `${window.location.origin}${window.location.pathname}/return?intent=auto_reload_enable`, + cancel_url: `${window.location.origin}${window.location.pathname}`, + }); + window.location.href = r.checkout_url; + } catch (e) { + toast.error("Could not open Checkout", { + description: e instanceof Error ? e.message : String(e), + }); + setBusy(null); + } + })(); + }; + + // Buy Credit always goes through Stripe Checkout — every commitment is + // a fresh on-session authorization (handles SCA, expiry, dispute, etc). + const handleBuy = (cents: number) => { + setBusy("buy"); + void (async () => { + try { + const r = await startTopUpCheckout(orgId, { + cents, + success_url: `${window.location.origin}${window.location.pathname}/return?intent=topup`, + cancel_url: `${window.location.origin}${window.location.pathname}`, + }); + window.location.href = r.checkout_url; + } catch (e) { + toast.error("Could not open Checkout", { + description: e instanceof Error ? e.message : String(e), + }); + setBusy(null); + } + })(); + }; + + if (loading) { + return ( + + + + ); + } + + if (!summary) { + return ( + + + +

+ Grida AI Credit is temporarily unavailable. Try refreshing. +

+
+
+
+ ); + } + + const balance = summary.balance_cents; + const blocked = !summary.entitled; + // First-run = blocked with no spend history. The gate is technically + // "blocked" because balance is below the floor, but framing it as + // blocked is alarming for users who haven't done anything yet. + // Out-of-credit = blocked AND has history → an actual recoverable + // state worth flagging. + const isFirstRun = + blocked && (balance === 0 || balance === null) && transactions.length === 0; + const isOutOfCredit = + blocked && (balance === 0 || balance === null) && transactions.length > 0; + + return ( + + + + {/* Balance + Buy Credit */} +
+
+
+ +
+
+

Current Balance

+

+ {balance === null ? "—" : fmtCents(balance)} +

+ {isFirstRun && ( +

+ Buy credit to start using Grida AI. +

+ )} + {isOutOfCredit && ( +

+ Out of credit — top up to continue. +

+ )} + {blocked && !isFirstRun && !isOutOfCredit && ( +

+ Balance is below the minimum to use Grida AI. +

+ )} + {summary.drifted && !blocked && ( +

+ Syncing… updated {fmtCreditAge(summary.cached_balance_at)} +

+ )} +
+
+ +
+ + + + {/* Auto-reload (collapsible) */} + + + + + + {!summary.has_active_subscription && !savedOn && ( +
+ +
+

+ Auto-reload is available on paid plans. Manual top-ups + work on any plan. +

+ +
+
+ )} +
+ + +
+ + {autoReloadOn && ( +
+
+ + + {(() => { + const validThreshold = + Number.isFinite(localThresholdCents) && + localThresholdCents >= + AUTO_RELOAD_THRESHOLD_MIN_CENTS && + localThresholdCents % 100 === 0; + if (!thresholdInput) return null; + if (validThreshold) return null; + return ( +

+ Minimum balance must be a whole number and at least $ + {AUTO_RELOAD_THRESHOLD_MIN_CENTS / 100}. +

+ ); + })()} +
+
+ + + {(() => { + const validRecharge = + Number.isFinite(localRechargeCents) && + localRechargeCents >= AUTO_RELOAD_RECHARGE_MIN_CENTS && + localRechargeCents <= AUTO_RELOAD_RECHARGE_MAX_CENTS && + localRechargeCents % 100 === 0; + if (!rechargeInput) return null; + if (validRecharge) return null; + const min = AUTO_RELOAD_RECHARGE_MIN_CENTS / 100; + const max = AUTO_RELOAD_RECHARGE_MAX_CENTS / 100; + return ( +

+ Target balance must be a whole number between ${min}{" "} + and ${max}. +

+ ); + })()} +
+
+ )} + + {autoReloadOn && + !savedOn && + Number.isFinite(localRechargeCents) && + localRechargeCents >= AUTO_RELOAD_RECHARGE_MIN_CENTS && ( +

+ Saving will redirect you to Stripe to authorize an initial{" "} + {fmtCents(localRechargeCents)} of credit (charged{" "} + {fmtCents(totalChargeForCredit(localRechargeCents))}{" "} + including processing fee) and save your card for future + auto-recharges. +

+ )} + +
+ + +
+
+
+ + {/* Recent activity (collapsible) */} + {transactions.length > 0 && ( + <> + + + + + + + + + + Type + Date + Amount + Status + + + + {transactions.map((t) => ( + + + + {TXN_LABEL[t.kind] ?? "Credit"} + + + + {fmtCreditAge(t.at)} + + + +{fmtCents(t.amountCents)} + + + {t.paid ? "paid" : "—"} + + + ))} + +
+
+
+ + )} +
+
+ +

+ Credit doesn't expire and is only valid for use on Grida AI. +

+ + { + setBuyOpen(false); + setAutoReloadOpen(true); + }} + /> +
+ ); +} + +function BuyCreditDialog({ + open, + onOpenChange, + autoReload, + busy, + onConfirm, + onChangeAutoReload, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + autoReload: AiCreditsSummary["auto_reload"]; + busy: boolean; + onConfirm: (cents: number) => void; + onChangeAutoReload: () => void; +}) { + // Two-step flow: + // 1. select → user picks the credit amount; only the credit amount + // is shown, no fee math (don't make the markup feel + // like an upsell at the picker). + // 2. confirm → reveal the line-item breakdown (credit + processing + // fee + total) and "Continue to Payment". + // Stripe Checkout handles the actual card UI; this dialog is purely + // pre-Checkout staging. + const [step, setStep] = useState<"select" | "confirm">("select"); + const [preset, setPreset] = useState(50); + const [customMode, setCustomMode] = useState(false); + const [customValue, setCustomValue] = useState("50"); + + // Reset to step 1 each time the dialog opens. + useEffect(() => { + if (open) { + setStep("select"); + setPreset(50); + setCustomMode(false); + setCustomValue("50"); + } + }, [open]); + + const minDollars = TOPUP_MIN_CENTS / 100; + const maxDollars = TOPUP_MAX_CENTS / 100; + const dollars = customMode ? parseFloat(customValue) : preset; + const validAmount = + Number.isFinite(dollars) && dollars >= minDollars && dollars <= maxDollars; + const safeDollars = Number.isFinite(dollars) ? dollars : 0; + const safeCents = Math.round(safeDollars * 100); + const totalCents = totalChargeForCredit(safeCents); + const feeCents = totalCents - safeCents; + + return ( + + + {step === "select" ? ( + <> + + Buy Grida AI Credit + + Purchase credit as a one-time top-up to use for your team's + Grida AI usage. Credit doesn't expire and is only valid for + use on Grida AI. + + + +
+

+ ${safeDollars.toFixed(2)} +

+

of credit

+
+ +
+ {BUY_PRESETS.map((v) => ( + + ))} + +
+ + {customMode && ( +
+ + {Number.isFinite(dollars) && dollars > 0 && !validAmount && ( +

+ {dollars < minDollars + ? `Minimum amount is $${minDollars}.` + : `Maximum amount is $${maxDollars}.`} +

+ )} +
+ )} + + {autoReload && ( +
+ +

+ Auto-reload is enabled. Your balance will be restored to{" "} + {fmtCents(autoReload.recharge_to_cents)} when + it falls below{" "} + {fmtCents(autoReload.threshold_cents)}. +

+ +
+ )} + + + + + + + ) : ( + <> + + Confirm purchase + + You'll be redirected to Stripe to complete payment with + your saved card or a new one. + + + +
+
+ Grida AI Credit + {fmtCents(safeCents)} +
+
+ Payment Processing Fee + {fmtCents(feeCents)} +
+
+ Total + + {fmtCents(totalCents)} + +
+

+ * Plus applicable tax +

+
+ + + + + + + )} +
+
+ ); +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/return/_view.tsx b/editor/app/(site)/organizations/[organization_name]/settings/billing/return/_view.tsx index e18eb0d85..fb3bd9a5c 100644 --- a/editor/app/(site)/organizations/[organization_name]/settings/billing/return/_view.tsx +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/return/_view.tsx @@ -19,28 +19,65 @@ import { useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; -import { getBillingSummary, type BillingSummary } from "../_actions"; +import { + getAiCreditsSummary, + getBillingSummary, + type AiCreditsSummary, + type BillingSummary, +} from "../_actions"; -type Intent = "subscribe" | "payment_method" | "generic"; +type Intent = + | "subscribe" + | "payment_method" + | "topup" + | "auto_reload_enable" + | "generic"; const POLL_INTERVAL_MS = 3_000; const MAX_ATTEMPTS = 10; // ~30s total -function isSettled(summary: BillingSummary, intent: Intent): boolean { +type Snapshot = { + billing: BillingSummary | null; + ai: AiCreditsSummary | null; +}; + +function isSettled(snap: Snapshot, intent: Intent): boolean { switch (intent) { - case "subscribe": + case "subscribe": { + const s = snap.billing; + if (!s) return false; // Paid plan visible AND not in a "first-invoice broken" state. return ( - (summary.plan === "pro" || summary.plan === "team") && - summary.status !== "incomplete" && - summary.status !== "incomplete_expired" + (s.plan === "pro" || s.plan === "team") && + s.status !== "incomplete" && + s.status !== "incomplete_expired" ); - case "payment_method": + } + case "payment_method": { + const s = snap.billing; + if (!s) return false; // The interesting transition is past_due/unpaid → active. If we already // see a healthy status, the update has been applied. - return summary.status === "active" || summary.status === "trialing"; + return s.status === "active" || s.status === "trialing"; + } + case "topup": { + const a = snap.ai; + if (!a) return false; + // Settle on entitlement, not a delta vs baseline. Delta races the + // webhook: if the webhook lands BEFORE this page mounts, baseline + // already reflects the new credit and the delta condition never + // fires. Entitlement is also the user-facing question — "can I + // use AI now?" — and is what the post-Checkout handler reconciles + // inline before this page even loads. + return a.entitled === true; + } + case "auto_reload_enable": { + const a = snap.ai; + if (!a) return false; + // Auto-reload config visible AND gate is open. + return a.auto_reload !== null && a.entitled === true; + } case "generic": - // No predicate — just exhaust the poll window. return false; } } @@ -77,13 +114,19 @@ export default function BillingReturnView({ router.replace(billingHref); }; + const isAiIntent = intent === "topup" || intent === "auto_reload_enable"; + const tick = async () => { if (cancelled || doneRef.current) return; attempts += 1; try { - const summary = await getBillingSummary(orgId); + const [billing, ai] = await Promise.all([ + isAiIntent ? Promise.resolve(null) : getBillingSummary(orgId), + isAiIntent ? getAiCreditsSummary(orgId) : Promise.resolve(null), + ]); + const snap: Snapshot = { billing, ai }; if (cancelled || doneRef.current) return; - if (isSettled(summary, intent)) { + if (isSettled(snap, intent)) { finish(true); return; } diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/return/page.tsx b/editor/app/(site)/organizations/[organization_name]/settings/billing/return/page.tsx index 9ac05b689..c1c65d116 100644 --- a/editor/app/(site)/organizations/[organization_name]/settings/billing/return/page.tsx +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/return/page.tsx @@ -28,8 +28,16 @@ export default async function BillingReturnPage({ // Whitelist of supported intents — anything else falls back to a // generic wait. Drives the copy and the "settled" predicate in the view. - const intent: "subscribe" | "payment_method" | "generic" = - rawIntent === "subscribe" || rawIntent === "payment_method" + const intent: + | "subscribe" + | "payment_method" + | "topup" + | "auto_reload_enable" + | "generic" = + rawIntent === "subscribe" || + rawIntent === "payment_method" || + rawIntent === "topup" || + rawIntent === "auto_reload_enable" ? rawIntent : "generic"; From ec9a643b226e774150d909c3ad03f71a4c1e0135 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 11 May 2026 04:25:08 +0900 Subject: [PATCH 06/21] feat(insiders): billing QA harness + GRIDA-SEC-002 dev-only gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The insiders harness drives every Metronome/Stripe lifecycle step manually (provision, add commit, ingest, alerts, refund, invoices, ...) — useful for debugging the live system without writing scripts. The harness's server actions intentionally skip org-membership checks, so the route group MUST never be reachable outside local development. GRIDA-SEC-002 enforces that contract at two layers: - editor/proxy.ts — 404 every /insiders/* request when NODE_ENV != "development", including Next-Action POSTs. - editor/app/(insiders)/layout.tsx — defense-in-depth notFound() in the layout if any request slips past the proxy. SECURITY.md documents both layers and lists every file bound by the id. --- SECURITY.md | 80 +- .../app/(insiders)/insiders/billing/_view.tsx | 1293 +++++++++++++++++ .../(insiders)/insiders/billing/actions.ts | 262 ++++ .../app/(insiders)/insiders/billing/page.tsx | 7 + editor/app/(insiders)/layout.tsx | 17 + editor/proxy.ts | 17 + 6 files changed, 1670 insertions(+), 6 deletions(-) create mode 100644 editor/app/(insiders)/insiders/billing/_view.tsx create mode 100644 editor/app/(insiders)/insiders/billing/actions.ts create mode 100644 editor/app/(insiders)/insiders/billing/page.tsx diff --git a/SECURITY.md b/SECURITY.md index 5c8c9c686..48d836848 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -95,18 +95,21 @@ as public. when the signing secret is missing in production. 4. **Replay protection** — receivers dedup on event id and reject events older than 5 minutes (where applicable). -5. **(Optional) tunnel path filter at the edge** — when local dev uses - a tunnel, the tunnel itself is configured to forward only - `/webhooks/*` and reject everything else with 404. Defense-in-depth - at the network layer: even if app code drifts, the tunnel cannot - expose non-webhook paths. Lands with the dev-tunnel commit. +5. **Tunnel path filter at the edge** — + [editor/scripts/billing/tunnel.sh](editor/scripts/billing/tunnel.sh) + configures cloudflared to forward only `/webhooks/*` and reject + everything else with 404. Defense-in-depth at the network layer: + even if app code drifts, the tunnel cannot expose non-webhook paths. **Files bound by this id.** Run `grep -rn GRIDA-SEC-001 .` to enumerate. Today: - [editor/app/(ingest)/README.md]() — rules. - [editor/app/(ingest)/webhooks/stripe/route.ts]() — Stripe receiver. +- [editor/app/(ingest)/webhooks/metronome/route.ts]() — Metronome receiver. - [editor/proxy.ts](editor/proxy.ts) — path bypass. +- [editor/scripts/billing/tunnel.sh](editor/scripts/billing/tunnel.sh) — tunnel ingress filter. +- [editor/scripts/billing/README.md](editor/scripts/billing/README.md) — dev docs. **What does NOT belong under `(ingest)/`.** Admin tools, internal RPC, anything that authenticates via cookie/session/bearer-token — those go @@ -115,9 +118,74 @@ under `(api)/private/**`. Anything user-facing goes under --- +### `GRIDA-SEC-002` — Insiders dev harness is local-only + +**What it protects.** The `(insiders)` route group hosts a developer +harness — pages and server actions used to drive Metronome/Stripe +lifecycle steps manually during development and QA. The actions there +intentionally **omit org-membership / ownership checks** and accept an +attacker-supplied `organizationId` as the first argument. That shape is +fine for a local-only debug surface; it would be a cross-org +compromise vector in any non-local environment. The boundary is the +rule that **`/insiders/*` is reachable if and only if +`NODE_ENV === "development"`.** + +**Vulnerable scenario (prevented).** A developer ships the +`(insiders)` route group as part of the production bundle without +gating it. Server actions like `actionAddStripeChargedCommit(orgId, +amountCents)`, `actionIngest(orgId, costMills)`, and +`actionGetInvoicePdf(orgId, invoiceId)` become reachable on the public +internet. An attacker enumerates `organization_id` (sequential bigint), +then calls these actions to charge any org's saved Stripe card, zero +out any org's AI-credit balance via the optimistic-debit RPC (which +also flips `customer_entitled = false`), or read any org's billing +state and invoice PDFs. + +**Why it's specifically risky here.** Next.js server actions are +**HTTP RPC endpoints addressable from any browser** via the +`Next-Action` header — the action hash is shipped in the client +bundle of any page that imports it. They are _not_ protected by +"the page UI isn't linked anywhere"; whatever URL group the action +lives under is the only structural gate. An open-source repo means +the action source is public, so the hashes are too. Without a +proxy-level gate, a single accidentally-deployed harness action is a +production cross-org vulnerability. + +**How the code prevents it.** + +1. **Proxy-level gate** — [editor/proxy.ts](editor/proxy.ts) returns + 404 for `/insiders` and `/insiders/*` whenever `NODE_ENV !== +"development"`. The proxy runs _before_ any handler, so this also + stops `Next-Action` POSTs to `/insiders/*` URLs. +2. **Layout-level `notFound()`** — + [editor/app/(insiders)/layout.tsx]() + throws `notFound()` when not in dev. Defense-in-depth: even if a + future change accidentally weakens the proxy gate, the layout still + renders 404 for every page in the group. +3. **No imports across the boundary** — + [editor/app/(insiders)/insiders/billing/actions.ts]() + carries a `GRIDA-SEC-002` header documenting that these actions + must NOT be imported from production code paths. Importing them + from a `(site)` page would re-emit the action hashes against that + page's URL and bypass the proxy gate. + +**Files bound by this id.** Run `grep -rn GRIDA-SEC-002 .` to enumerate. +Today: + +- [editor/proxy.ts](editor/proxy.ts) — proxy gate. +- [editor/app/(insiders)/layout.tsx]() — layout `notFound()` fallback. +- [editor/app/(insiders)/insiders/billing/actions.ts]() — header callout, "no import from prod code". + +**What does NOT belong under `(insiders)/`.** Anything that needs to +ship to production. If a feature in development outgrows the dev +harness, move it to `(site)/...` (with proper auth) or `(api)/...` +(with proper auth) — never relax the `(insiders)/` gate to host it. + +--- + ## Adding a new GRIDA-SEC entry -1. Allocate the next sequential id (`GRIDA-SEC-002` for the next one). +1. Allocate the next sequential id (`GRIDA-SEC-003` for the next one). 2. Add an "Active boundaries" subsection here with the same shape as GRIDA-SEC-001: what it protects, vulnerable scenario, why it's risky here, how the code prevents it, files bound. diff --git a/editor/app/(insiders)/insiders/billing/_view.tsx b/editor/app/(insiders)/insiders/billing/_view.tsx new file mode 100644 index 000000000..4494ef789 --- /dev/null +++ b/editor/app/(insiders)/insiders/billing/_view.tsx @@ -0,0 +1,1293 @@ +"use client"; + +// Insiders dev harness for billing. +// +// Two-column layout: +// - left "Billing portal": configure & top up — state, add credit, auto-reload. +// - right "Usage": consume credits — simulate usage, watch webhooks land. +// +// Section 1 (pick an org) and the result banner span both columns. +// +// All money inputs are dollars (UI) → cents (API). + +import { useCallback, useEffect, useState } from "react"; +import { + actionAddComplimentaryCommit, + actionAddStripeChargedCommit, + actionDisableAutoReload, + actionGetAccountView, + actionGetAlertsStatus, + actionGetBalance, + actionGetEntitlement, + actionGetInvoicePdf, + actionGetInvoices, + actionGetTransactions, + actionIngest, + actionIngestGated, + actionLinkStripeAndAttachTestCard, + actionListWebhookEvents, + actionProvisionOrg, + actionRefreshBalance, + actionRevokeUnused, + actionSetAutoReload, +} from "./actions"; +import type { ActionResult, WebhookEventRow } from "./actions"; + +// ---- helpers -------------------------------------------------------------- + +const fmtCents = (n?: number | null) => + typeof n === "number" ? `$${(n / 100).toFixed(2)}` : "—"; + +const dollarsToCents = (s: string): number | null => { + const f = parseFloat(s); + if (!Number.isFinite(f) || f < 0) return null; + return Math.round(f * 100); +}; + +const PRIORITY_LABEL = (p?: number): string => { + if (p === undefined || p === null) return "—"; + if (p <= 70) return `PROMO (${p})`; + return `TOPUP (${p})`; +}; + +const fmtAge = (iso?: string | null) => { + if (!iso) return "—"; + const ms = Date.now() - new Date(iso).getTime(); + if (ms < 0) return "in the future"; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +}; + +/** Future- AND past-aware relative formatter. */ +const fmtRel = (iso?: string | null): string => { + if (!iso) return "—"; + const t = new Date(iso).getTime(); + if (!Number.isFinite(t)) return "—"; + const dms = t - Date.now(); + const future = dms > 0; + const abs = Math.abs(dms); + const s = Math.floor(abs / 1000); + let body: string; + if (s < 60) body = `${s}s`; + else if (s < 3600) body = `${Math.floor(s / 60)}m`; + else if (s < 86_400) body = `${Math.floor(s / 3600)}h`; + else if (s < 31_536_000) body = `${Math.floor(s / 86_400)}d`; + else body = `${Math.floor(s / 31_536_000)}y`; + return future ? `in ${body}` : `${body} ago`; +}; + +/** Translate Metronome's internal invoice `type` to user-facing language. */ +const INVOICE_TYPE_LABEL: Record = { + SCHEDULED: "Credit purchase", + USAGE: "Usage", + USAGE_CONSOLIDATED: "Usage (rolled up)", + CONTRACT_USAGE: "Usage", + AD_HOC: "One-off", +}; + +const fmtInvoiceType = (t?: string) => (t && INVOICE_TYPE_LABEL[t]) || t || "—"; + +/** Decode base64 → Blob → trigger a one-shot browser download. */ +function downloadBase64Pdf(filename: string, dataB64: string) { + const bin = atob(dataB64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + const blob = new Blob([bytes], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 5000); +} + +/** Hard year cutoff for "indefinite" top-up commits (FAR_FUTURE = 2099). */ +const isIndefinite = (iso?: string | null): boolean => { + if (!iso) return false; + const t = new Date(iso).getTime(); + if (!Number.isFinite(t)) return false; + // Anything > 10 years from now is treated as "no end". + return t - Date.now() > 10 * 365 * 86_400_000; +}; + +type Extract = T extends { ok: true; data: infer D } ? D : never; +type AccountView = Extract>>; +type BalanceData = Extract>>; +type EntitlementData = Extract< + Awaited> +>; +type TransactionsData = Extract< + Awaited> +>; +type InvoicesData = Extract>>; +type AlertsData = Extract>>; + +const TXN_LABEL: Record = { + topup: "Top-up", + auto_reload: "Auto-reload", + promo: "Promo", + unknown: "Credit", +}; + +const TXN_BADGE_CLASS: Record = { + topup: "bg-blue-600/15 text-blue-700 dark:text-blue-300", + auto_reload: "bg-purple-600/15 text-purple-700 dark:text-purple-300", + promo: "bg-amber-600/15 text-amber-700 dark:text-amber-300", + unknown: "bg-muted text-muted-foreground", +}; + +// ---- shared UI primitives ------------------------------------------------- + +function Section({ + step, + title, + description, + children, +}: { + step?: number; + title: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+

+ {step !== undefined && ( + + {step} + + )} + {title} +

+ {description && ( +

{description}

+ )} +
+ {children} +
+ ); +} + +function Btn({ + busy, + label, + loading, + onClick, + disabled, + variant = "default", +}: { + busy?: boolean; + label: string; + loading?: boolean; + onClick: () => void; + disabled?: boolean; + variant?: "default" | "primary" | "danger" | "ghost"; +}) { + const base = + "px-3 py-1.5 border rounded text-xs disabled:opacity-50 transition"; + const variants = { + default: "border-border hover:bg-muted", + primary: + "border-emerald-600 dark:border-emerald-500 bg-emerald-600/10 hover:bg-emerald-600/20", + danger: + "border-red-600 dark:border-red-500 bg-red-600/10 hover:bg-red-600/20", + ghost: "border-transparent hover:bg-muted text-muted-foreground", + }; + return ( + + ); +} + +function Pill({ + ok, + okLabel, + badLabel, +}: { + ok: boolean; + okLabel: string; + badLabel: string; +}) { + return ( + + {ok ? okLabel : badLabel} + + ); +} + +function DollarInput({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; +}) { + return ( +
+ + $ + + onChange(e.target.value)} + placeholder={placeholder} + inputMode="decimal" + /> +
+ ); +} + +// ---- the page ------------------------------------------------------------- + +export default function BillingDevView() { + const [orgId, setOrgId] = useState("1"); + const [topUpAmount, setTopUpAmount] = useState("25"); + const [compAmount, setCompAmount] = useState("5"); + const [reloadThreshold, setReloadThreshold] = useState("10"); + const [reloadAmount, setReloadAmount] = useState("50"); + const [ingestDollars, setIngestDollars] = useState("1"); + const [busy, setBusy] = useState(null); + const [lastResult, setLastResult] = useState(null); + const [view, setView] = useState(null); + const [balance, setBalance] = useState(null); + const [entitlement, setEntitlement] = useState(null); + const [events, setEvents] = useState([]); + const [transactions, setTransactions] = useState( + null + ); + const [invoices, setInvoices] = useState(null); + const [alerts, setAlerts] = useState(null); + const [showRaw, setShowRaw] = useState(false); + + const orgIdNum = (() => { + const n = parseInt(orgId, 10); + return Number.isFinite(n) ? n : null; + })(); + + const refreshAll = useCallback(async (id: number) => { + const [v, b, e, ev, tx, inv, al] = await Promise.all([ + actionGetAccountView(id), + actionGetBalance(id), + actionGetEntitlement(id), + actionListWebhookEvents(id, 20), + actionGetTransactions(id), + actionGetInvoices(id), + actionGetAlertsStatus(id), + ]); + if (v.ok) setView(v.data); + if (b.ok) setBalance(b.data); + if (e.ok) setEntitlement(e.data); + if (ev.ok) setEvents(ev.data); + if (tx.ok) setTransactions(tx.data); + if (inv.ok) setInvoices(inv.data); + if (al.ok) setAlerts(al.data); + }, []); + + // Auto-load state when org id is valid + on mount. + useEffect(() => { + if (orgIdNum !== null) { + void refreshAll(orgIdNum); + } + }, [orgIdNum, refreshAll]); + + // Auto-poll the webhook log every 5s so users see events arrive live. + useEffect(() => { + if (orgIdNum === null) return; + const t = setInterval(() => { + void actionListWebhookEvents(orgIdNum, 20).then((r) => { + if (r.ok) setEvents(r.data); + }); + }, 5000); + return () => clearInterval(t); + }, [orgIdNum]); + + const run = useCallback( + async (label: string, fn: () => Promise>) => { + if (orgIdNum === null) { + setLastResult({ + action: label, + ok: false, + error: "org_id must be a number", + }); + return; + } + setBusy(label); + try { + const res = await fn(); + setLastResult({ action: label, ...res }); + await refreshAll(orgIdNum); + return res; + } finally { + setBusy(null); + } + }, + [orgIdNum, refreshAll] + ); + + const account = view?.db ?? null; + const live = view?.live ?? null; + const drift = view?.drift; + const driftKeys = drift + ? (Object.keys(drift) as Array).filter((k) => drift[k]) + : []; + const stripeLinked = !!account?.stripe_customer_id; + const metronomeLinked = !!account?.metronome_customer_id; + const autoReloadOn = !!live?.autoReload?.enabled; + const liveBalanceCents = live?.balanceCents ?? null; + // Empty-window state: auto-reload is configured AND balance is below + // its threshold. Metronome's recharge is in-flight (~3-5 min). + const reloadThresholdCents = live?.autoReload?.thresholdCents ?? null; + const reloadInFlight = + autoReloadOn && + reloadThresholdCents !== null && + liveBalanceCents !== null && + liveBalanceCents < reloadThresholdCents; + + const lastResultTyped = lastResult as + | { action: string; ok: true; data: unknown } + | { action: string; ok: false; error: string } + | null; + + return ( +
+
+ {/* Header ------------------------------------------------------ */} +
+

+ Billing — dev harness +

+

+ Configure billing on the left; consume credits on the right. +

+
+ + + The AI seam isn't wired yet. Ingesting past balance creates + real PAYG charges on the draft invoice. + +
+
+ + {/* Result banner — visible feedback for every action ----------- */} + {lastResultTyped && ( +
+
+
+ + {lastResultTyped.ok ? "✓" : "✗"} + {" "} + {lastResultTyped.action}{" "} + {lastResultTyped.ok ? "succeeded" : "failed"} +
+ +
+ {!lastResultTyped.ok && ( +
+                {lastResultTyped.error}
+              
+ )} + {lastResultTyped.ok && + lastResultTyped.data !== null && + lastResultTyped.data !== undefined && ( +
+                  {JSON.stringify(lastResultTyped.data, null, 2)}
+                
+ )} +
+ )} + + {/* 1. Pick an org --------------------------------------------- */} +
+
+ +
+
+
+ + run("linkStripe", () => + actionLinkStripeAndAttachTestCard(orgIdNum!) + ) + } + /> +

+ Creates Stripe customer if missing, attaches{" "} + pm_card_visa as + default. Test mode only. +

+
+
+ + run("provision", () => actionProvisionOrg(orgIdNum!)) + } + /> +

+ Match-or-create the Metronome customer + contract. If Stripe is + linked, the contract uses it for billing. +

+
+
+
+ + {/* Two-column body — billing portal (left) | usage (right) ---- */} +
+ {/* ====== LEFT: Billing portal ============================== */} +
+
+ Billing portal — configure & top up +
+ + {/* 2. State ------------------------------------------------ */} +
+ {/* Linkage badges */} +
+ + + +
+ + {driftKeys.length > 0 && ( +
+ ⚠ DB cache drift — local + cache disagrees with Metronome on:{" "} + + {driftKeys.join(", ")} + + . Usually a dropped webhook. UI shows the live values; click{" "} + Refresh from Metronome to re-sync the cache. +
+ )} + + {/* Gate verdict — the headline */} +
+
+
+ Gate decision +
+
+ cache {fmtAge(account?.cached_balance_at)} +
+
+
+ {entitlement ? ( + + {entitlement.allowed + ? "ALLOWED" + : `BLOCKED — ${entitlement.reason ?? "?"}`} + + ) : ( + + )} + + live + + {fmtCents(liveBalanceCents)} + + + + (cache {fmtCents(account?.cached_balance_cents)}) + +
+ + {reloadInFlight && ( +
+ ⏳ Topping up — balance + is below the auto-reload threshold ( + {fmtCents(reloadThresholdCents)}). Metronome typically fires + the recharge within 3–5 minutes; the gate may refuse during + this window. +
+ )} + +
+ + run("refreshBalance", () => + actionRefreshBalance(orgIdNum!) + ) + } + /> +
+
+ + {/* Alerts (auto-provisioned by provisionOrg) */} +
+
+ Low-balance alerts ({alerts?.length ?? 0}) + + $0 depletion is auto-provisioned; warning tiers configurable + +
+ {!alerts || alerts.length === 0 ? ( +
+ No alerts yet — re-run "Provision Metronome" to attach the + $0 depletion alert. +
+ ) : ( + + + + + + + + + + {alerts.map((a) => ( + + + + + + ))} + +
name + threshold + status
+ {a.name} + {a.thresholdCents === 0 && ( + + (depletion) + + )} + + {fmtCents(a.thresholdCents)} + + {a.status === "in_alarm" ? ( + + ⚠ TRIGGERED + + ) : a.status === "ok" ? ( + + ok + + ) : a.status === "evaluating" ? ( + + evaluating + + ) : ( + + — + + )} + {a.triggeredBy && ( +
+ {a.triggeredBy} +
+ )} +
+ )} +
+ + {/* Commits */} +
+
+ Live commits ({balance?.commits.length ?? 0}) + {balance && ( + + total {fmtCents(balance.totalCents)} + + )} +
+ {balance && balance.commits.length > 0 ? ( + + + + + + + + + + + + + {balance.commits.map( + (c: BalanceData["commits"][number]) => ( + + + + + + + + + ) + )} + +
nameprioritytime + initial + + remaining +
+ {c.name ?? c.id.slice(0, 8) + "…"} + + {PRIORITY_LABEL(c.priority)} + +
+ start: {fmtRel(c.startingAt)} +
+
+ {isIndefinite(c.endingBefore) + ? "no end" + : `end: ${fmtRel(c.endingBefore)}`} +
+
+ {fmtCents(c.initial)} + + {fmtCents(c.balance)} + + + run(`revoke-${c.id}`, () => + actionRevokeUnused(orgIdNum!, c.id) + ) + } + /> +
+ ) : ( +
+ No commits yet. Add one in section 3. +
+ )} +
+
+ + {/* 3. Add credit ------------------------------------------ */} +
+
+ {/* Stripe top-up */} +
+
Top-up
+
+ Real Stripe charge. $5–$1000. Drains last (TOPUP, prio 90). +
+ + { + const c = dollarsToCents(topUpAmount); + if (c === null) return; + run("topup", () => + actionAddStripeChargedCommit(orgIdNum!, c) + ); + }} + /> + {!stripeLinked && ( +
+ Org needs a Stripe customer first. +
+ )} +
+ + {/* Complimentary */} +
+
Complimentary
+
+ No Stripe charge. For dev / promo / refund / manual. PROMO + priority (50). +
+ + { + const c = dollarsToCents(compAmount); + if (c === null) return; + run("compCommit", () => + actionAddComplimentaryCommit(orgIdNum!, c) + ); + }} + /> +
+
+
+ + {/* 4. Auto-reload ----------------------------------------- */} +
+
+ + +
+
+ { + const t = dollarsToCents(reloadThreshold); + const a = dollarsToCents(reloadAmount); + if (t === null || a === null) return; + run("setAutoReload", () => + actionSetAutoReload(orgIdNum!, t, a) + ); + }} + /> + + run("disableAutoReload", () => + actionDisableAutoReload(orgIdNum!) + ) + } + /> + {autoReloadOn && ( + + live: {fmtCents(live!.autoReload!.thresholdCents)} →{" "} + {fmtCents(live!.autoReload!.rechargeToCents)} + + )} +
+
+
+ {/* ====== RIGHT: Usage ===================================== */} +
+
+ Usage — consume credits +
+ + {/* 5. Simulate usage -------------------------------------- */} +
+
+ + { + const cents = dollarsToCents(ingestDollars); + if (cents === null) return; + const mills = cents * 10; + run("ingestGated", () => + actionIngestGated(orgIdNum!, mills) + ); + }} + /> + { + const cents = dollarsToCents(ingestDollars); + if (cents === null) return; + const mills = cents * 10; + run("ingest", () => actionIngest(orgIdNum!, mills)); + }} + /> + + {((dollarsToCents(ingestDollars) ?? 0) * 10).toLocaleString()}{" "} + mills + +
+
+ Gated matches the seam: checks the entitlement + cache, throws 402 if blocked. Raw bypasses the + gate (use to demonstrate past-zero ingest creating PAYG + charges). +
+
+ + {/* 6. Webhook log ---------------------------------------- */} +
+ {events.length === 0 ? ( +
+ No events for this org yet. Triggers: top-up, auto-reload, + balance-zero alert, commit lifecycle. +
+ ) : ( + + + + + + + + + + {events.map((e) => ( + + + + + + ))} + +
receivedeventstatus
+ {fmtAge(e.received_at)} + +
{e.event_type}
+ {e.payment_status && ( +
+ payment: {e.payment_status} +
+ )} +
+ {e.processed_at ? ( + + processed + + ) : e.failure_reason ? ( + + failed: {e.failure_reason} + + ) : ( + + pending + + )} +
+ )} +
+
+ {/* end RIGHT column */} +
+ {/* end two-column body */} + + {/* User-facing preview ---------------------------------------- */} +
+
+

+ + ★ + + User-facing preview +

+

+ How the customer sees their billing. Same data as above but in + their vocabulary — no "commit", no "contract", no Metronome ids. +

+
+ + {/* Balance headline (user-facing) */} +
+
Balance
+
+ {fmtCents(liveBalanceCents)} +
+ {autoReloadOn && ( +
+ Auto-recharges to{" "} + {fmtCents(live!.autoReload!.rechargeToCents)}{" "} + when below{" "} + {fmtCents(live!.autoReload!.thresholdCents)}. +
+ )} +
+ +
+ {/* Recent activity (transactions) */} +
+
+ Recent activity +
+ {!transactions || transactions.length === 0 ? ( +
+ No activity yet. +
+ ) : ( + + + + + + + + + + + + {transactions.slice(0, 12).map((t) => ( + + + + + + + + ))} + +
typedate + amount + + remaining + status
+ + {TXN_LABEL[t.kind] ?? "Credit"} + +
+ {t.description} +
+
+ {fmtRel(t.at)} + + +{fmtCents(t.amountCents)} + + {fmtCents(t.remainingCents)} + + {t.paid ? ( + + paid + + ) : ( + + )} +
+ )} + {transactions && transactions.length > 12 && ( +
+ +{transactions.length - 12} older entries +
+ )} +
+ + {/* Invoices */} +
+
+ Invoices +
+ {!invoices || invoices.length === 0 ? ( +
+ No invoices yet. +
+ ) : ( + + + + + + + + + + + {invoices.slice(0, 12).map((inv) => ( + + + + + + + ))} + +
issuedtype + total + status
+ {fmtRel(inv.issuedAt)} + +
+ {fmtInvoiceType(inv.type)} +
+ {inv.lineItems[0] && ( +
+ {inv.lineItems[0].name} + {inv.lineItems.length > 1 && + ` +${inv.lineItems.length - 1}`} +
+ )} +
+ {fmtCents(inv.totalCents)} + +
+ {inv.external?.status ?? inv.status} +
+
+ +
+ {inv.external && ( +
+ via {inv.external.provider} + {inv.external.receiptUrl && ( + <> + · + + Receipt + + + )} + {inv.external.pdfUrl && ( + <> + · + + PDF + + + )} +
+ )} +
+ )} + {invoices && invoices.length > 12 && ( +
+ +{invoices.length - 12} older invoices +
+ )} +
+
+
+ + {/* Advanced --------------------------------------------------- */} +
+
+ setShowRaw((v) => !v)} + /> +
+ {showRaw && ( +
+              {lastResult ? JSON.stringify(lastResult, null, 2) : "(none)"}
+            
+ )} +
+
+
+ ); +} diff --git a/editor/app/(insiders)/insiders/billing/actions.ts b/editor/app/(insiders)/insiders/billing/actions.ts new file mode 100644 index 000000000..9275c3882 --- /dev/null +++ b/editor/app/(insiders)/insiders/billing/actions.ts @@ -0,0 +1,262 @@ +"use server"; + +// GRIDA-SEC-002 — +// see editor/proxy.ts and /SECURITY.md. Every action in this file accepts +// an attacker-supplied `organizationId` and performs privileged Metronome / +// Stripe operations against it without checking caller membership. That is +// only safe because the `(insiders)` route group is gated to local +// development by the proxy + the route-group layout. Do NOT import any of +// these actions from production code paths — that bypass would defeat the +// gate (server-action hashes are addressable from any page that imports +// them, regardless of the URL the action originally lived under). + +// Insiders dev harness server actions for the Metronome lifecycle. +// Thin wrappers over `lib/billing/metronome` service functions. +// All ops take an `organizationId: number` (the bigint PK on `public.organization`). + +import { stripe, resolveOrCreateStripeCustomer } from "@/lib/billing"; +import { service_role } from "@/lib/supabase/server"; +import { + addComplimentaryCommit, + addStripeChargedCommit, + disableAutoReload, + getAccount, + getAccountView, + getAlertsStatus, + getEntitlement, + getInvoicePdfBase64, + getInvoices, + getOrgBalance, + getTransactions, + ingestUsageEvent, + ingestUsageEventGated, + provisionLowBalanceAlert, + provisionOrg, + refreshBalance, + revokeUnusedOnCommit, + setAutoReload, +} from "@/lib/billing/metronome"; + +export type ActionResult = + | { ok: true; data: T } + | { ok: false; error: string }; + +function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} + +async function wrap(fn: () => Promise): Promise> { + try { + return { ok: true, data: await fn() }; + } catch (err) { + return { ok: false, error: errorMessage(err) }; + } +} + +// -- account / state ------------------------------------------------------- + +/** + * Live account view for the dev page: DB cache + Metronome live + drift. + * The UI should display `live.*` as the source of truth and surface drift + * when present (= dropped webhook). + */ +export async function actionGetAccountView(organizationId: number) { + return wrap(() => getAccountView(organizationId)); +} + +export async function actionGetEntitlement(organizationId: number) { + return wrap(() => getEntitlement(organizationId)); +} + +export async function actionGetBalance(organizationId: number) { + return wrap(() => getOrgBalance(organizationId)); +} + +export async function actionRefreshBalance(organizationId: number) { + return wrap(() => refreshBalance(organizationId)); +} + +// -- user-facing translations (transactions + invoices) ------------------- + +export async function actionGetTransactions(organizationId: number) { + return wrap(() => getTransactions(organizationId)); +} + +export async function actionGetInvoices(organizationId: number) { + return wrap(() => getInvoices(organizationId)); +} + +export async function actionGetAlertsStatus(organizationId: number) { + return wrap(() => getAlertsStatus(organizationId)); +} + +/** + * Fetch the Metronome-rendered PDF for one invoice. Returns base64 + a + * suggested filename; the UI is expected to decode and trigger a + * download via Blob + object URL. + */ +export async function actionGetInvoicePdf( + organizationId: number, + invoiceId: string +) { + return wrap(() => getInvoicePdfBase64(organizationId, invoiceId)); +} + +// -- provisioning ---------------------------------------------------------- + +export async function actionProvisionOrg(organizationId: number) { + return wrap(() => provisionOrg(organizationId)); +} + +/** + * Provision a low-balance alert at an arbitrary threshold (cents). + * Use 0 for the depletion-tier alert (flips entitlement off). + * Use any positive value for warning tiers (refresh balance only). + */ +export async function actionProvisionLowBalanceAlert( + organizationId: number, + thresholdCents: number, + name?: string +) { + return wrap(() => + provisionLowBalanceAlert(organizationId, thresholdCents, { name }) + ); +} + +// -- commits --------------------------------------------------------------- + +export async function actionAddComplimentaryCommit( + organizationId: number, + amountCents: number, + name?: string, + priority?: number +) { + return wrap(() => + addComplimentaryCommit(organizationId, amountCents, { name, priority }) + ); +} + +export async function actionAddStripeChargedCommit( + organizationId: number, + amountCents: number, + name?: string +) { + return wrap(() => + addStripeChargedCommit(organizationId, amountCents, { name }) + ); +} + +export async function actionRevokeUnused( + organizationId: number, + commitId: string +) { + return wrap(() => revokeUnusedOnCommit(organizationId, commitId)); +} + +// -- auto-reload ----------------------------------------------------------- + +export async function actionSetAutoReload( + organizationId: number, + thresholdCents: number, + rechargeAmountCents: number +) { + return wrap(() => + setAutoReload(organizationId, thresholdCents, rechargeAmountCents) + ); +} + +export async function actionDisableAutoReload(organizationId: number) { + return wrap(() => disableAutoReload(organizationId)); +} + +// -- ingest (manual usage event for QA) ----------------------------------- + +export async function actionIngest(organizationId: number, costMills: number) { + return wrap(() => ingestUsageEvent(organizationId, costMills)); +} + +export async function actionIngestGated( + organizationId: number, + costMills: number +) { + return wrap(() => ingestUsageEventGated(organizationId, costMills)); +} + +// -- webhook log (recent events from grida_billing.metronome_event) ------- + +export type WebhookEventRow = { + event_id: string; + event_type: string; + received_at: string; + processed_at: string | null; + failure_reason: string | null; + customer_id: string | null; + payment_status: string | null; +}; + +export async function actionListWebhookEvents( + organizationId: number, + limit: number = 20 +) { + return wrap(async (): Promise => { + const { data, error } = await service_role.workspace.rpc( + "fn_billing_list_metronome_events" as never, + { p_org: organizationId, p_limit: limit } as never + ); + if (error) throw new Error(error.message); + return (data ?? []) as WebhookEventRow[]; + }); +} + +// -- one-click Stripe customer + test payment method (insiders QA only) ---- + +export type LinkStripeResult = { + stripeCustomerId: string; + paymentMethodId: string; + alreadyLinked: boolean; +}; + +/** + * Insiders-only convenience: ensure the org has a Stripe customer, attach + * a Stripe test payment method (`pm_card_visa`), and set it as default. + * + * Refuses to run if STRIPE_SECRET_KEY is not a test key. + */ +export async function actionLinkStripeAndAttachTestCard( + organizationId: number +) { + return wrap(async (): Promise => { + const sk = process.env.STRIPE_SECRET_KEY ?? ""; + if (!sk.startsWith("sk_test_")) { + throw new Error( + "Refusing: STRIPE_SECRET_KEY must start with 'sk_test_'." + ); + } + + const before = await getAccount(organizationId); + const alreadyLinked = !!before?.stripe_customer_id; + + const stripeCustomerId = + await resolveOrCreateStripeCustomer(organizationId); + + // Attach pm_card_visa if not already there. + const existing = await stripe.paymentMethods.list({ + customer: stripeCustomerId, + type: "card", + limit: 5, + }); + let pm = existing.data[0]; + if (!pm) { + pm = await stripe.paymentMethods.attach("pm_card_visa", { + customer: stripeCustomerId, + }); + } + + await stripe.customers.update(stripeCustomerId, { + invoice_settings: { default_payment_method: pm.id }, + }); + + return { stripeCustomerId, paymentMethodId: pm.id, alreadyLinked }; + }); +} diff --git a/editor/app/(insiders)/insiders/billing/page.tsx b/editor/app/(insiders)/insiders/billing/page.tsx new file mode 100644 index 000000000..50af22a4b --- /dev/null +++ b/editor/app/(insiders)/insiders/billing/page.tsx @@ -0,0 +1,7 @@ +import BillingDevView from "./_view"; + +export const dynamic = "force-dynamic"; + +export default function BillingDevPage() { + return ; +} diff --git a/editor/app/(insiders)/layout.tsx b/editor/app/(insiders)/layout.tsx index da5eabd5b..0c402926a 100644 --- a/editor/app/(insiders)/layout.tsx +++ b/editor/app/(insiders)/layout.tsx @@ -1,3 +1,15 @@ +// GRIDA-SEC-002 — +// see editor/proxy.ts and /SECURITY.md. +// +// The `(insiders)` route group hosts a developer harness with intentionally +// unauthenticated server actions (mutators that take an arbitrary +// `organizationId` as the first argument). It must only ever run in local +// development. The proxy at `editor/proxy.ts` is the primary gate (404s +// every `/insiders/*` request when `NODE_ENV !== "development"`); this +// layout-level `notFound()` is the defense-in-depth fallback for any code +// path that bypasses the proxy. + +import { notFound } from "next/navigation"; import { ThemeProvider } from "@/components/theme-provider"; import { Inter } from "next/font/google"; import "../globals.css"; @@ -14,6 +26,11 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { + // GRIDA-SEC-002: fail closed when not in local dev. + if (process.env.NODE_ENV !== "development") { + notFound(); + } + return ( diff --git a/editor/proxy.ts b/editor/proxy.ts index 1c0cf956f..41a4ba803 100644 --- a/editor/proxy.ts +++ b/editor/proxy.ts @@ -30,6 +30,23 @@ export async function proxy(req: NextRequest) { return new NextResponse("Not Found", { status: 404 }); } + // GRIDA-SEC-002 — + // see editor/app/(insiders)/layout.tsx and /SECURITY.md. + // + // The `(insiders)` route group is a developer harness with intentionally + // unauthenticated server actions (mutators that take an arbitrary + // `organizationId` as the first argument). It MUST NOT be reachable from + // any non-development environment. Block both page loads and server-action + // POSTs at the proxy — the gate runs before any handler, so this also + // covers `Next-Action` invocations against `/insiders/*`. + if ( + (req.nextUrl.pathname === "/insiders" || + req.nextUrl.pathname.startsWith("/insiders/")) && + !IS_DEV + ) { + return new NextResponse("Not Found", { status: 404 }); + } + // GRIDA-SEC-001 — // see editor/app/(ingest)/README.md and /SECURITY.md. // From b4d31f199116bd3bc764c4fc1be8d6eebeeaaa70 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 11 May 2026 04:25:22 +0900 Subject: [PATCH 07/21] chore(scripts/billing): consolidate dev/QA scripts into one CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 27 ad-hoc scripts → 6 files: cli.ts single entry point with subcommands _env.ts shared env loader (.env.test.local > .env.test > .env.local) setup.ts setup:stripe, setup:metronome smoke.ts ping, smoke:topup, smoke:auto-reload, smoke:webhook ops.ts backfill, markup-sim README.md Drops: - All `_*` underscore-prefix diagnostics (transient debug artifacts). - All mid-development spike scripts (overage-test, spike-experiments, stripe-poc, refire-test, empty-window, drift-test, multi-tier-alerts, optimistic-debit) — proven and folded into code/docs. - tunnel.sh — locally-configured cloudflared per docs/contributing/billing.md. --- editor/scripts/billing/README.md | 55 ++ editor/scripts/billing/_env.ts | 50 ++ editor/scripts/billing/cli.ts | 89 ++++ editor/scripts/billing/ops.ts | 242 +++++++++ editor/scripts/billing/setup-stripe-test.ts | 325 ------------ editor/scripts/billing/setup.ts | 388 ++++++++++++++ editor/scripts/billing/smoke.ts | 541 ++++++++++++++++++++ 7 files changed, 1365 insertions(+), 325 deletions(-) create mode 100644 editor/scripts/billing/README.md create mode 100644 editor/scripts/billing/_env.ts create mode 100644 editor/scripts/billing/cli.ts create mode 100644 editor/scripts/billing/ops.ts delete mode 100644 editor/scripts/billing/setup-stripe-test.ts create mode 100644 editor/scripts/billing/setup.ts create mode 100644 editor/scripts/billing/smoke.ts diff --git a/editor/scripts/billing/README.md b/editor/scripts/billing/README.md new file mode 100644 index 000000000..508bf6424 --- /dev/null +++ b/editor/scripts/billing/README.md @@ -0,0 +1,55 @@ +# Billing CLI + +Single-entry CLI for billing setup, smoke tests, and ops helpers. Runs against +your **sandbox** Stripe + Metronome accounts. + +> Setup guide (env, tunnel, accounts) lives in +> [`docs/contributing/billing.md`](../../../docs/contributing/billing.md). +> Design notes: [`docs/wg/platform/ai-credits.md`](../../../docs/wg/platform/ai-credits.md). + +## Usage + +```sh +pnpm tsx editor/scripts/billing/cli.ts +``` + +Run with no args for the full command index. + +## Commands + +| Command | When to run it | +| ------------------- | ---------------------------------------------------------------------------------------- | +| `setup:stripe` | After every `supabase db reset`. Provisions products, prices, Customer Portal config. | +| `setup:metronome` | After every `supabase db reset`. Provisions billable metric, products, rate card, rate. | +| `ping` | Confirm `METRONOME_API_TOKEN` is good and pointing at the expected workspace. | +| `smoke:topup` | Demonstrate end-to-end prepaid-credit flow against a sandbox customer. No Stripe charge. | +| `smoke:auto-reload` | Demonstrate Metronome's threshold-recharge: drain below threshold → silent recharge. | +| `smoke:webhook` | 3-layer probe (localhost → tunnel → DB). Pinpoints which link is broken. | +| `backfill` | Provision Metronome customer + contract for every existing org. Idempotent. | +| `markup-sim` | Audit the AI-credit markup formula across all Stripe card types. | + +`backfill` honors `ORG_FILTER=` (one org) and `DRY_RUN=true` (report only). + +## File map + +``` +cli.ts # entry point + dispatch +_env.ts # env loader (precedence: process.env > .env.test.local > .env.test > .env.local) +setup.ts # setupStripe(), setupMetronome() +smoke.ts # ping(), topup(), autoReload(), webhook() +ops.ts # backfill(), markupSim() +``` + +Each module is independently importable in case you want to script a flow from +elsewhere — call `setup.setupMetronome()` etc. directly. + +## Cleaning up sandbox state + +Sandbox resources accumulate across runs. Re-running `setup:*` reuses +existing resources by name; that's fine. To wipe and start over: + +- **Customers / contracts** — archive in the Metronome dashboard. +- **Substrate** (metric / products / rate card) — archive in the dashboard; + next `setup:metronome` recreates them. +- **Stripe products / prices** — archive in the Stripe dashboard; + next `setup:stripe` recreates them. diff --git a/editor/scripts/billing/_env.ts b/editor/scripts/billing/_env.ts new file mode 100644 index 000000000..f1263702c --- /dev/null +++ b/editor/scripts/billing/_env.ts @@ -0,0 +1,50 @@ +// Shared env loader for billing scripts. Must be imported BEFORE any +// `lib/billing` import — those modules throw at load time on missing +// STRIPE_SECRET_KEY / SUPABASE_*. +// +// Precedence: process.env > .env.test.local > .env.test > .env.local + +import * as fs from "node:fs"; +import * as path from "node:path"; + +function loadFile(filePath: string): void { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, "utf8").split("\n")) { + const t = line.trim(); + if (!t || t.startsWith("#")) continue; + const eq = t.indexOf("="); + if (eq < 0) continue; + const k = t.slice(0, eq).trim(); + let v = t.slice(eq + 1).trim(); + if ( + (v.startsWith('"') && v.endsWith('"')) || + (v.startsWith("'") && v.endsWith("'")) + ) { + v = v.slice(1, -1); + } + if (!(k in process.env)) process.env[k] = v; + } +} + +const editorDir = path.resolve(__dirname, "..", ".."); +loadFile(path.join(editorDir, ".env.test.local")); +loadFile(path.join(editorDir, ".env.test")); +loadFile(path.join(editorDir, ".env.local")); + +export function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) + throw new Error(`${name} is required (set in editor/.env.test.local).`); + return v; +} + +export function requireStripeTestKey(): string { + const sk = requireEnv("STRIPE_SECRET_KEY"); + if (!sk.startsWith("sk_test_")) { + throw new Error("Refusing: STRIPE_SECRET_KEY must start with 'sk_test_'."); + } + if (process.env.BILLING_TEST_MODE !== "true") { + throw new Error("Refusing: BILLING_TEST_MODE must be 'true'."); + } + return sk; +} diff --git a/editor/scripts/billing/cli.ts b/editor/scripts/billing/cli.ts new file mode 100644 index 000000000..e44df84ce --- /dev/null +++ b/editor/scripts/billing/cli.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env -S pnpm tsx +// Billing CLI — single entry point for setup, smoke tests, and ops helpers. +// +// pnpm tsx editor/scripts/billing/cli.ts [args] +// +// Run with no args for the command index. Env loading is handled by `_env` +// (auto-imported by each command module). + +import "./_env"; + +type Cmd = { + name: string; + desc: string; + run: () => Promise; +}; + +async function main(): Promise { + const setup = await import("./setup"); + const smoke = await import("./smoke"); + const ops = await import("./ops"); + + const COMMANDS: Cmd[] = [ + { + name: "setup:stripe", + desc: "Provision Stripe products + prices + Customer Portal config (test mode).", + run: setup.setupStripe, + }, + { + name: "setup:metronome", + desc: "Provision Metronome billable metric, products, rate card + rate (sandbox).", + run: setup.setupMetronome, + }, + { + name: "ping", + desc: "List Metronome customers / metrics / products / rate cards. Read-only.", + run: smoke.ping, + }, + { + name: "smoke:topup", + desc: "End-to-end prepaid-credit flow against a sandbox customer (no Stripe charge).", + run: smoke.topup, + }, + { + name: "smoke:auto-reload", + desc: "Provision Stripe + Metronome customer, enable auto-reload, drain, watch recharge.", + run: smoke.autoReload, + }, + { + name: "smoke:webhook", + desc: "3-layer probe of the Metronome webhook pipeline (localhost, tunnel, DB).", + run: smoke.webhook, + }, + { + name: "backfill", + desc: "Provision Metronome for every existing organization. ORG_FILTER / DRY_RUN env.", + run: ops.backfill, + }, + { + name: "markup-sim", + desc: "Audit the AI-credit markup formula across all Stripe card types.", + run: ops.markupSim, + }, + ]; + + const arg = process.argv[2]; + if (!arg || arg === "-h" || arg === "--help" || arg === "help") { + console.log("Usage: pnpm tsx editor/scripts/billing/cli.ts \n"); + console.log("Commands:"); + const w = Math.max(...COMMANDS.map((c) => c.name.length)); + for (const c of COMMANDS) { + console.log(` ${c.name.padEnd(w)} ${c.desc}`); + } + console.log("\nSee editor/scripts/billing/README.md for env setup."); + return; + } + + const cmd = COMMANDS.find((c) => c.name === arg); + if (!cmd) { + console.error(`Unknown command: ${arg}`); + console.error(`Run with --help for the command index.`); + process.exit(2); + } + await cmd.run(); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/editor/scripts/billing/ops.ts b/editor/scripts/billing/ops.ts new file mode 100644 index 000000000..3c53ce232 --- /dev/null +++ b/editor/scripts/billing/ops.ts @@ -0,0 +1,242 @@ +// One-shot operational helpers — backfill, markup audit. Each is independently +// runnable and idempotent. + +import "./_env"; + +// --------------------------------------------------------------------------- +// backfill — ensure every existing organization has a Metronome customer + +// contract provisioned. Mirrors the lazy `provisionOrg` call the user-facing +// billing page makes; running this ahead of cutover guarantees first-load +// is a no-op. `provisionOrg` is match-or-create; safe to re-run. +// +// Env knobs: +// ORG_FILTER=255 one org by id (default: all) +// DRY_RUN=true report only +// --------------------------------------------------------------------------- + +export async function backfill(): Promise { + const { service_role } = await import("../../lib/supabase/server"); + const { provisionOrg } = await import("../../lib/billing/metronome"); + + const filterRaw = process.env.ORG_FILTER; + const filterId = + filterRaw && filterRaw !== "all" ? parseInt(filterRaw, 10) : null; + const dryRun = process.env.DRY_RUN === "true"; + + let q = service_role.workspace + .from("organization") + .select("id, name") + .order("id", { ascending: true }); + if (filterId !== null) q = q.eq("id", filterId); + const { data, error } = await q; + if (error) throw new Error(`organization list: ${error.message}`); + const orgs = (data ?? []) as Array<{ id: number; name: string }>; + + console.log(`[backfill] ${orgs.length} org(s)${dryRun ? " (dry-run)" : ""}`); + let ok = 0; + let failed = 0; + let skipped = 0; + for (const org of orgs) { + if (dryRun) { + console.log(` [dry] org=${org.id} name=${org.name}`); + skipped++; + continue; + } + try { + const r = await provisionOrg(org.id); + const note = + r.created.customer || r.created.contract + ? `created${r.created.customer ? " customer" : ""}${r.created.contract ? " contract" : ""}` + : "already wired"; + console.log( + ` ✓ org=${org.id} name=${org.name} → ${note} customer=${r.customerId} contract=${r.contractId}` + ); + ok++; + } catch (e) { + console.error( + ` ✗ org=${org.id} name=${org.name} → ${e instanceof Error ? e.message : String(e)}` + ); + failed++; + } + } + console.log(`\n[backfill] ok=${ok} skipped=${skipped} failed=${failed}`); + if (failed > 0) process.exit(1); +} + +// --------------------------------------------------------------------------- +// markup-sim — find a flat markup formula that NEVER loses money on any +// Stripe card type for top-ups in [$10, $500]. Audit trail for the formula +// in `lib/billing/fees.ts`. Re-run when Stripe rates change. +// --------------------------------------------------------------------------- + +type CardType = { name: string; pct: number; fixed: number }; +const CARDS: CardType[] = [ + { name: "us_card", pct: 0.029, fixed: 30 }, + { name: "intl_card", pct: 0.039, fixed: 30 }, + { name: "amex_us", pct: 0.035, fixed: 30 }, + { name: "intl_amex", pct: 0.044, fixed: 30 }, + { name: "us_card_fx", pct: 0.039, fixed: 30 }, + { name: "intl_card_fx", pct: 0.049, fixed: 30 }, +]; +const TEST_AMOUNTS_CENTS = [1000, 2500, 5000, 10000, 20000, 50000]; + +type Formula = { label: string; total: (creditCents: number) => number }; +const flatPct = (p: number): Formula => ({ + label: `ceil(c * ${(1 + p).toFixed(3)}) [+${(p * 100).toFixed(1)}%]`, + total: (c) => Math.ceil(c * (1 + p)), +}); +const flatPctPlusFixed = (p: number, f: number): Formula => ({ + label: `ceil(c * ${(1 + p).toFixed(3)} + ${f}) [+${(p * 100).toFixed(1)}% + ${(f / 100).toFixed(2)}]`, + total: (c) => Math.ceil(c * (1 + p) + f), +}); +const grossUp = (p: number, f: number): Formula => ({ + label: `ceil((c + ${f}) / ${(1 - p).toFixed(3)}) [gross-up ${(p * 100).toFixed(1)}% + ${(f / 100).toFixed(2)}]`, + total: (c) => Math.ceil((c + f) / (1 - p)), +}); + +const FORMULAS: Formula[] = [ + flatPct(0.04), + flatPct(0.045), + flatPct(0.05), + flatPct(0.055), + flatPct(0.06), + flatPct(0.07), + flatPct(0.08), + flatPctPlusFixed(0.04, 50), + flatPctPlusFixed(0.045, 30), + flatPctPlusFixed(0.05, 30), + flatPctPlusFixed(0.05, 50), + flatPctPlusFixed(0.04, 75), + grossUp(0.045, 30), + grossUp(0.045, 50), + grossUp(0.05, 30), + grossUp(0.05, 50), + grossUp(0.05, 75), + grossUp(0.055, 30), +]; + +const dollars = (cents: number) => + `${cents < 0 ? "-" : ""}$${(Math.abs(cents) / 100).toFixed(2)}`; +const pct = (x: number) => `${(x * 100).toFixed(1)}%`; + +// Stripe rounds the fee up to the nearest cent. +const stripeFee = (amountCents: number, card: CardType) => + Math.ceil(amountCents * card.pct + card.fixed); + +export async function markupSim(): Promise { + type Eval = { + rows: Array<{ + amount: number; + card: string; + total: number; + profit: number; + markupPct: number; + }>; + worstLossCents: number; + worstCardForLoss: string; + bestCaseOver10: number; + bestCaseOver10Pct: number; + maxMarkupPctOnUs: number; + maxOverchargeAnywhere: number; + }; + + const evaluate = (f: Formula): Eval => { + const rows: Eval["rows"] = []; + let worstLoss = 0; + let worstCard = ""; + let bestCaseOver10 = 0; + let bestCaseOver10Pct = 0; + let maxUsMarkupPct = 0; + let maxOverAny = 0; + for (const amount of TEST_AMOUNTS_CENTS) { + const total = f.total(amount); + for (const card of CARDS) { + const fee = stripeFee(total, card); + const profit = total - fee - amount; + const markupPct = (total - amount) / amount; + rows.push({ amount, card: card.name, total, profit, markupPct }); + if (profit < worstLoss) { + worstLoss = profit; + worstCard = card.name; + } + if (card.name === "us_card") { + if (amount === 1000) { + bestCaseOver10 = profit; + bestCaseOver10Pct = markupPct; + } + if (markupPct > maxUsMarkupPct) maxUsMarkupPct = markupPct; + if (profit > maxOverAny) maxOverAny = profit; + } + } + } + return { + rows, + worstLossCents: worstLoss, + worstCardForLoss: worstCard, + bestCaseOver10, + bestCaseOver10Pct, + maxMarkupPctOnUs: maxUsMarkupPct, + maxOverchargeAnywhere: maxOverAny, + }; + }; + + const summaries = FORMULAS.map((f) => ({ f, e: evaluate(f) })); + + console.log("# Markup formula simulation\n"); + console.log( + "Goal: profit >= 0 on every card type, every credit in [$10, $500].\n" + ); + console.log("## Summary\n"); + console.log( + "| Formula | Safe? | Worst loss | Worst card | US $10 markup | Max US markup | Max US overcharge |" + ); + console.log("|---|---|---|---|---|---|---|"); + for (const { f, e } of summaries) { + console.log( + `| \`${f.label}\` | ${e.worstLossCents >= 0 ? "yes" : "NO"} | ${dollars(e.worstLossCents)} | ${e.worstCardForLoss || "-"} | ${pct(e.bestCaseOver10Pct)} | ${pct(e.maxMarkupPctOnUs)} | ${dollars(e.maxOverchargeAnywhere)} |` + ); + } + + // Filter to safe formulas, rank by US $10 markup pct then max US markup. + const safe = summaries + .filter((s) => s.e.worstLossCents >= 0) + .sort((a, b) => { + const pctDiff = a.e.bestCaseOver10Pct - b.e.bestCaseOver10Pct; + return pctDiff !== 0 + ? pctDiff + : a.e.maxMarkupPctOnUs - b.e.maxMarkupPctOnUs; + }); + + console.log("\n## Top safe candidates\n"); + for (const { f, e } of safe.slice(0, 3)) { + console.log(`### \`${f.label}\`\n`); + console.log( + `- worst-case profit: ${dollars(e.worstLossCents)} on ${e.worstCardForLoss || "n/a"}` + ); + console.log( + `- US $10 markup: ${dollars(e.bestCaseOver10)} (${pct(e.bestCaseOver10Pct)})` + ); + console.log(`- max US markup: ${pct(e.maxMarkupPctOnUs)}\n`); + console.log(`| credit | ${CARDS.map((c) => c.name).join(" | ")} |`); + console.log(`|---${CARDS.map(() => "|---").join("")}|`); + for (const amount of TEST_AMOUNTS_CENTS) { + const cells = [dollars(amount)]; + for (const card of CARDS) { + const row = e.rows.find( + (r) => r.amount === amount && r.card === card.name + )!; + cells.push(`${dollars(row.total)} → +${dollars(row.profit)}`); + } + console.log(`| ${cells.join(" | ")} |`); + } + console.log(""); + } + + console.log("## Card-type rates\n"); + console.log("| card | rate |"); + console.log("|---|---|"); + for (const c of CARDS) + console.log( + `| ${c.name} | ${(c.pct * 100).toFixed(1)}% + $${(c.fixed / 100).toFixed(2)} |` + ); +} diff --git a/editor/scripts/billing/setup-stripe-test.ts b/editor/scripts/billing/setup-stripe-test.ts deleted file mode 100644 index 7afff3d7f..000000000 --- a/editor/scripts/billing/setup-stripe-test.ts +++ /dev/null @@ -1,325 +0,0 @@ -#!/usr/bin/env -S pnpm tsx -// Idempotent Stripe test-mode setup: products, prices, and Customer -// Portal config. Match-or-create by `metadata.grida_billing_id`. Writes the -// resulting Stripe ids into `grida_billing.product_catalogue`. -// -// Run from the repo root: -// pnpm tsx editor/scripts/billing/setup-stripe-test.ts -// -// Refuses to run unless STRIPE_SECRET_KEY starts with `sk_test_` and -// BILLING_TEST_MODE=true. Reads env from (in precedence order): -// process.env > .env.test.local > .env.test > .env.local - -import * as fs from "node:fs"; -import * as path from "node:path"; -// Type-only — erased at compile time, so no runtime touch of lib/billing. -import type { Stripe } from "../../lib/billing"; - -// Env must be loaded before any import touches `lib/billing`, which throws on -// missing STRIPE_SECRET_KEY / SUPABASE_* at module load. -function loadEnvFile(filePath: string): void { - if (!fs.existsSync(filePath)) return; - for (const line of fs.readFileSync(filePath, "utf8").split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - let value = trimmed.slice(eq + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - if (!(key in process.env)) process.env[key] = value; - } -} -const editorDir = path.resolve(__dirname, "..", ".."); -loadEnvFile(path.join(editorDir, ".env.test.local")); -loadEnvFile(path.join(editorDir, ".env.test")); -loadEnvFile(path.join(editorDir, ".env.local")); - -async function main(): Promise { - const sk = process.env.STRIPE_SECRET_KEY ?? ""; - if (!sk.startsWith("sk_test_")) { - throw new Error("Refusing: STRIPE_SECRET_KEY must start with 'sk_test_'."); - } - if (process.env.BILLING_TEST_MODE !== "true") { - throw new Error("Refusing: BILLING_TEST_MODE must be 'true'."); - } - - const { stripe } = await import("../../lib/billing"); - const { service_role } = await import("../../lib/supabase/server"); - - // ----------------------------------------------------------------------- - // Plan products + prices (monthly + annual) - // ----------------------------------------------------------------------- - // - // One Stripe product per plan ("Grida Pro", "Grida Team"); two prices per - // product (monthly + annual). Annual prices encode the 20% discount in - // `unit_amount` directly (no separate coupon line). The catalogue gets - // one row per (plan, interval) pair, keyed `plan.` for monthly - // and `plan..annual` for annual. - // - // Numeric prices come from `lib/billing/plans.ts` (single source of - // truth) — never hardcode them here. - - const { PAID_PLAN_LIST, price_catalogue_id } = - await import("../../lib/billing/plans"); - type Interval = "month" | "year"; - - type PlanProduct = { - name: string; - description: string; - product_grida_id: `plan.${"pro" | "team"}`; - }; - - type PriceSpec = { - catalogue_id: - | "plan.pro" - | "plan.team" - | "plan.pro.annual" - | "plan.team.annual"; - interval: Interval; - unit_amount_cents: number; - nickname: string; - }; - - const PRODUCTS = Object.fromEntries( - PAID_PLAN_LIST.map((p): [typeof p.id, PlanProduct] => [ - p.id, - { - name: `Grida ${p.name}`, - description: `Grida ${p.name}: $${p.monthly_cents / 100}/mo or $${ - p.annual_cents / 100 - }/yr (20% off).`, - product_grida_id: `plan.${p.id}`, - }, - ]) - ) as Record<"pro" | "team", PlanProduct>; - - const PRICES: { product: PlanProduct; price: PriceSpec }[] = - PAID_PLAN_LIST.flatMap((p) => [ - { - product: PRODUCTS[p.id], - price: { - catalogue_id: price_catalogue_id(p.id, "month"), - interval: "month" as const, - unit_amount_cents: p.monthly_cents, - nickname: `${p.name} monthly`, - }, - }, - { - product: PRODUCTS[p.id], - price: { - catalogue_id: price_catalogue_id(p.id, "year"), - interval: "year" as const, - unit_amount_cents: p.annual_cents, - nickname: `${p.name} annual`, - }, - }, - ]); - - // We list+filter instead of products.search because search is eventually - // consistent and can miss a product we created seconds ago, breaking - // idempotency on the second run. - async function ensureProduct(p: PlanProduct): Promise { - const list = await stripe.products.list({ active: true, limit: 100 }); - const existing = list.data.find( - (x) => x.metadata?.grida_billing_id === p.product_grida_id - ); - if (existing) { - console.log( - `[stripe-setup] reusing product ${existing.id} (${p.product_grida_id})` - ); - return existing.id; - } - const created = await stripe.products.create({ - name: p.name, - description: p.description, - metadata: { grida_billing_id: p.product_grida_id }, - }); - console.log( - `[stripe-setup] created product ${created.id} (${p.product_grida_id})` - ); - return created.id; - } - - async function ensurePrice( - product_id: string, - spec: PriceSpec - ): Promise { - const list = await stripe.prices.list({ - product: product_id, - active: true, - limit: 100, - }); - const existing = list.data.find( - (p) => - p.unit_amount === spec.unit_amount_cents && - p.currency === "usd" && - p.recurring?.interval === spec.interval && - p.recurring?.usage_type === "licensed" - ); - if (existing) { - console.log( - `[stripe-setup] reusing price ${existing.id} (${spec.catalogue_id} $${spec.unit_amount_cents / 100}/${spec.interval})` - ); - return existing.id; - } - const created = await stripe.prices.create({ - product: product_id, - currency: "usd", - unit_amount: spec.unit_amount_cents, - recurring: { interval: spec.interval, usage_type: "licensed" }, - nickname: spec.nickname, - metadata: { grida_billing_id: spec.catalogue_id }, - }); - console.log( - `[stripe-setup] created price ${created.id} (${spec.catalogue_id} $${spec.unit_amount_cents / 100}/${spec.interval})` - ); - return created.id; - } - - async function writeCatalogue( - catalogue_id: PriceSpec["catalogue_id"], - product_id: string, - price_id: string - ): Promise { - const { error } = await service_role.workspace.rpc( - "fn_billing_setup_product", - { - p_grida_billing_id: catalogue_id, - p_stripe_product_id: product_id, - p_stripe_price_id: price_id, - } - ); - if (error) { - throw new Error(`writeCatalogue ${catalogue_id}: ${error.message}`); - } - } - - // ----------------------------------------------------------------------- - // Customer Portal config - // ----------------------------------------------------------------------- - - // `proration_behavior=always_invoice` immediately invoices the prorated - // difference on a price change (including monthly ↔ annual on the same - // plan) rather than deferring to the next invoice. Downgrades still - // prorate (Stripe credits the unused portion to the Customer Balance). - type ProductWired = { - product_id: string; - monthly_price_id: string; - annual_price_id: string; - }; - - async function setupPortal(wired: { - pro: ProductWired; - team: ProductWired; - }): Promise { - type ConfigParams = Stripe.BillingPortal.ConfigurationCreateParams; - const config: ConfigParams = { - business_profile: { headline: "Grida billing" }, - features: { - // We never expose the generic portal dashboard. Every portal session - // we open is a deep-link `flow_data` session scoped to one intent. - // The features below have to be `enabled` for their corresponding - // flow_data types to work, but with no generic portal entry point - // the user never reaches the dashboard anyway. - // - // Disabled: customer_update (no flow_data type for email/profile - // edits; we don't want a section the user can't control from our UI). - subscription_cancel: { - enabled: true, - mode: "at_period_end", - proration_behavior: "none", - }, - customer_update: { enabled: false }, - payment_method_update: { enabled: true }, - invoice_history: { enabled: true }, - subscription_update: { - enabled: true, - default_allowed_updates: ["price"], - proration_behavior: "always_invoice", - products: [ - { - product: wired.pro.product_id, - prices: [wired.pro.monthly_price_id, wired.pro.annual_price_id], - }, - { - product: wired.team.product_id, - prices: [wired.team.monthly_price_id, wired.team.annual_price_id], - }, - ], - }, - }, - metadata: { grida_billing_id: "portal.v1" }, - }; - - const list = await stripe.billingPortal.configurations.list({ limit: 100 }); - const existing = list.data.find( - (c) => c.metadata?.grida_billing_id === "portal.v1" - ); - - if (existing) { - const updated = await stripe.billingPortal.configurations.update( - existing.id, - config as Stripe.BillingPortal.ConfigurationUpdateParams - ); - console.log(`[stripe-setup] updated portal config ${updated.id}`); - return updated.id; - } - - const created = await stripe.billingPortal.configurations.create(config); - console.log(`[stripe-setup] created portal config ${created.id}`); - return created.id; - } - - console.log("[stripe-setup] starting"); - - // Provision both products in parallel, then their 4 prices in parallel. - const [pro_product_id, team_product_id] = await Promise.all([ - ensureProduct(PRODUCTS.pro), - ensureProduct(PRODUCTS.team), - ]); - - const productIdFor = (p: PlanProduct): string => - p === PRODUCTS.pro ? pro_product_id : team_product_id; - - const priceIds = await Promise.all( - PRICES.map(async ({ product, price }) => { - const id = await ensurePrice(productIdFor(product), price); - await writeCatalogue(price.catalogue_id, productIdFor(product), id); - return { catalogue_id: price.catalogue_id, price_id: id }; - }) - ); - - const idByCatalogue = (id: PriceSpec["catalogue_id"]): string => { - const found = priceIds.find((p) => p.catalogue_id === id); - if (!found) throw new Error(`missing price for ${id}`); - return found.price_id; - }; - - const wired = { - pro: { - product_id: pro_product_id, - monthly_price_id: idByCatalogue("plan.pro"), - annual_price_id: idByCatalogue("plan.pro.annual"), - }, - team: { - product_id: team_product_id, - monthly_price_id: idByCatalogue("plan.team"), - annual_price_id: idByCatalogue("plan.team.annual"), - }, - }; - - const portal_config_id = await setupPortal(wired); - console.log("[stripe-setup] done"); - console.log(JSON.stringify({ ...wired, portal_config_id }, null, 2)); -} - -main().catch((err) => { - console.error("[stripe-setup] failed:", err?.message ?? err); - process.exit(1); -}); diff --git a/editor/scripts/billing/setup.ts b/editor/scripts/billing/setup.ts new file mode 100644 index 000000000..b654f8a8c --- /dev/null +++ b/editor/scripts/billing/setup.ts @@ -0,0 +1,388 @@ +// Idempotent substrate setup for Stripe (test mode) and Metronome (sandbox). +// Both match-or-create by stable id; safe to re-run after `supabase db reset`. + +import "./_env"; +import { requireEnv, requireStripeTestKey } from "./_env"; + +// --------------------------------------------------------------------------- +// Stripe — products + prices + Customer Portal config +// +// One Stripe product per plan; two prices per product (monthly + annual). +// Annual prices encode the 20% discount in `unit_amount` (no separate +// coupon line). Catalogue gets one row per (plan, interval) pair, keyed +// `plan.` for monthly and `plan..annual` for annual. +// --------------------------------------------------------------------------- + +export async function setupStripe(): Promise { + requireStripeTestKey(); + + const { stripe } = await import("../../lib/billing"); + const { service_role } = await import("../../lib/supabase/server"); + const { PAID_PLAN_LIST, price_catalogue_id } = + await import("../../lib/billing/plans"); + type Interval = "month" | "year"; + type CatalogueId = + | "plan.pro" + | "plan.team" + | "plan.pro.annual" + | "plan.team.annual"; + + const PRODUCTS = Object.fromEntries( + PAID_PLAN_LIST.map((p) => [ + p.id, + { + name: `Grida ${p.name}`, + description: `Grida ${p.name}: $${p.monthly_cents / 100}/mo or $${p.annual_cents / 100}/yr (20% off).`, + product_grida_id: `plan.${p.id}` as `plan.${"pro" | "team"}`, + }, + ]) + ) as Record< + "pro" | "team", + { + name: string; + description: string; + product_grida_id: `plan.${"pro" | "team"}`; + } + >; + + const PRICES = PAID_PLAN_LIST.flatMap((p) => [ + { + product: PRODUCTS[p.id], + catalogue_id: price_catalogue_id(p.id, "month") as CatalogueId, + interval: "month" as Interval, + unit_amount_cents: p.monthly_cents, + nickname: `${p.name} monthly`, + }, + { + product: PRODUCTS[p.id], + catalogue_id: price_catalogue_id(p.id, "year") as CatalogueId, + interval: "year" as Interval, + unit_amount_cents: p.annual_cents, + nickname: `${p.name} annual`, + }, + ]); + + // We list+filter rather than products.search because search is eventually + // consistent and can miss a product we created seconds ago. + const ensureProduct = async ( + p: (typeof PRODUCTS)["pro"] + ): Promise => { + const list = await stripe.products.list({ active: true, limit: 100 }); + const existing = list.data.find( + (x) => x.metadata?.grida_billing_id === p.product_grida_id + ); + if (existing) { + console.log( + `[stripe] reusing product ${existing.id} (${p.product_grida_id})` + ); + return existing.id; + } + const created = await stripe.products.create({ + name: p.name, + description: p.description, + metadata: { grida_billing_id: p.product_grida_id }, + }); + console.log( + `[stripe] created product ${created.id} (${p.product_grida_id})` + ); + return created.id; + }; + + const ensurePrice = async ( + product_id: string, + spec: (typeof PRICES)[number] + ): Promise => { + const list = await stripe.prices.list({ + product: product_id, + active: true, + limit: 100, + }); + const existing = list.data.find( + (p) => + p.unit_amount === spec.unit_amount_cents && + p.currency === "usd" && + p.recurring?.interval === spec.interval && + p.recurring?.usage_type === "licensed" + ); + if (existing) { + console.log( + `[stripe] reusing price ${existing.id} (${spec.catalogue_id} $${spec.unit_amount_cents / 100}/${spec.interval})` + ); + return existing.id; + } + const created = await stripe.prices.create({ + product: product_id, + currency: "usd", + unit_amount: spec.unit_amount_cents, + recurring: { interval: spec.interval, usage_type: "licensed" }, + nickname: spec.nickname, + metadata: { grida_billing_id: spec.catalogue_id }, + }); + console.log( + `[stripe] created price ${created.id} (${spec.catalogue_id} $${spec.unit_amount_cents / 100}/${spec.interval})` + ); + return created.id; + }; + + const writeCatalogue = async ( + catalogue_id: CatalogueId, + product_id: string, + price_id: string + ): Promise => { + const { error } = await service_role.workspace.rpc( + "fn_billing_setup_product", + { + p_grida_billing_id: catalogue_id, + p_stripe_product_id: product_id, + p_stripe_price_id: price_id, + } + ); + if (error) + throw new Error(`writeCatalogue ${catalogue_id}: ${error.message}`); + }; + + // `proration_behavior=always_invoice` immediately invoices the prorated + // difference on a price change rather than deferring to next invoice. + const setupPortal = async (wired: { + pro: { + product_id: string; + monthly_price_id: string; + annual_price_id: string; + }; + team: { + product_id: string; + monthly_price_id: string; + annual_price_id: string; + }; + }): Promise => { + const config = { + business_profile: { headline: "Grida billing" }, + features: { + // Every portal session we open is a deep-link `flow_data` session + // scoped to one intent — the user never reaches the dashboard. + // The features below must be `enabled` for their flow_data types + // to work; with no generic entry point, it doesn't matter. + subscription_cancel: { + enabled: true, + mode: "at_period_end" as const, + proration_behavior: "none" as const, + }, + customer_update: { enabled: false }, + payment_method_update: { enabled: true }, + invoice_history: { enabled: true }, + subscription_update: { + enabled: true, + default_allowed_updates: ["price" as const], + proration_behavior: "always_invoice" as const, + products: [ + { + product: wired.pro.product_id, + prices: [wired.pro.monthly_price_id, wired.pro.annual_price_id], + }, + { + product: wired.team.product_id, + prices: [wired.team.monthly_price_id, wired.team.annual_price_id], + }, + ], + }, + }, + metadata: { grida_billing_id: "portal.v1" }, + }; + + const list = await stripe.billingPortal.configurations.list({ limit: 100 }); + const existing = list.data.find( + (c) => c.metadata?.grida_billing_id === "portal.v1" + ); + if (existing) { + const updated = await stripe.billingPortal.configurations.update( + existing.id, + config as Parameters< + typeof stripe.billingPortal.configurations.update + >[1] + ); + console.log(`[stripe] updated portal config ${updated.id}`); + return updated.id; + } + const created = await stripe.billingPortal.configurations.create( + config as Parameters[0] + ); + console.log(`[stripe] created portal config ${created.id}`); + return created.id; + }; + + console.log("[stripe] starting"); + const [pro_product_id, team_product_id] = await Promise.all([ + ensureProduct(PRODUCTS.pro), + ensureProduct(PRODUCTS.team), + ]); + const productIdFor = (p: (typeof PRODUCTS)["pro"]) => + p === PRODUCTS.pro ? pro_product_id : team_product_id; + + const priceIds = await Promise.all( + PRICES.map(async (spec) => { + const id = await ensurePrice(productIdFor(spec.product), spec); + await writeCatalogue(spec.catalogue_id, productIdFor(spec.product), id); + return { catalogue_id: spec.catalogue_id, price_id: id }; + }) + ); + + const idByCatalogue = (id: CatalogueId) => { + const found = priceIds.find((p) => p.catalogue_id === id); + if (!found) throw new Error(`missing price for ${id}`); + return found.price_id; + }; + + const wired = { + pro: { + product_id: pro_product_id, + monthly_price_id: idByCatalogue("plan.pro"), + annual_price_id: idByCatalogue("plan.pro.annual"), + }, + team: { + product_id: team_product_id, + monthly_price_id: idByCatalogue("plan.team"), + annual_price_id: idByCatalogue("plan.team.annual"), + }, + }; + + const portal_config_id = await setupPortal(wired); + console.log("[stripe] done"); + console.log(JSON.stringify({ ...wired, portal_config_id }, null, 2)); +} + +// --------------------------------------------------------------------------- +// Metronome — billable metric, products, rate card, rate +// +// Substrate only. Customers/contracts/commits are created per-org at runtime. +// --------------------------------------------------------------------------- + +const METRONOME_NAMES = { + billableMetric: "Grida AI Usage", + usageProduct: "Grida AI Usage", + creditProduct: "Grida AI Credits", // FIXED product used as commit.product_id + rateCard: "Grida AI Sandbox", + eventType: "ai.usage", + costProperty: "cost_mills", +}; + +export async function setupMetronome(): Promise { + requireEnv("METRONOME_API_TOKEN"); + const { metronome } = await import("../../lib/billing/metronome"); + const N = METRONOME_NAMES; + + // 1. billable metric + let metricId: string | undefined; + for await (const m of metronome.v1.billableMetrics.list()) { + if (m.name === N.billableMetric) { + metricId = m.id; + break; + } + } + if (metricId) { + console.log(`billable_metric: reusing ${metricId} (${N.billableMetric})`); + } else { + const r = await metronome.v1.billableMetrics.create({ + name: N.billableMetric, + aggregation_type: "SUM", + aggregation_key: N.costProperty, + event_type_filter: { in_values: [N.eventType] }, + property_filters: [{ name: N.costProperty, exists: true }], + }); + metricId = r.data.id; + console.log(`billable_metric: created ${metricId} (${N.billableMetric})`); + } + + // 2. usage + credit products + let usageProductId: string | undefined; + let creditProductId: string | undefined; + for await (const p of metronome.v1.contracts.products.list({ + archive_filter: "NOT_ARCHIVED", + })) { + if (p.current?.name === N.usageProduct && p.type === "USAGE") { + usageProductId = p.id; + } else if (p.current?.name === N.creditProduct && p.type === "FIXED") { + creditProductId = p.id; + } + } + if (usageProductId) { + console.log( + `usage_product: reusing ${usageProductId} (${N.usageProduct})` + ); + } else { + const r = await metronome.v1.contracts.products.create({ + name: N.usageProduct, + type: "USAGE", + billable_metric_id: metricId, + }); + usageProductId = r.data.id; + console.log( + `usage_product: created ${usageProductId} (${N.usageProduct})` + ); + } + if (creditProductId) { + console.log( + `credit_product: reusing ${creditProductId} (${N.creditProduct})` + ); + } else { + const r = await metronome.v1.contracts.products.create({ + name: N.creditProduct, + type: "FIXED", + }); + creditProductId = r.data.id; + console.log( + `credit_product: created ${creditProductId} (${N.creditProduct})` + ); + } + + // 3. rate card + let rateCardId: string | undefined; + for await (const r of metronome.v1.contracts.rateCards.list({ body: {} })) { + if (r.name === N.rateCard) { + rateCardId = r.id; + break; + } + } + if (rateCardId) { + console.log(`rate_card: reusing ${rateCardId} (${N.rateCard})`); + } else { + const r = await metronome.v1.contracts.rateCards.create({ + name: N.rateCard, + }); + rateCardId = r.data.id; + console.log(`rate_card: created ${rateCardId} (${N.rateCard})`); + } + + // 4. rate (FLAT @ 0.1 cents/unit = $0.001/mill — at cost) + let rateExists = false; + for await (const r of metronome.v1.contracts.rateCards.rates.list({ + rate_card_id: rateCardId, + at: new Date().toISOString(), + selectors: [{ product_id: usageProductId }], + })) { + if (r.product_id === usageProductId) { + rateExists = true; + break; + } + } + if (rateExists) { + console.log( + `rate: already present for usage product ${usageProductId}` + ); + } else { + await metronome.v1.contracts.rateCards.rates.add({ + rate_card_id: rateCardId, + product_id: usageProductId, + entitled: true, + rate_type: "FLAT", + starting_at: new Date(Date.UTC(2026, 0, 1)).toISOString(), + price: 0.1, + }); + console.log(`rate: added FLAT @ 0.1 cents/unit ($0.001/mill)`); + } + + console.log("\nok."); + console.log(` billable_metric_id = ${metricId}`); + console.log(` usage_product_id = ${usageProductId}`); + console.log(` credit_product_id = ${creditProductId}`); + console.log(` rate_card_id = ${rateCardId}`); +} diff --git a/editor/scripts/billing/smoke.ts b/editor/scripts/billing/smoke.ts new file mode 100644 index 000000000..ad199e974 --- /dev/null +++ b/editor/scripts/billing/smoke.ts @@ -0,0 +1,541 @@ +// Sandbox smoke tests. Each is independently runnable and demonstrates one +// flow end-to-end against your sandbox accounts. Run via `cli.ts smoke `. + +import "./_env"; +import * as crypto from "node:crypto"; +import { requireEnv, requireStripeTestKey } from "./_env"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const cents = (n?: number | null) => + n === undefined || n === null ? "—" : `$${(n / 100).toFixed(2)}`; + +// --------------------------------------------------------------------------- +// ping — list customers / metrics / products / rate cards. Read-only. +// Verifies token + workspace. +// --------------------------------------------------------------------------- + +export async function ping(): Promise { + requireEnv("METRONOME_API_TOKEN"); + const { metronome } = await import("../../lib/billing/metronome"); + + const tryListing = async ( + label: string, + iter: () => AsyncIterable, + fmt: (item: T) => string + ): Promise => { + try { + const items: T[] = []; + for await (const item of iter()) { + items.push(item); + if (items.length >= 5) break; + } + console.log(`\n${label} (first ${items.length}):`); + for (const item of items) console.log(` ${fmt(item)}`); + } catch (err) { + console.log(`\n${label}: ${(err as Error).message.split("\n")[0]}`); + } + }; + + await tryListing( + "customers", + () => metronome.v1.customers.list(), + (c) => `${c.id} ${c.name ?? ""}` + ); + await tryListing( + "billable_metrics", + () => metronome.v1.billableMetrics.list(), + (m) => `${m.id} ${m.name ?? ""}` + ); + await tryListing( + "contracts.products", + () => + metronome.v1.contracts.products.list({ archive_filter: "NOT_ARCHIVED" }), + (p) => `${p.id} [${p.type ?? "?"}] ${p.current?.name ?? ""}` + ); + await tryListing( + "contracts.rate_cards", + () => metronome.v1.contracts.rateCards.list({ body: {} }), + (r) => `${r.id} ${r.name ?? ""}` + ); + + console.log("\nok."); +} + +// --------------------------------------------------------------------------- +// topup — full prepaid-credit lifecycle. Demonstrates: customer match-or- +// create, contract with PREPAID complimentary commit, ingest event, balance +// drain. No Stripe charges (commit has no invoice_schedule). +// --------------------------------------------------------------------------- + +const TOPUP_CUSTOMER = "grida-test-org"; +const TOPUP_AMOUNT_CENTS = 2500; // $25 + +export async function topup(): Promise { + requireEnv("METRONOME_API_TOKEN"); + const { metronome, getSubstrate, hourFloor, FAR_FUTURE } = + await import("../../lib/billing/metronome"); + + console.log("\n--- step 1: look up substrate ---"); + const sub = await getSubstrate(); + console.log(" ok"); + + console.log("\n--- step 2: match-or-create customer ---"); + let customerId: string | undefined; + for await (const c of metronome.v1.customers.list()) { + if (c.name === TOPUP_CUSTOMER) { + customerId = c.id; + break; + } + } + if (customerId) { + console.log(` reusing ${customerId}`); + } else { + const r = await metronome.v1.customers.create({ + name: TOPUP_CUSTOMER, + ingest_aliases: [TOPUP_CUSTOMER], + }); + customerId = r.data.id; + console.log(` created ${customerId}`); + } + + console.log( + `\n--- step 3: create contract with ${cents(TOPUP_AMOUNT_CENTS)} PREPAID commit ---` + ); + const startISO = hourFloor(); + const contract = await metronome.v1.contracts.create({ + customer_id: customerId, + rate_card_id: sub.rateCardId, + starting_at: startISO, + name: `Test contract ${new Date().toISOString()}`, + commits: [ + { + product_id: sub.creditProductId, + applicable_product_ids: [sub.usageProductId], + type: "PREPAID", + name: "Sandbox top-up $25", + access_schedule: { + schedule_items: [ + { + amount: TOPUP_AMOUNT_CENTS, + starting_at: startISO, + ending_before: FAR_FUTURE, + }, + ], + }, + }, + ], + }); + console.log(` contract_id ${contract.data.id}`); + + console.log( + `\n--- step 4: read balance (expect ${cents(TOPUP_AMOUNT_CENTS)}) ---` + ); + await reportBalance(metronome, customerId); + + for (const [step, mills] of [ + [5, 5000], + [8, 3000], + ] as const) { + console.log(`\n--- step ${step}: ingest ${mills} mills ---`); + await metronome.v1.usage.ingest({ + usage: [ + { + transaction_id: crypto.randomUUID(), + customer_id: TOPUP_CUSTOMER, + event_type: sub.eventType, + timestamp: new Date().toISOString(), + properties: { [sub.costProperty]: mills }, + }, + ], + }); + console.log(`--- step ${step + 1}: wait 10s + read ---`); + await sleep(10_000); + await reportBalance(metronome, customerId); + } + console.log("\nok."); +} + +// --------------------------------------------------------------------------- +// auto-reload — proves Metronome's prepaid_balance_threshold_configuration: +// on drain below threshold, Metronome charges Stripe and adds a fresh commit. +// --------------------------------------------------------------------------- + +export async function autoReload(): Promise { + const sk = requireStripeTestKey(); + requireEnv("METRONOME_API_TOKEN"); + + const Stripe = (await import("stripe")).default; + const stripe = new Stripe(sk, { apiVersion: "2026-04-22.dahlia" as never }); + const { metronome, getSubstrate, COMMIT_PRIORITY, hourFloor, FAR_FUTURE } = + await import("../../lib/billing/metronome"); + const sub = await getSubstrate(); + + const RUN_ID = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const alias = `auto-reload-${RUN_ID}`; + const THRESHOLD = 500; // $5 + const RECHARGE_TO = 2000; // $20 + const SEED = 700; // $7 + const SPEND_MILLS = 5000; // $5 worth + + console.log(`\n=== 1. Stripe customer + pm_card_visa ===`); + const sc = await stripe.customers.create({ + name: alias, + email: `${alias}@example.test`, + metadata: { run_id: RUN_ID, scope: "auto-reload-smoke" }, + }); + const pm = await stripe.paymentMethods.attach("pm_card_visa", { + customer: sc.id, + }); + await stripe.customers.update(sc.id, { + invoice_settings: { default_payment_method: pm.id }, + }); + console.log(` stripe_customer_id ${sc.id}`); + + console.log(`\n=== 2. Metronome customer + contract (Stripe-linked) ===`); + const customer = await metronome.v1.customers.create({ + name: alias, + ingest_aliases: [alias], + customer_billing_provider_configurations: [ + { + billing_provider: "stripe", + delivery_method: "direct_to_billing_provider", + configuration: { + stripe_customer_id: sc.id, + stripe_collection_method: "charge_automatically", + }, + }, + ], + }); + const customerId = customer.data.id; + const contract = await metronome.v1.contracts.create({ + customer_id: customerId, + rate_card_id: sub.rateCardId, + starting_at: hourFloor(), + name: `Auto-reload smoke`, + billing_provider_configuration: { + billing_provider: "stripe", + delivery_method: "direct_to_billing_provider", + }, + }); + console.log(` metronome_customer_id ${customerId}`); + console.log(` contract_id ${contract.data.id}`); + + console.log( + `\n=== 3. enable auto-reload (${cents(THRESHOLD)} → ${cents(RECHARGE_TO)}) ===` + ); + await metronome.v2.contracts.edit({ + customer_id: customerId, + contract_id: contract.data.id, + add_prepaid_balance_threshold_configuration: { + threshold_amount: THRESHOLD, + recharge_to_amount: RECHARGE_TO, + is_enabled: true, + payment_gate_config: { + payment_gate_type: "STRIPE", + stripe_config: { payment_type: "PAYMENT_INTENT" }, + tax_type: "NONE", + }, + commit: { + product_id: sub.creditProductId, + applicable_product_ids: [sub.usageProductId], + priority: COMMIT_PRIORITY.TOPUP, + name: "Auto-reload top-up", + }, + } as never, + }); + + console.log(`\n=== 4. seed ${cents(SEED)} complimentary commit ===`); + await metronome.v2.contracts.edit({ + customer_id: customerId, + contract_id: contract.data.id, + add_commits: [ + { + product_id: sub.creditProductId, + applicable_product_ids: [sub.usageProductId], + type: "PREPAID", + name: "Seed complimentary", + priority: COMMIT_PRIORITY.PROMO, + access_schedule: { + schedule_items: [ + { + amount: SEED, + starting_at: hourFloor(), + ending_before: FAR_FUTURE, + }, + ], + }, + }, + ], + }); + await sleep(5_000); + await reportBalance(metronome, customerId); + + console.log( + `\n=== 5. drain (ingest ${SPEND_MILLS} mills = ${cents(SPEND_MILLS / 10)}) ===` + ); + await metronome.v1.usage.ingest({ + usage: [ + { + transaction_id: crypto.randomUUID(), + customer_id: alias, + event_type: sub.eventType, + timestamp: new Date().toISOString(), + properties: { [sub.costProperty]: SPEND_MILLS }, + }, + ], + }); + await sleep(15_000); + await reportBalance(metronome, customerId); + + console.log(`\n=== 6. poll up to 3 min for auto-recharge ===`); + const start = Date.now(); + const deadline = start + 3 * 60_000; + let observed = false; + let lastTotal = -1; + while (Date.now() < deadline) { + const elapsed = Math.floor((Date.now() - start) / 1000); + const commits = await listCommits(metronome, customerId); + const total = commits.reduce((a, c) => a + (c.balance ?? 0), 0); + const topupVisible = commits.some( + (c) => c.name === "Auto-reload top-up" && (c.balance ?? 0) > 0 + ); + console.log( + ` t+${String(elapsed).padStart(3, " ")}s balance=${cents(total)} commits=${commits.length} topup_visible=${topupVisible}` + ); + if (topupVisible && total > lastTotal && total > THRESHOLD) { + observed = true; + break; + } + lastTotal = total; + await sleep(10_000); + } + + console.log(`\n=== 7. final ===`); + await reportBalance(metronome, customerId); + console.log( + observed ? " ✓ auto-recharge observed" : " ✗ NOT observed within 3 min" + ); + + const charges = await stripe.charges.list({ customer: sc.id, limit: 5 }); + console.log(` stripe charges: ${charges.data.length}`); + for (const ch of charges.data) { + console.log( + ` ${ch.id} ${ch.status} ${cents(ch.amount)} paid=${ch.paid}` + ); + } +} + +// --------------------------------------------------------------------------- +// webhook — 3-layer probe: localhost → tunnel → DB. Each layer adds one +// variable so a failure pinpoints exactly which link is broken. +// --------------------------------------------------------------------------- + +const LOCAL_URL = "http://localhost:3000/webhooks/metronome"; +const TUNNEL_URL = "https://dev-webhooks.grida.co/webhooks/metronome"; + +export async function webhook(): Promise { + const secret = requireEnv("METRONOME_WEBHOOK_SECRET"); + const { createClient } = await import("@supabase/supabase-js"); + + const colour = (c: string, s: string) => `\x1b[${c}m${s}\x1b[0m`; + const ok = (s: string) => colour("32", s); + const bad = (s: string) => colour("31", s); + const dim = (s: string) => colour("90", s); + + const sign = (body: string, dateHeader: string) => + crypto + .createHmac("sha256", secret) + .update(`${dateHeader}\n${body}`) + .digest("hex"); + + type ProbeResult = { + layer: number; + url: string; + eventId: string; + ok: boolean; + status?: number; + body?: unknown; + error?: string; + durationMs: number; + }; + + const probe = async (layer: number, url: string): Promise => { + const eventId = `smoke-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`; + const dateHeader = new Date().toUTCString(); + const payload = { + id: eventId, + type: "webhooks.test", + timestamp: new Date().toISOString(), + properties: { source: "smoke webhook" }, + }; + const body = JSON.stringify(payload); + const sig = sign(body, dateHeader); + const start = Date.now(); + try { + const res = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + date: dateHeader, + "metronome-webhook-signature": sig, + }, + body, + }); + const text = await res.text(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } + return { + layer, + url, + eventId, + ok: res.ok, + status: res.status, + body: parsed, + durationMs: Date.now() - start, + }; + } catch (err) { + return { + layer, + url, + eventId, + ok: false, + error: (err as Error).message, + durationMs: Date.now() - start, + }; + } + }; + + const verifyDbRow = async (eventId: string) => { + const url = + process.env.NEXT_PUBLIC_SUPABASE_URL ?? process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SECRET_KEY; + if (!url || !key) { + throw new Error( + "NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SECRET_KEY required" + ); + } + const sb = createClient(url, key); + const { data, error } = await ( + sb.rpc as ( + n: string, + p: object + ) => Promise<{ data: unknown; error: { message: string } | null }> + )("fn_billing_list_metronome_events", { p_org: null, p_limit: 100 }); + if (error) + throw new Error(`fn_billing_list_metronome_events: ${error.message}`); + const rows = (data ?? []) as Array<{ + event_id: string; + event_type: string; + processed_at: string | null; + }>; + return rows.find((r) => r.event_id === eventId) ?? null; + }; + + console.log(dim(`webhook smoke ${new Date().toISOString()}`)); + console.log(dim(`secret length=${secret.length} (${secret.slice(0, 4)}…)`)); + + console.log(`\nLayer 1 ${dim("→")} ${LOCAL_URL}`); + const r1 = await probe(1, LOCAL_URL); + console.log( + r1.ok + ? ` ${ok("✓")} ${r1.status} (${r1.durationMs}ms) → ${JSON.stringify(r1.body)}` + : ` ${bad("✗")} ${r1.status ?? "no response"} (${r1.durationMs}ms)${r1.error ? ` ${r1.error}` : ""}` + ); + + console.log(`\nLayer 2 ${dim("→")} ${TUNNEL_URL}`); + const r2 = await probe(2, TUNNEL_URL); + console.log( + r2.ok + ? ` ${ok("✓")} ${r2.status} (${r2.durationMs}ms) → ${JSON.stringify(r2.body)}` + : ` ${bad("✗")} ${r2.status ?? "no response"} (${r2.durationMs}ms)${r2.error ? ` ${r2.error}` : ""}` + ); + + console.log(`\nLayer 3 ${dim("→")} read back metronome_event rows`); + let dbOk = true; + for (const r of [r1, r2]) { + if (!r.ok) { + console.log(` ${dim("•")} L${r.layer} skipped`); + continue; + } + try { + const row = await verifyDbRow(r.eventId); + if (row) { + console.log( + ` ${ok("✓")} L${r.layer} row found: type=${row.event_type} processed_at=${row.processed_at ?? "(null)"}` + ); + } else { + dbOk = false; + console.log(` ${bad("✗")} L${r.layer} row NOT found (${r.eventId})`); + } + } catch (err) { + dbOk = false; + console.log( + ` ${bad("✗")} L${r.layer} db read failed: ${(err as Error).message}` + ); + } + } + + if (r1.ok && r2.ok && dbOk) { + console.log( + `\n${ok("ALL GREEN")} — pipeline wired. Next: send a test event from the Metronome dashboard.` + ); + return; + } + console.log(`\n${bad("FAILED")}`); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +type CommitRow = { + id: string; + name?: string; + balance?: number; + priority?: number; +}; + +async function listCommits( + metronome: Awaited, + customerId: string +): Promise { + const out: CommitRow[] = []; + for await (const b of metronome.v1.contracts.listBalances({ + customer_id: customerId, + covering_date: new Date().toISOString(), + include_balance: true, + })) { + const x = b as { + id: string; + name?: string; + balance?: number; + priority?: number; + }; + out.push({ + id: x.id, + name: x.name, + balance: x.balance, + priority: x.priority, + }); + } + return out; +} + +async function reportBalance( + metronome: Awaited, + customerId: string +): Promise { + const commits = await listCommits(metronome, customerId); + const total = commits.reduce((a, c) => a + (c.balance ?? 0), 0); + console.log(` total ${cents(total)} across ${commits.length} commit(s)`); + for (const c of commits) { + console.log( + ` [${c.id.slice(0, 8)}…] prio=${c.priority ?? "?"} ${c.name ?? "(unnamed)"}: ${cents(c.balance)}` + ); + } +} From ec1bf8b23695abcf54b7bd654477caef88953188 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 11 May 2026 04:25:37 +0900 Subject: [PATCH 08/21] test(billing): headless e2e for AI-credit topup post-Checkout flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives `handleAiCreditCheckoutCompleted` directly with a synthetic Stripe Checkout session — no browser, no real Checkout UI, no dev server. The test asserts: - the handler returns `applied` - DB customer_entitled flips to true synchronously (proves the inline reconcile closes Gap B for the happy path) - DB cached_balance_cents reflects the new credit - the transaction's `at` field is wall-clock-fresh (Commit.created_at, not the hour-floored schedule_items[0].starting_at) vitest.config.ts now also falls back to .env.local for env vars, mirroring what the cli.ts scripts already do — a token kept there for the dev server no longer has to be duplicated into .env.test.local. --- .../lib/billing/__tests__/e2e/fixtures/db.ts | 2 +- .../e2e/scenarios/ai-credit-topup.test.ts | 102 ++++++++++++++++++ editor/vitest.config.ts | 12 ++- 3 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 editor/lib/billing/__tests__/e2e/scenarios/ai-credit-topup.test.ts diff --git a/editor/lib/billing/__tests__/e2e/fixtures/db.ts b/editor/lib/billing/__tests__/e2e/fixtures/db.ts index 11e2b1e9b..e89fa9f2b 100644 --- a/editor/lib/billing/__tests__/e2e/fixtures/db.ts +++ b/editor/lib/billing/__tests__/e2e/fixtures/db.ts @@ -44,7 +44,7 @@ export async function getProPriceId(): Promise { const cat = await getCatalogueStripeIds("plan.pro"); if (!cat) { throw new Error( - "plan.pro price not wired. Run: pnpm tsx editor/scripts/billing/setup-stripe-test.ts" + "plan.pro price not wired. Run: pnpm tsx editor/scripts/billing/cli.ts setup:stripe" ); } return cat.stripe_price_id; diff --git a/editor/lib/billing/__tests__/e2e/scenarios/ai-credit-topup.test.ts b/editor/lib/billing/__tests__/e2e/scenarios/ai-credit-topup.test.ts new file mode 100644 index 000000000..5d09780ce --- /dev/null +++ b/editor/lib/billing/__tests__/e2e/scenarios/ai-credit-topup.test.ts @@ -0,0 +1,102 @@ +// E2E coverage for the post-Checkout AI credit top-up handler. +// +// Drives `handleAiCreditCheckoutCompleted` directly with a synthetic Stripe +// session payload — same shape the real Stripe webhook produces. This is the +// path that fired the user-visible bugs: +// +// - the return-page race ("took too long" toast) — covered by +// asserting `customer_entitled` is `true` immediately after the handler +// returns, so the return page settles on first poll instead of waiting +// for the Metronome `commit.create` webhook. +// - the stale-DB-cache amber banner — covered by +// asserting `cached_balance_cents >= cents` (not just live read). +// - the "59m ago" mis-display — covered by +// asserting `getTransactions` exposes a `createdAt`-derived `at` that +// is within a few seconds of wall clock, not hour-floored. + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { assertSuiteSafety } from "../fixtures/safety"; +import { + provisionEphemeralOrg, + teardownOrg, + type EphemeralOrg, +} from "../fixtures/org"; +import { + AI_CHECKOUT_KIND, + getAccount, + getTransactions, + handleAiCreditCheckoutCompleted, + provisionOrg, +} from "@/lib/billing/metronome"; +import { stripe } from "../../.."; + +describe("E2E — AI credit top-up post-Checkout handler", () => { + let org: EphemeralOrg; + + beforeAll(() => assertSuiteSafety()); + afterAll(async () => { + if (org) await teardownOrg(org); + }); + + it("lands credit, flips entitlement, and exposes a fresh createdAt", async () => { + org = await provisionEphemeralOrg(); + + // Mint a Stripe customer + attach to the org so `provisionOrg` wires + // billing-provider config (mirrors the real flow where the user has + // a Stripe customer by the time they hit Checkout). + const customer = await stripe.customers.create({ + metadata: { grida_organization_id: String(org.org_id) }, + }); + const { error: attachErr } = await ( + await import("@/lib/supabase/server") + ).service_role.workspace.rpc("fn_billing_attach_stripe_customer", { + p_org_id: org.org_id, + p_stripe_customer_id: customer.id, + }); + if (attachErr) throw new Error(`attach: ${attachErr.message}`); + await provisionOrg(org.org_id, { stripeCustomerId: customer.id }); + + // Pre-handler baseline. New ephemeral org → all zero, not entitled. + const before = await getAccount(org.org_id); + expect(before?.customer_entitled).toBe(false); + expect(before?.cached_balance_cents).toBe(0); + + const t0 = Date.now(); + const CENTS = 1000; // $10 + const TOTAL = 1080; // includes mock processing fee + + const result = await handleAiCreditCheckoutCompleted({ + id: `cs_test_${t0}`, + payment_intent: `pi_test_${t0}`, + payment_status: "paid", + amount_total: TOTAL, + metadata: { + grida_organization_id: String(org.org_id), + kind: AI_CHECKOUT_KIND.TOPUP, + cents: String(CENTS), + total_cents: String(TOTAL), + }, + }); + expect(result.result).toBe("applied"); + + // The handler MUST reconcile the DB cache inline so the return page + // settles on its first poll. Without this, `customer_entitled` waits + // for the Metronome `commit.create` webhook (unbounded delay). + const after = await getAccount(org.org_id); + expect(after?.customer_entitled).toBe(true); + expect(after?.cached_balance_cents).toBeGreaterThanOrEqual(CENTS); + + // Transactions feed should expose the commit's actual `created_at`, + // not the hour-floored `schedule_items[0].starting_at`. We can't pin + // the exact wall clock (Metronome stamps it server-side), but a few + // seconds of skew is fine and an hour of skew is the bug. + const txns = await getTransactions(org.org_id); + const topup = txns.find((t) => t.kind === "topup"); + expect(topup).toBeDefined(); + expect(topup!.amountCents).toBe(CENTS); + expect(topup!.at).not.toBeNull(); + const ageMs = Date.now() - Date.parse(topup!.at!); + expect(ageMs).toBeGreaterThanOrEqual(0); + expect(ageMs).toBeLessThan(5 * 60_000); // < 5 min (covers any test slowness) + }, 60_000); +}); diff --git a/editor/vitest.config.ts b/editor/vitest.config.ts index 05178740a..6139d68d5 100644 --- a/editor/vitest.config.ts +++ b/editor/vitest.config.ts @@ -3,11 +3,12 @@ import { fileURLToPath, URL } from "node:url"; import * as fs from "node:fs"; import * as path from "node:path"; -// Auto-load `.env.test` and `.env.test.local` so `pnpm vitest run` works -// without `set -a; . ./.env.test.local` ceremony. -// Precedence (highest wins): shell > .env.test.local > .env.test. -// `loadEnvFile()` only sets a key when not already in process.env, so an -// existing shell-exported value is never overridden by a file. +// Auto-load env so `pnpm vitest run` works without `set -a; . ./.env.test.local` +// ceremony. +// Precedence (highest wins): shell > .env.test.local > .env.test > .env.local. +// `.env.local` is a fallback so tokens kept there for the dev server are also +// usable by the e2e suite without duplication. `loadEnvFile()` only sets a +// key when not already in process.env, so the precedence chain holds. function loadEnvFile(filePath: string): void { if (!fs.existsSync(filePath)) return; for (const line of fs.readFileSync(filePath, "utf8").split("\n")) { @@ -29,6 +30,7 @@ function loadEnvFile(filePath: string): void { const dir = path.dirname(fileURLToPath(import.meta.url)); loadEnvFile(path.join(dir, ".env.test.local")); loadEnvFile(path.join(dir, ".env.test")); +loadEnvFile(path.join(dir, ".env.local")); // The billing E2E suite under `lib/billing/__tests__/e2e/` hits real Stripe // (test mode) and the local webhook receiver. Slow, rate-limited, requires From 6c3cb1679fcb3bf947d06763c01d0afbde8e5fba Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 11 May 2026 04:25:51 +0900 Subject: [PATCH 09/21] docs(billing): AI credit design, Metronome integration, known issues Three working-group docs under docs/wg/platform/: - ai-credits.md the master design (constraints, flows, drain order, refunds) - metronome.md the Metronome integration playbook - billing-known-issues.md living register; KI-BILL-001 (silent auto-recharge markup gap, mitigated by subscription gate) + KI-BILL-002 (concurrent subscribe-Checkout race, accepted for v1). Contributor setup (docs/contributing/billing.md) is rewritten for the consolidated CLI (`cli.ts setup:stripe`, `setup:metronome`, etc.) and the locally-configured cloudflared tunnel (no longer ships a wrapper script). editor/.env.example documents METRONOME_API_TOKEN, METRONOME_WEBHOOK_SECRET, and WEBHOOK_TUNNEL_HOSTNAME alongside the existing Stripe vars. --- docs/contributing/billing.md | 140 ++++-- docs/wg/platform/ai-credits.md | 523 +++++++++++++++++++++++ docs/wg/platform/billing-known-issues.md | 160 +++++++ docs/wg/platform/metronome.md | 504 ++++++++++++++++++++++ editor/.env.example | 27 +- 5 files changed, 1312 insertions(+), 42 deletions(-) create mode 100644 docs/wg/platform/ai-credits.md create mode 100644 docs/wg/platform/billing-known-issues.md create mode 100644 docs/wg/platform/metronome.md diff --git a/docs/contributing/billing.md b/docs/contributing/billing.md index 319334e8a..7f9e00ac4 100644 --- a/docs/contributing/billing.md +++ b/docs/contributing/billing.md @@ -1,70 +1,129 @@ # Contributing to Grida | Billing -Setup guide for contributors working on the billing surface. Once Stripe and Supabase are wired locally, the rest of the codebase works the same against any test account. +Setup guide for contributors working on the billing surface. Two clouds to wire: -> **We don't share Stripe credentials.** Every contributor uses their own free Stripe test account. +- **Stripe** — subscription + payment processing. +- **Metronome** — AI credit ledger (top-up, auto-reload, usage gate). Charges flow through Stripe under the hood; Metronome owns balance, drain order, and the entitlement decision the AI seam reads. + +> Every contributor uses their own free Stripe + Metronome **test/sandbox** accounts. **We don't share credentials.** Live keys are refused at boot. --- ## What you need - Local Supabase running (`supabase start`). -- A free Stripe account in **test mode** (no payment info required at signup). -- The Stripe CLI: `brew install stripe/stripe-cli/stripe` or [stripe.com/docs/stripe-cli](https://stripe.com/docs/stripe-cli). -- Node 24 + pnpm (covered by the repo-wide setup). +- A free Stripe **test mode** account. +- The Stripe CLI: `brew install stripe/stripe-cli/stripe`. +- A Metronome **sandbox** account + API token. Sign up at [metronome.com](https://metronome.com). +- `cloudflared` for the Metronome webhook tunnel: `brew install cloudflared`. +- Node 24 + pnpm (covered by repo-wide setup). --- ## Setup -### 1. Stripe test account - -Sign up at [dashboard.stripe.com](https://dashboard.stripe.com), switch to **Test mode**, and copy your **Secret key** (starts with `sk_test_…`) from **Developers → API keys**. - -> Don't use a live key. The test fixtures refuse to start unless `STRIPE_SECRET_KEY` begins with `sk_test_`. - -### 2. Local Supabase +### 1. Local Supabase ```bash supabase start supabase db reset ``` -### 3. Secrets in `.env.test.local` +### 2. Stripe — test key + +Stripe Dashboard → **Test mode** → Developers → API keys. Copy the secret key (`sk_test_…`). -Committed defaults live in `editor/.env.test`. Secrets go in `editor/.env.test.local` (gitignored): +### 3. Metronome — sandbox token + +Metronome Dashboard → Connections → API tokens & webhooks → create a sandbox token. + +### 4. Secrets in `editor/.env.test.local` + +`.env.test` holds committed defaults; `.env.test.local` is gitignored. ```bash supabase status -o env | grep SUPABASE_SECRET_KEY >> editor/.env.test.local -echo 'STRIPE_SECRET_KEY=sk_test_...' >> editor/.env.test.local -# STRIPE_WEBHOOK_SECRET=whsec_... ← add after step 5 +echo 'STRIPE_SECRET_KEY=sk_test_...' >> editor/.env.test.local +echo 'METRONOME_API_TOKEN=...' >> editor/.env.test.local +# STRIPE_WEBHOOK_SECRET / METRONOME_WEBHOOK_SECRET / WEBHOOK_TUNNEL_HOSTNAME — added below ``` -### 4. Provision Stripe products + portal config +### 5. Provision substrates ```bash -pnpm tsx editor/scripts/billing/setup-stripe-test.ts +pnpm tsx editor/scripts/billing/cli.ts setup:stripe +pnpm tsx editor/scripts/billing/cli.ts setup:metronome ``` -Idempotent. Creates products/prices in your sandbox and writes the resulting Stripe IDs into the catalog. Re-run after every `supabase db reset`. +Both idempotent. **Re-run after every `supabase db reset`.** Stripe writes price IDs into the catalog; Metronome creates the rate card / products / billable metric. + +The CLI is the single entry point for every billing script — run it without arguments to see all subcommands. -### 5. Forward webhooks +### 6. Stripe webhooks (local forwarding) -In a dedicated terminal, kept open during development: +In a dedicated terminal kept open during development: ```bash -stripe listen --forward-to localhost:3000/private/webhooks/stripe +stripe listen --forward-to localhost:3000/webhooks/stripe ``` -Copy the printed `whsec_…` into `STRIPE_WEBHOOK_SECRET` in `.env.test.local`. The signing secret is per-`stripe listen` session — restart resets it. +Copy the printed `whsec_…` into `STRIPE_WEBHOOK_SECRET`. Per-`stripe listen` session — restart resets it. -### 6. Run + try the flow +### 7. Metronome webhooks (cloudflared tunnel) + +The Stripe CLI can forward to localhost; Metronome can't — it requires a public HTTPS endpoint. Use a Cloudflare named tunnel configured **locally** to forward `/webhooks/*` only. Nothing about the tunnel is git-tracked — the config lives in your `~/.cloudflared/` directory and the hostname is one of yours. + +One-time setup (~5 min): + +```bash +brew install cloudflared +cloudflared tunnel login # browser → pick a Cloudflare zone you control +cloudflared tunnel create grida-webhooks +cloudflared tunnel route dns grida-webhooks # e.g. metronome-dev.yourdomain.co +``` + +Create `~/.cloudflared/grida-webhooks.yml` (path filter is the security boundary — see [SECURITY.md](../../SECURITY.md) `GRIDA-SEC-001`): + +```yaml +tunnel: grida-webhooks +ingress: + - hostname: metronome-dev.yourdomain.co + path: ^/webhooks/.*$ + service: http://localhost:3000 + - service: http_status:404 +``` + +Run it in a dedicated terminal: + +```bash +cloudflared tunnel --config ~/.cloudflared/grida-webhooks.yml run +``` + +Add the hostname to `.env.test.local`: + +``` +WEBHOOK_TUNNEL_HOSTNAME=metronome-dev.yourdomain.co +``` + +### 8. Metronome webhook destination + +Metronome Dashboard → Webhooks → Add endpoint: + +- URL: `https:///webhooks/metronome` +- Copy the generated signing secret → `METRONOME_WEBHOOK_SECRET` in `.env.test.local`. + +### 9. Run + try the flow ```bash pnpm dev --filter=editor ``` -Sign in as `insider@grida.co` / `password`. Go to org settings → Billing → Upgrade. Use the test card `4242 4242 4242 4242`, any future expiry, any CVC. Watch the `stripe listen` terminal: events flow in, the local DB mirrors, the sidebar plan badge updates within a couple seconds. +Sign in as `insider@grida.co` / `password`. Two flows to try: + +- **Subscription**: Org settings → Billing → Upgrade. Test card `4242 4242 4242 4242`, any future expiry / CVC. +- **AI credit**: Same page, "Grida AI Credit" section → Buy Credit. The first top-up bootstraps the Stripe customer if needed. + +The insiders QA harness at `/insiders/billing` exercises every primitive (top-up, complimentary commit, auto-reload, alerts, ingest) directly. --- @@ -82,25 +141,27 @@ See the suite's own README for the contract. ## Stable surface -A few names that don't change and are useful to know: - -- **DB schema**: `grida_billing.*` (locked down, internal). Public read access through views like `v_billing_subscription` and RPCs prefixed `fn_billing_*`. -- **Projector**: `public.fn_billing_apply_stripe_event` is the only place subscription state mutates from a webhook. All projection logic is PL/pgSQL. -- **Webhook path**: `/private/webhooks/stripe` — what Stripe POSTs to, verified by signature. - -Anything else (file layout under `editor/`, server action names, type names) is a moving target — read the code. +- **DB schema**: `grida_billing.*` — locked, not REST-exposed. Public reads via `v_billing_*` views; writes only via `fn_billing_*` RPCs. +- **Stripe projector**: `public.fn_billing_apply_stripe_event` — only place subscription state mutates. +- **Metronome projector**: `public.fn_billing_apply_metronome_event` — credit / alert / `payment_gate` events. +- **Webhook paths**: `/webhooks/stripe`, `/webhooks/metronome`. Both signature-verified. +- **Service module**: `editor/lib/billing/metronome.ts` — `provisionOrg`, `addStripeChargedCommit`, `setAutoReload`, `getEntitlement`, `ingestUsageEvent`. +- **`grida_billing.account.provisioning_uid`**: per-account UUID composed into Metronome aliases. `supabase db reset` produces fresh aliases — any orphan Metronome customers from previous instances are inert. No manual cleanup needed. -User-facing billing docs: [`docs/platform/billing.mdx`](../platform/billing.mdx). Behaviour test cases live at `test/billing-*.md` in the repo root. +User-facing billing copy: [`docs/platform/billing.mdx`](../platform/billing.mdx). Design notes: [`docs/wg/platform/ai-credits.md`](../wg/platform/ai-credits.md), [`docs/wg/platform/metronome.md`](../wg/platform/metronome.md). Known issues: [`docs/wg/platform/billing-known-issues.md`](../wg/platform/billing-known-issues.md). CLI guide: [`editor/scripts/billing/README.md`](../../editor/scripts/billing/README.md). --- ## Troubleshooting -- **`STRIPE_SECRET_KEY is required`** — `.env.test.local` not loaded. Confirm path and contents. -- **`plan.pro price not wired`** — you haven't run the setup script since your last `supabase db reset`. -- **Webhook signature verification failing** — your `stripe listen` was restarted and produced a new `whsec_…`. Update `STRIPE_WEBHOOK_SECRET`. -- **Sidebar plan stale** — the local mirror updates only when the webhook lands. Check that `stripe listen` is running. -- **Customer Portal flows error with "no Stripe customer"** — the org hasn't upgraded yet. Stripe customers are lazy-created on first paid checkout. +- **`STRIPE_SECRET_KEY is required`** — `.env.test.local` not loaded. +- **`plan.pro price not wired`** — re-run `cli.ts setup:stripe` after `db reset`. +- **`Metronome substrate missing`** — re-run `cli.ts setup:metronome`. +- **Stripe webhook signature failing** — `stripe listen` was restarted; new `whsec_…`. Update `STRIPE_WEBHOOK_SECRET`. +- **Metronome webhook signature mismatch** — `METRONOME_WEBHOOK_SECRET` differs from the value in Metronome Dashboard. Re-copy. +- **Tunnel returns 404** — `WEBHOOK_TUNNEL_HOSTNAME` doesn't match the routed hostname, or `cloudflared` isn't running. Re-run from `cli.ts smoke:webhook` to pinpoint which layer is broken. +- **Customer Portal "no Stripe customer"** — org hasn't subscribed or topped up yet. Stripe customer is lazy-created on first paid action. +- **AI credit shows "Out of credit" forever after a successful top-up** — Metronome webhook didn't reach the tunnel. Run `cli.ts smoke:webhook` to verify each layer. --- @@ -112,4 +173,7 @@ User-facing billing docs: [`docs/platform/billing.mdx`](../platform/billing.mdx) | `SUPABASE_SECRET_KEY` | `editor/.env.test.local` | | `STRIPE_SECRET_KEY` | `editor/.env.test.local` | | `STRIPE_WEBHOOK_SECRET` | `editor/.env.test.local` | +| `METRONOME_API_TOKEN` | `editor/.env.test.local` | +| `METRONOME_WEBHOOK_SECRET` | `editor/.env.test.local` | +| `WEBHOOK_TUNNEL_HOSTNAME` | `editor/.env.test.local` | | `BILLING_E2E`, `BILLING_TEST_MODE`, `APP_URL` | `editor/.env.test` (committed) | diff --git a/docs/wg/platform/ai-credits.md b/docs/wg/platform/ai-credits.md new file mode 100644 index 000000000..a19ec719b --- /dev/null +++ b/docs/wg/platform/ai-credits.md @@ -0,0 +1,523 @@ +--- +title: AI Credits — Master Plan +tags: + - internal + - wg + - platform + - billing + - ai +status: implementation +--- + +| feature id | status | description | PRs | +| ------------ | --------------------- | ------------------------------------------------------------------------------------ | --- | +| `ai-credits` | implementation (v1.0) | Metered AI usage with top-ups and auto-reload. Metronome = ledger; Stripe = payment. | — | + +# AI Credits — Master Plan + +> Companion to [docs/platform/billing.mdx](../../platform/billing.mdx) (the +> user-facing promise). This doc says **how** we implement AI credits. +> v1.0 wires every flow except the AI seam call sites; gate primitive is +> ready to consume. + +> **Scope note (deferred).** The plan-included recurring credit flow and +> the `PLAN_GRANT` priority tier are intentionally **not** implemented in +> v1.0. They re-enter scope alongside seat-based subscriptions; until +> then, `setMonthlyIncludedCredit`, `stopMonthlyIncludedCredit`, and the +> `actionApplyPlan` insiders harness referenced below do not exist in +> code. The design here is preserved as forward reference. + +## Audience + +- Core platform engineers +- Future agents working on billing, AI seam, webhook projector + +## Purpose + +Define the AI-credits system as a self-contained design covering every +production flow: + +- **Top-up** — explicit $5–$1000 prepaid charge. +- **Auto-reload** — threshold-triggered recharge. +- **Plan-included credit** — monthly grant from a paid subscription. +- **Refund / revoke** — voluntary refund for unused balance. +- **Gate** — sub-100ms entitlement read for the AI seam. + +What's deferred from v1.0: the AI seam call sites (`editor/lib/ai/server.ts` +wrapping `replicate`, `ai`, `@ai-sdk/*`, `openai`, etc.). The gate +primitive and the `ingestUsageEvent` function are ready; wiring is +mechanical. + +--- + +## Constraints (this plan honors all of these) + +These are the long-lived invariants. Where any of them changes, this doc +changes. + +**Money model** + +- AI is sold at cost. Zero markup on provider price. Margin comes from + the base plan / seat fee, not from AI usage. +- Grida never loses money on a paid user under any realistic failure + mode. Free users may lose up to $0.50/period (capped, accepted as CAC). +- **Top-up envelope.** Custom amount **$10–$500**. User pays a + **flat-rate processing markup** on top of the credit amount; receives + exactly $X of credit. The markup is `total = ceil((credit + 30) / 0.95)` + — i.e. 5% gross-up plus a $0.30 buffer. Single safe envelope across + every Stripe card type (US 2.9%+30¢, intl 3.9%+30¢, AmEx 3.5%+30¢, + intl AmEx 4.4%+30¢, +1% currency conversion). Verified by + `editor/scripts/billing/cli.ts markup-sim`; re-run when Stripe's + rates change or the amount range changes. Implementation: + `lib/billing/fees.ts > totalChargeForCredit()`. Applied at + user-initiated Checkout (manual top-up + first auto-reload setup). +- **Auto-reload envelope.** Threshold ≥ $5 (whole dollars). Recharge + target $25–$500 (whole dollars). Recharge target must exceed + threshold. Same markup formula applies on the _initial_ setup + Checkout. **Silent recharges thereafter run at-cost** — see + [billing-known-issues.md KI-BILL-001](./billing-known-issues.md#ki-bill-001--silent-auto-recharge-runs-at-cost-markup-gap) + for cause + planned fix. v1 mitigation: **auto-reload is gated behind + an active paid subscription** so the loss is bounded by base-plan + margin. Manual top-up is unaffected and remains free-tier accessible. +- Top-up credits never expire. +- Plan-bundled credits expire monthly (use-it-or-lose-it). +- Hard floor: AI is blocked when balance falls below **$0.25 (250 mills)**. + No per-model pre-flight cost ceiling. Floor is a single global gate. +- No post-paid overage. Below floor → block + top-up CTA. There is no + "use now, settle later" path. + +**Identity & accounts** + +- Billing subject is the **organization**, not the user. Every org has + exactly one Stripe Customer and one Metronome Customer. Lazy-create at + first paid intent. +- AI credit pools at the org level — one balance per org, regardless of + seat count. + +**Money denomination** + +- Cents at every persistence layer (Stripe + Metronome both use cents). + Sub-cent provider costs come in as `cost_mills` (1 mill = $0.001) on + the ingest payload; Metronome's rate-card `unit_amount_decimal` does + the per-mill math (`0.1` cents/mill). + +**Source of truth** + +- **Metronome** is source of truth for **credit balance**, grants, drain + order, period rollover. +- **Stripe** is source of truth for **money** — payment intents, + charges, refunds, subscription state. Metronome facilitates the charge + via `payment_gate_config: STRIPE` on commits. +- **Our DB** is source of truth for **gate decisions** — boolean + `customer_entitled` + cached balance, kept in sync via Metronome + webhooks. + +**Operational** + +- Idempotent metering at every layer. `transaction_id` on Metronome + ingest is the end-to-end idempotency key (34-day dedup window). +- All Stripe and Metronome webhook handlers dedup on event id at the DB + layer (`stripe_event` / `metronome_event` tables, PK on event_id). + +--- + +## Terminology + +- **Top-up commit**: prepaid balance bought via Stripe-charged commit. + Metronome charges the card, lands on success. Modeled with + `payment_gate_config: STRIPE` + `invoice_schedule`. +- **Complimentary commit**: dev / promo / refund / manual grant. No + Stripe charge. +- **Plan-included credit**: monthly recurring grant for a paid + subscription. Modeled as a complimentary commit with multiple monthly + schedule items (one per future month). +- **Auto-reload**: `prepaid_balance_threshold_configuration` on the + contract — Metronome auto-charges when balance drops below threshold. +- **Gate**: pre-flight check on whether an AI call is allowed. Reads + `grida_billing.account.customer_entitled` boolean. +- **Seam**: the single `editor/lib/ai/server.ts` file all AI calls go + through. Not yet wired in v1.0. +- **Substrate**: the named Metronome resources (billable metric, + products, rate card, rate) created out-of-band by + `editor/scripts/billing/cli.ts setup:metronome`. + +--- + +## Why Metronome (and not in-house, and not Stripe Billing Credits) + +Three engines evaluated. Decision: **Metronome**. + +- **Stripe Billing Credits** — applies only to subscription items using + metered prices reporting through Meters. 100 unused-grants-per-customer + cap. Two `category` values. Stripe themselves recommend Metronome for + new integrations. +- **In-house FIFO ledger** — fully owned. Tempting for vendor + independence, but commits us to maintaining FIFO/expiration math, + period rollover, seat-prorate logic, drain-order discipline, audit + trail, and invoice line-item display. +- **Metronome** — Stripe-acquired, the recommended path. Used by OpenAI, + Anthropic, Databricks, NVIDIA. Native primitives for everything we + need: customers, contracts, rate cards, prepaid commits, recurring + credits, payment-gated commits with Stripe handoff, + threshold-recharge, alert webhooks. + +Trade-offs accepted: opaque pricing, two-system sync (Stripe + Metronome +webhooks both feed our state), vendor lock-in (bounded by Metronome's +data export to BigQuery / S3 / Snowflake). + +--- + +## Architecture + +### Single AI gateway seam (deferred from v1.0) + +All AI calls in the editor pass through one file: +`editor/lib/ai/server.ts`. It is the only file allowed to import +`replicate`, `ai`, `@ai-sdk/*`, `openai`, or `@anthropic-ai/sdk`. Three +layers of enforcement: + +1. **Lint** (oxlint `no-restricted-imports`) — direct provider imports + outside the seam fail at lint. +2. **Runtime contract** — the seam's `withBilling()` wrapper requires an + `organizationId`. No `organizationId` → throws. +3. **Audit script** — CI grep that flags any new file importing a + provider SDK. + +The gate primitive is ready (`getEntitlement(organizationId)` in +`@/lib/billing/metronome`). The seam itself is deferred — wiring the +existing AI route handlers through this seam is a separate, mechanical +PR. + +### Two AI flow archetypes + +**A. Media generation (single-shot)** + +``` +gate(local cache) → provider → ingest event to Metronome + ↓ on failure + no ingest (no charge) +``` + +Replicate predictions, image-tools, audio-gen, single image-gen. Cost +known post-hoc. No two-phase reservation needed; Metronome's +`transaction_id` dedup window is 34 days. + +**B. Streaming / agentic (multi-turn, growing)** + +``` +gate(local cache, check entitled) + ─▶ stream tokens (input + output) + ─▶ tool calls (each may spawn nested provider calls — apply A above) + ─▶ ingest event(s) on stream completion + no ingest on cancellation +``` + +Tool calls inside the agent re-enter `withBilling` with their own gate. +Outer agent does not pre-allocate budget for tool spend; tools are +charged separately. If floor is breached mid-stream, the stream +terminates cleanly with a `BillingError` part. + +### Top-up flow (Stripe-charged) + +``` +user clicks "Top up $X" + ↓ +addStripeChargedCommit(orgId, amountCents) + ↓ + Metronome v2.contracts.edit + add_commits with: + payment_gate_config: STRIPE / PAYMENT_INTENT + invoice_schedule (the line that gets charged) + access_schedule (what becomes available on success) + priority COMMIT_PRIORITY.TOPUP (90 — drains last) + ↓ + Metronome charges Stripe synchronously + ↓ + on success: commit lands, balance updates + on failure: commit voided + ↓ + webhook: payment_gate.payment_status (paid/failed) + ↓ + fn_billing_apply_metronome_event: + flips customer_entitled = true on success + ↓ + refreshBalance: pulls current balance, updates cache +``` + +Critical anti-spoof rule: amounts always come from API parameters, never +from request metadata. + +### Auto-reload (threshold-recharge) + +``` +setAutoReload(orgId, thresholdCents, rechargeAmountCents) + ↓ + contracts.edit + add_prepaid_balance_threshold_configuration: + threshold_amount, recharge_to_amount + payment_gate_config: STRIPE + commit: { product_id, applicable_product_ids, priority: TOPUP } + is_enabled: true + ↓ + persist enabled / threshold / amount to grida_billing.account + ↓ + when balance drops below threshold: + ↓ + Metronome auto-charges Stripe → commit added + ↓ + same webhook flow as Top-up +``` + +### Plan-included credit (subscription monthly grant) + +``` +user upgrades to Pro → Stripe subscription activates + ↓ +[Stripe webhook handler — TODO: hook into existing projector] + ↓ +setMonthlyIncludedCredit(orgId, monthlyCents, monthsAhead) + ↓ + contracts.edit + add_commits: + access_schedule.schedule_items: 12 monthly slots, one per month + priority: COMMIT_PRIORITY.PLAN_GRANT (10 — drains first) + no invoice_schedule (complimentary; the plan fee paid for it) + ↓ + Metronome materializes month-N grant on its starting_at boundary + ↓ + each month, a new "segment" of the commit becomes accessible + ↓ + webhook: commit.segment.start fires when a new month opens + ↓ + refreshBalance keeps cache in sync +``` + +When user cancels: the access_schedule items past `cancel_at_period_end` +should be archived. Currently a manual op via `setMonthlyIncludedCredit` +re-call (which archives existing then adds replacement). Wire to +subscription-cancel webhook in next iteration. + +### AI call flow (gate primitive ready) + +``` +[AI route handler — TODO] + ↓ +withBilling({ organizationId, kind, model_id }, op) + ↓ +getEntitlement(organizationId) ← reads grida_billing.account + (no Metronome round-trip) + ↓ +if !allowed: throw BillingError(reason) + ↓ +op(transactionId) ← provider call + ↓ +on success: ingestUsageEvent(organizationId, costMills, { transactionId }) + ↓ +fire-and-forget; Metronome dedups, drains commits per priority + ↓ +when commit hits zero: alerts.low_remaining_… webhook fires + ↓ +fn_billing_apply_metronome_event: + flips customer_entitled = false + ↓ +next gate check returns BLOCKED until top-up +``` + +The gate **never** calls Metronome on the hot path. All gate reads +come from the local DB row. + +### Drain order — `priority` discipline + +Metronome's drain order is **not** "expires-soonest first by default." +The order is: tier (rollover commits → prepaid commits/credits → +postpaid commits) → `priority` integer (lower drains first) → 6-step +tiebreaker chain where `ending_before` is rule #6. + +For us, all grants land in the prepaid tier. We assign `priority` +explicitly via `COMMIT_PRIORITY` (in `lib/billing/metronome.ts`): + +| Grant type | priority | Defined as | +| ----------------------- | -------- | ---------------------------- | +| Plan-included credit | 10 | `COMMIT_PRIORITY.PLAN_GRANT` | +| Promo / refund / manual | 50 | `COMMIT_PRIORITY.PROMO` | +| Top-up + auto-reload | 90 | `COMMIT_PRIORITY.TOPUP` | + +Plan-included drains first (it expires monthly anyway), then promos, +then top-ups (they never expire — preserve them as long as possible). + +### Refund flow + +Metronome explicitly does not own refunds. Workflow: + +1. User requests refund. +2. Compute `unused = original − consumed`. Spent portion is non-refundable. +3. Issue Stripe refund for the unused portion. +4. Call `revokeUnusedOnCommit(orgId, commitId)` — shrinks the commit's + schedule amount to the consumed portion. Remaining balance becomes 0. +5. Audit row written; `refreshBalance` updates cache. +6. On forced chargeback, balance floors at zero; spent portion is logged + as fraud loss. + +### Reconciliation jobs (TODO — separate PR) + +| Job | Compares | Cadence | On drift | +| ------------------- | -------------------------------------------------------- | ------- | --------------------------------------------------------------------------- | +| **Balance** | local `cached_balance_cents` ↔ Metronome `/listBalances` | hourly | Reset cache. Flags missed webhooks if delta > $0.10. | +| **Orphan-usage** | provider request lists ↔ local audit | daily | Insert missing event; ingest with same `transaction_id` (Metronome dedups). | +| **Cost-card audit** | local recorded cost ↔ provider monthly invoice | weekly | Update cost cards; book diff as cost-of-goods. | + +### Webhook security + +- HMAC-SHA256 of `\n` keyed by the secret. + Compared to `Metronome-Webhook-Signature` header. +- DB-backed dedup via `grida_billing.metronome_event` (PK on event_id). +- 5-minute freshness window (reject older events). +- `fn_billing_apply_metronome_event` is `SECURITY DEFINER` and idempotent + by design (`INSERT ... ON CONFLICT DO NOTHING` on the event row). + +Stripe webhook is parallel: `grida_billing.stripe_event` table, +`fn_billing_apply_stripe_event` projector. Already wired. + +--- + +## Implementation map + +| Concern | File | +| -------------------------------- | -------------------------------------------------------------------------------- | +| Schema (account columns + dedup) | `supabase/migrations/20260508130000_grida_billing_metronome.sql` | +| Service module | `editor/lib/billing/metronome.ts` | +| Webhook receiver | `editor/app/(ingest)/webhooks/metronome/route.ts` | +| Webhook projector RPC | `public.fn_billing_apply_metronome_event` (in the migration) | +| Insiders QA harness | `editor/app/(insiders)/insiders/billing/metronome/{page,_view,actions}.{tsx,ts}` | +| Substrate setup | `editor/scripts/billing/cli.ts setup:metronome` (and `setup:stripe`) | +| Smoke / sandbox proofs | `editor/scripts/billing/cli.ts smoke:{topup,auto-reload,webhook}` + `ping` | + +The service module exports: +`provisionOrg`, `addStripeChargedCommit`, `addComplimentaryCommit`, +`setAutoReload`, `disableAutoReload`, +`provisionZeroBalanceAlert`, `provisionLowBalanceAlert`, +`getEntitlement`, `refreshBalance`, `getOrgBalance`, +`ingestUsageEvent`, `ingestUsageEventGated`, `revokeUnusedOnCommit`. +(Plan-included helpers `setMonthlyIncludedCredit` / +`stopMonthlyIncludedCredit` were removed for v1.0; see scope note at +the top of this doc.) + +--- + +## Open questions + +| ID | Question | +| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Q-AI-2 | Mid-stream floor breach in agent flows: terminate cleanly with `BillingError` part, or finish with bounded overspend? (Defer to seam wiring.) | +| Q-AI-6 | The user-facing doc promises features L1 doesn't deliver (~80%). Update doc, delay launch, or feature-flag to beta orgs? | +| Q-AI-10 | Cost-card source of truth: keep `editor/lib/ai/ai.ts` cards (current), or migrate to per-rate dimensional pricing in Metronome? | +| Q-AI-13 | Streaming agent + tool re-entry: tools re-gate against the floor each call; if floor breached mid-loop, terminate the agent cleanly. Spec the UX. | +| Q-AI-14 | **Metronome pricing.** Public is "Contact sales." Need a sales conversation before we can model COGS. Blocks production launch. | +| Q-AI-15 | Customer hierarchy: ingest aliases (single contract, sub-org keys) vs account hierarchies (per-child contracts, parent commits, max 10 nodes). For Grida orgs with seats, ingest aliases probably suffice. Confirm. | +| Q-AI-16 | Free-tier $0.50 monthly credit: provision a Metronome customer at signup with a `setMonthlyIncludedCredit($0.50)` recurring grant? Or Free has no AI? | + +### Resolved during research + +- **Engine choice** — Metronome. +- **Reservation pattern** — replaced by Metronome's `transaction_id` + idempotency. +- **Drain order configurability** — yes via `priority`; explicit per + grant. +- **Top-up expiration** — never expires. +- **Refund pattern** — `editCommit` with `update_schedule_items.amount` + shrinks remaining balance (`revokeUnusedOnCommit`). +- **Auto-reload pattern** — `prepaid_balance_threshold_configuration` + with `payment_gate_config: STRIPE`. +- **Plan-included credit pattern** — multi-segment access schedule with + `priority: PLAN_GRANT`. +- **Webhook signature scheme** — HMAC-SHA256 over `Date\n`; + verified by `fn_billing_apply_metronome_event`'s parent route. + +--- + +## Phasing + +1. **Phase 0 — Sales conversation with Metronome.** Get pricing. + Resolves Q-AI-14. Required before production launch. +2. **Phase 1 — Schema + projector. ✅** Migration applied: + `20260508130000_grida_billing_metronome.sql`. +3. **Phase 2 — Substrate + Metronome integration scaffolding. ✅** + Service module, dev harness, all spike + smoke scripts. +4. **Phase 3 — Webhook receiver + DB-backed dedup. ✅** HMAC verified + against the wire; `payment_gate.payment_status` event captured. +5. **Phase 4 — User-facing top-up CTA on settings page.** Wires to + `addStripeChargedCommit` via a server action. Deferred — UI work. +6. **Phase 5 — Subscription event integration.** Hook the Stripe + subscription projector to call `setMonthlyIncludedCredit` when the + org transitions to Pro/Team. Deferred — touches the existing Stripe + projector. +7. **Phase 6 — AI seam.** Wrap provider SDK call sites through + `withBilling`. The gate + ingest primitives are ready. +8. **Phase 7 — Reconcile jobs.** Three jobs (balance, orphan-usage, + cost-card) on cron. + +--- + +## Risks + +- **Metronome pricing is opaque.** Treat Phase 0 as the gate to + production launch. +- **Public-doc gap.** L1 ships less than what `billing.mdx` promises. + Either delay launch, gate behind a beta flag, or rewrite the public + doc. +- **Two-system sync.** Metronome + Stripe webhooks both feed our state. + Mitigated by the projector RPC's idempotency and the hourly balance + reconcile (when wired). +- **Two idempotency keys.** Metronome events use `transaction_id` + (34-day window); REST POSTs use `Idempotency-Key` header. Codified in + the service module. +- **No native hard-block at zero.** Metronome accepts ingest past zero; + the balance-zero alert webhook is what flips entitlement to false. + Concurrency loss bound: events in-flight when the webhook arrives. + Bounded by floor × concurrent clients per period. +- **Vendor lock-in.** Real but bounded by Metronome's data export + facility. +- **Streaming-agent tool re-entry.** Tools re-gate against the floor + each call; mid-loop breach terminates the agent cleanly. + +--- + +## Manual QA via the insiders dev harness + +Every flow is exercisable from +[/insiders/billing/metronome](<../../../editor/app/(insiders)/insiders/billing/metronome/_view.tsx>): + +1. Enter an `organization_id` (bigint PK; e.g. `1` if you have one). +2. **Provision** → creates Metronome customer + contract, persists ids. +3. **Provision $0 alert** → creates the balance-zero alert. +4. **Charge Stripe + add commit** → real top-up via Stripe-charged + commit. Metronome charges your test Stripe customer. +5. **Enable auto-reload** → configures threshold-recharge. +6. **Set monthly included credit** → L2 plan grant. +7. **Add complimentary commit** → promo / refund / manual grant. +8. **Ingest** → fires a usage event with `cost_mills`. +9. **Refresh balance (sync from Metronome)** → updates cache. +10. **Revoke unused** → click any commit_id in the live commits table; + shrinks to consumed portion. + +The "grida_billing.account" panel shows what the gate reads; the "Live +commits (Metronome)" panel shows the truth. They should agree after a +refresh. + +--- + +## Appendix — relationship to existing artifacts + +- `supabase/migrations/20260508130000_grida_billing_metronome.sql` — + schema migration. Adds account columns + dedup table + projector. +- The earlier untracked `supabase/schemas/grida_ai.sql` is dropped + entirely. Its `usage_grant`, `credit_balance_cache`, `usage_meter` + designs are replaced by Metronome; the audit/cost-card pieces are + deferred until needed. +- `editor/lib/billing/metronome.ts` — the service layer. +- `editor/lib/billing/index.ts` — Stripe surfaces (existing). +- `editor/app/(ingest)/webhooks/metronome/route.ts` — webhook receiver. +- `editor/scripts/billing/*` — substrate setup + every smoke / spike + script that proved each flow against the sandbox. +- [docs/platform/billing.mdx](../../platform/billing.mdx) — user-facing + doc; needs reconciliation with what L1 actually ships (Q-AI-6). +- [test/billing-quota-and-ai.md](../../../test/billing-quota-and-ai.md), + [test/billing-payment-and-money-safety.md](../../../test/billing-payment-and-money-safety.md): + manual-test corpus L1 must pass. diff --git a/docs/wg/platform/billing-known-issues.md b/docs/wg/platform/billing-known-issues.md new file mode 100644 index 000000000..2b150e4c2 --- /dev/null +++ b/docs/wg/platform/billing-known-issues.md @@ -0,0 +1,160 @@ +--- +title: Billing — Known Issues +tags: + - internal + - wg + - platform + - billing +status: living +--- + +# Billing — Known Issues + +> Living document. Every known issue in the billing surface (subscriptions, +> AI credit, Stripe ↔ Metronome sync, webhooks) is tracked here with its +> cause, current behavior, mitigation, and planned fix. +> +> Add new issues at the **bottom**. Don't delete entries when fixed — +> move them to the **Resolved** section with the PR / commit that closed +> them. The history is the audit trail for "why did we do it that way." + +| ID | Area | Severity | Status | +| ----------- | ----------------------- | -------- | --------- | +| KI-BILL-001 | AI credit · auto-reload | Medium | Mitigated | +| KI-BILL-002 | Subscriptions | Low | Accepted | + +--- + +## KI-BILL-001 — Silent auto-recharge runs at-cost (markup gap) + +**Area.** AI credit · Metronome `prepaid_balance_threshold_configuration`. + +**Discovered.** 2026-05 during the AI credit v1 implementation. + +**Cause.** Metronome's threshold-recharge primitive +(`prepaid_balance_threshold_configuration`) exposes a single +`recharge_to_amount` field. That value is used as both: + +1. the amount **charged** to the customer's saved card via Stripe, and +2. the amount **credited** to the Metronome balance. + +There is no separate "charge X, credit Y" mode on this primitive. Our +markup envelope (`lib/billing/fees.ts > totalChargeForCredit`, +`ceil((credit + 30) / 0.95)`) needs the two amounts to differ — the user +pays gross, receives net. Because we can't apply that envelope here, every +silent recharge fires at-cost: Stripe takes its 2.9–4.4% + $0.30 + optional +1% FX out of our pocket. + +**Current behavior.** + +- The **first** auto-reload setup goes through Stripe Checkout + (`startEnableAutoReloadCheckout`) with the markup applied — that + charge is safe. +- **Subsequent** silent recharges — fired by Metronome when balance + crosses the threshold — run at-cost. Per-fire loss: + + | Card | Recharge | Loss | + | -------------------- | -------- | ------ | + | US Visa/MC, $25 | $25.00 | $1.03 | + | US Visa/MC, $100 | $100.00 | $3.20 | + | US Visa/MC, $500 | $500.00 | $14.80 | + | Intl card, $100 | $100.00 | $4.20 | + | Intl card + FX, $100 | $100.00 | $5.20 | + +**Mitigation (shipped, v1).** Auto-reload is gated behind an active paid +subscription (`assertAutoReloadAllowed` in +`editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts`). + +- Free orgs cannot enable auto-reload at all. The UI hides the toggle + behind a "Pro plan required" badge with an Upgrade CTA. +- Paid orgs can enable it; the silent-recharge loss is then bounded and + recovered from the base-plan margin. +- Manual top-up is unaffected — it always goes through Checkout, always + pays the markup. Free users have full access to manual top-up. + +This converts an unbounded, per-recharge loss (scales with usage on the +free tier) into a fixed, predictable cost on the subscriber population +that already covers it. + +**Planned fix.** Drop reliance on Metronome's threshold-config charge +behavior; drive recharges from the +`alerts.low_remaining_commit_balance_reached` webhook with our own +`add_commits` call using `access_schedule.amount ≠ invoice_schedule.amount` +(Metronome's commit API supports this split). Metronome still does balance +tracking, alert evaluation, and Stripe charge execution — we just route the +trigger and apply the markup ourselves. + +- Estimated effort: ~130 LOC. +- Once shipped, the subscription gate on auto-reload can be lifted. +- Tracking issue: TODO — file before unblocking free-tier auto-reload. + +**Why we didn't fix it before shipping.** The fix touches the alert +webhook handler, requires a new outbound `add_commits` call path, and +needs careful ordering against Metronome's own balance bookkeeping +(don't double-credit on race). Not worth blocking v1 for a loss surface +we can cap at the product layer in five lines. + +**Files.** + +- `editor/lib/billing/fees.ts` — markup envelope (correct path). +- `editor/lib/billing/metronome.ts > setAutoReload` — Metronome + threshold-config call (the at-cost path). +- `editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts > assertAutoReloadAllowed` + — subscription gate. +- `docs/wg/platform/ai-credits.md` "Auto-reload envelope" — references + this entry. + +--- + +## KI-BILL-002 — Concurrent subscribe Checkouts can produce orphan Stripe sub + +**Area.** Subscriptions · Stripe Checkout race. + +**Discovered.** During the subscription system v1 design (TC-BILLING-SUB-059). + +**Cause.** `startSubscribeCheckout` checks for an existing active sub +locally before opening Checkout, but two concurrent calls (e.g. the user +opens Checkout in two browser tabs and pays in both) can both pass the +check and produce two live Stripe subscriptions. + +**Current behavior.** The second `customer.subscription.created` webhook +is rejected at the DB layer by `subscription_one_active_per_org_idx`. +Locally the org has exactly one active subscription. Stripe, however, +holds two — one of them is unbacked by any local row and will keep +billing the customer. + +**Mitigation (shipped, v1).** None at the application layer. Closure +documented inline at `_actions.ts > startSubscribeCheckout` referencing +GRIDA-60. + +**Planned fix.** Track open Checkout sessions in +`grida_billing.checkout_session` (or similar); reject a new +`startSubscribeCheckout` call when an open session for the same org is +younger than the Checkout session TTL. + +**Why accepted for v1.** Risk is to Grida (we refund manually on the +duplicate Stripe sub), not the customer. Volume in v1 is bounded by +manual onboarding; not worth the schema work yet. + +--- + +## Resolved + +_(none yet)_ + +--- + +## Adding a new entry + +Use the next sequential `KI-BILL-NNN` id. Required sections: + +- **Area** — which subsystem. +- **Discovered** — date and context. +- **Cause** — the root mechanism, not just the symptom. +- **Current behavior** — what users / Grida actually see today. +- **Mitigation** — what's shipped to keep the loss / risk bounded. +- **Planned fix** — concrete next step, with effort estimate. +- **Why we didn't fix it before shipping** — required if status is + "Mitigated" or "Accepted." +- **Files** — pointers into the codebase. Helps the future fix-PR + scope itself. diff --git a/docs/wg/platform/metronome.md b/docs/wg/platform/metronome.md new file mode 100644 index 000000000..26d970f4e --- /dev/null +++ b/docs/wg/platform/metronome.md @@ -0,0 +1,504 @@ +--- +title: Metronome — Billing Infrastructure +tags: + - internal + - wg + - platform + - billing +status: implemented +--- + +| feature id | status | description | PRs | +| ----------- | ----------- | ------------------------------------------------------------------------------------------------------- | --- | +| `metronome` | implemented | Metronome integration: prepaid credit substrate, threshold-recharge, webhook projector, drift detector. | — | + +# Metronome — Billing Infrastructure + +> Architectural reference for our Metronome integration. This is the +> **infrastructure layer** that the [AI Credits master plan](./ai-credits.md) +> sits on top of. AI Credits is the product feature; this doc is how the +> billing substrate underneath it actually works, what Metronome owns, +> what we own, and the documented gotchas. + +## Audience + +- Core platform engineers +- Future agents working on billing, the AI seam, or the webhook projector +- Anyone debugging a billing issue at 2am + +## Purpose + +Describe the long-lived shape of our Metronome integration: the +substrate, the building blocks we layer on top, the user journey from +provisioning through steady-state usage and recovery, and the gotchas +we have already documented from sandbox spikes. + +This doc deliberately avoids implementation identifiers (function +names, file paths, class names). Those rot; the concepts here do not. +For implementation, see [AI Credits master plan](./ai-credits.md) which +maps the building blocks below to current code. + +--- + +## Terminology + +**Metronome's own concepts (used verbatim — these are stable external API names):** + +- **Customer** — Metronome's per-org entity. Billing subject. +- **Contract** — the container for commits, rate cards, alert configs, + and threshold configs attached to a Customer. +- **Commit** — a unit of credit on a contract. Has an `access_schedule` + (when/how much becomes available), an optional `invoice_schedule` + (charge it via Stripe), and a `priority` (drain order tiebreaker). +- **Recurring Credit** — a commit whose access schedule has multiple + monthly segments; refreshes automatically. +- **Billable Metric** — Metronome's definition of how raw usage events + aggregate into a billable quantity. +- **Rate Card** — pricing for a contract; maps a metric to a price. +- **Product** — Metronome's catalog object. We use a USAGE Product + (priced per metric) and a FIXED credit Product (the unit that commits + are denominated in). +- **`payment_gate_config`** — flag on a commit or threshold config that + routes the charge through Stripe. +- **`prepaid_balance_threshold_configuration`** — the auto-recharge + config on a contract. Fires a Stripe charge when balance drops below + threshold. +- **Events**: `payment_gate.threshold_reached`, + `payment_gate.payment_status`, + `payment_gate.payment_pending_action_required`, + `alerts.low_remaining_*`, `commit.create`, `contract.edit`. +- **`transaction_id`** — idempotency key on usage ingest. 34-day + dedup window. + +**Our layer (semantic names — these are how we talk about our own components):** + +- **Credit account** — per-org DB row that holds Metronome linkage IDs, + cached balance, cached entitlement boolean, and cached auto-reload + config. The gate primitive reads this row. +- **Substrate** — the named Metronome resources (Billable Metric, + USAGE Product, FIXED credit Product, Rate Card, Rate) created + out-of-band and looked up by name at runtime. +- **Provisioner** — idempotent match-or-create routine for the + Metronome Customer + Contract + Stripe billing provider configuration + on the Customer. +- **Gate primitive** — sub-100ms entitlement check read by the AI seam + before every provider call. Local DB read; never hits Metronome. +- **Webhook projector** — HMAC-verified inbound endpoint that consumes + Metronome webhook events, dedups by event id, and reconciles the + credit account. +- **Live state reconciler** — the write→read→reconcile pattern: after + any contract mutation, we read the contract back from Metronome and + derive cache from the live state. The DB is never ahead of Metronome. +- **Drift detector** — pairs cached state against a live read; flags + divergence as "dropped webhook" so debugging surfaces the layer that + broke. +- **Commit operations** — the two flavors of credit grant we issue + today: Stripe-charged top-up and complimentary grant. Plan-included + recurring credit is deferred (re-introduced when seat-based + subscriptions land). + +--- + +## Roles — who owns what + +| System | Source of truth for | +| ------------------ | ------------------------------------------------------------------------------ | +| **Metronome** | Credit balance, drain order, recharge mechanism, usage aggregation | +| **Stripe** | Money movement (charges, refunds, payment methods, subscription state) | +| **Credit account** | Gate decision (entitled true/false), cached balance, cached auto-reload config | + +Metronome facilitates Stripe charges via `payment_gate_config: STRIPE` +on commits and on the threshold configuration. The credit account is a +read-side projection updated by Metronome webhooks and by the live +state reconciler. + +--- + +## Building blocks + +### Substrate + +Five named Metronome resources are created out-of-band and looked up by +name at runtime: + +- a Billable Metric (the unit we ingest usage against) +- a USAGE Product (carries the metric and its rate) +- a FIXED credit Product (the unit commits are denominated in — we + use a $1.00-per-credit denomination so commits can be expressed in + cents directly) +- a Rate Card (the contract's pricing surface) +- a Rate on the Rate Card mapping the USAGE Product to a price using + `unit_amount_decimal` for sub-cent precision + +These resources are conventionally named. Our layer never tries to +create them at request time; if they are missing, provisioning fails +loudly. This keeps Metronome's catalog clean and makes the substrate +reviewable as a single artifact. + +### Credit account + +One row per organization. Holds: + +- Metronome `Customer` ID (linkage) +- Metronome `Contract` ID (linkage) +- cached balance in cents +- cached entitlement boolean +- cached auto-reload state (enabled / threshold / recharge amount) + +The gate primitive reads this row. Webhooks write to this row. The +live state reconciler is the only path that overwrites cached values +from a fresh Metronome read. + +### Provisioner + +Idempotent. Match-or-create the Metronome Customer + Contract for the +org, then ensure the Customer carries a Stripe billing provider +configuration so threshold-recharge can charge a card. Self-heals +legacy state: if a Customer exists without the configuration (e.g. an +older provisioning run), the next provisioner pass attaches it. +Re-running the provisioner is always safe. + +### Commit operations + +Two flavors today, each landing as a Metronome `Commit` on the contract: + +| Operation | Charges Stripe? | Drain priority | Use case | +| --------------------- | --------------- | ------------------- | ---------------------------------------- | +| Stripe-charged top-up | yes | TOPUP (drains last) | user-initiated $5–$1000 prepaid purchase | +| Complimentary grant | no | PROMO (middle) | promo / refund / manual ops grant | + +Both are commits on the same contract. The drain priority scheme +encodes "use promos before paid balance": + +``` +PROMO (50) < TOPUP (90) +``` + +Lower priority drains first. This is the cookbook pattern from +Metronome's prepaid-credits launch guide; integers are arbitrary as +long as the relative order holds. A third tier (plan-included +recurring credit, drains first) is reserved for the seat-based +subscription milestone and deliberately not implemented today. + +### Auto-reload configuration + +A `prepaid_balance_threshold_configuration` on the contract: when +balance falls below `threshold_amount`, Metronome fires a Stripe +charge to bring balance back to `recharge_to_amount` and lands the +result as a new TOPUP-priority commit. Keyed off the Stripe +`payment_gate_config` on the contract. + +Configuration is mirrored into the credit account so the gate read +and the settings UI do not have to call Metronome to display +"auto-reload is on." + +### Gate primitive + +A sub-100ms read of the credit account: returns "entitled" iff +`customer_entitled = true` AND cached balance ≥ floor. Read from local +DB; never hits Metronome. The AI seam consults this before every +provider call. + +This is the primary cookbook recommendation: cache `customer_entitled` +locally and gate on it. The cache is kept in sync by webhooks. There +is no fast-path call to Metronome on the request critical path. + +### Ingest pipeline + +Post-call cost emission to Metronome's `usage.ingest`. Each event +carries: + +- the org's Metronome Customer ID +- the metric name +- the cost (in mills, so sub-cent costs are representable) +- a `transaction_id` (idempotency key, 34-day dedup window) + +The `transaction_id` is the end-to-end idempotency key for the call. +Metronome dedups; replays are safe. + +### Webhook projector + +HMAC-SHA256 verification on the inbound webhook (over `Date\n`), +then DB-backed dedup on event id, then dispatch by event type. Each +event type reconciles a different slice of the credit account: + +| Event | Reconciles | +| ---------------------------------------------- | --------------------------------------------------------------------- | +| `commit.create` | balance after a new commit lands | +| `payment_gate.threshold_reached` | recharge starting (informational) | +| `payment_gate.payment_status` (paid) | recharge succeeded; reconcile balance + entitlement | +| `payment_gate.payment_status` (failed) | recharge declined; auto-reload has been disabled; entitlement off | +| `payment_gate.payment_pending_action_required` | 3DS / SCA needed; surface to the user | +| `alerts.low_remaining_*` | balance crossed an alert threshold; flip entitlement off when at zero | + +The projector is idempotent by design: the dedup row on event id is +the lock. + +### Live state reconciler + +After any contract mutation, the pattern is: + +1. Write to Metronome (e.g. `contract.edit` adding a commit) +2. Read the contract back from Metronome +3. Derive cached balance / entitlement / auto-reload state from the + live response +4. Write the derived state to the credit account + +This guarantees the DB is never ahead of Metronome. If step 2 fails, +the cache is left stale and the next webhook (or a manual refresh) +will reconcile it. + +### Drift detector + +Pairs the cached state against a fresh live read. If they disagree +beyond a small tolerance, log a "drift" record. The only way for +cache and live to disagree is a dropped webhook (or a webhook that +arrived but failed to project). Surfacing drift is how we discover +the upstream layer that broke; refreshing from live always fixes the +cache. + +--- + +## User journey + +### 1. First sign-in + +Org is provisioned in our system. No Metronome Customer or Contract +yet. Credit account row exists with empty linkage IDs and entitled = +false. + +### 2. First top-up + +User links a Stripe payment method. Then they click "Top up $X": + +- Provisioner runs (match-or-create Metronome Customer + Contract, + ensure Stripe billing provider configuration on the Customer) +- Stripe-charged commit is added to the contract +- Metronome charges Stripe synchronously +- On success, the commit lands; balance becomes $X +- `payment_gate.payment_status` (paid) webhook arrives; projector + flips entitlement on, refreshes cached balance +- Live state reconciler also runs as part of the top-up call, so the + UI does not have to wait for the webhook + +### 3. Steady-state usage + +For each AI call: + +- AI seam reads gate primitive (sub-100ms local read) +- If entitled: provider is called +- On success: cost is ingested to Metronome with a fresh + `transaction_id` +- Metronome aggregates usage events asynchronously and drains + commits per the priority scheme + +The gate never calls Metronome on the hot path. + +### 4. Auto-reload kicks in + +- Balance drops below threshold +- Metronome's threshold detector flips the threshold state (within + ~3 minutes of the breach-causing ingest, see Gotchas) +- `payment_gate.threshold_reached` webhook arrives (informational) +- Metronome charges Stripe; takes ~2 seconds end-to-end +- New TOPUP-priority commit lands +- `payment_gate.payment_status` (paid) webhook arrives; projector + refreshes cache +- Entitlement remains on; balance is back at `recharge_to_amount` + +### 5. Payment failure path + +- Stripe declines the auto-recharge +- Metronome **automatically disables** the threshold configuration + (the contract's `is_enabled` field is set to `false`) +- Metronome does **not** retry failed payments +- `payment_gate.payment_status` (failed) webhook arrives; projector + flips entitlement off +- User sees the "AI blocked" state with a CTA +- Recovery is manual: user updates payment method, then we re-enable + the threshold configuration on the contract + +### 6. Voluntary refund + +- Compute the consumed portion of the original commit +- Issue a Stripe refund for the unused portion +- Edit the commit's `access_schedule.amount` down to the consumed + portion (Metronome's documented refund pattern; Metronome does not + own refunds) +- Webhook + reconciler refresh the cache; balance now reflects the + refund + +--- + +## Documented gotchas + +### Three-minute evaluation cadence, five-minute outer bound + +Metronome's threshold detector evaluates **at least every three minutes**; +alerts fire **within five minutes** of the breach-causing ingest. This is +documented behavior. From [Metronome — Create and manage alerts](https://docs.metronome.com/manage-product-access/create-manage-alerts/): + +> "Notifications will fire within 5 minutes of the usage triggering the +> breach of the threshold being ingested." + +> "The threshold is evaluated at least once every three minutes." + +This cadence is the dominant latency in the recharge cycle. + +### Auto-recharge is "asynchronous" with no SLA + +From [Metronome — Launch a prepaid credits business model](https://docs.metronome.com/launch-guides/prepaid-credits/): + +> "Metronome handles all of the recharge actions asynchronously based +> on customer usage." + +There is no published SLA on how fast a recharge completes. Empirically +it completes within the documented 5-minute outer bound, but the system +must be designed assuming a recharge gap exists. + +### Decomposition of the recharge wait (real measurements) + +Measured from our sandbox during spike testing: + +| Stage | Observed time | +| ------------------------------------------------------- | --------------- | +| Metronome eval pipeline (T0 ingest → T1 charge created) | ~3 min | +| Stripe processing + Metronome's post-charge webhook | ~2 sec | +| Our projector + DB write | <100 ms | +| **Total observed** | **~3 min 12 s** | + +Well within the 5-minute documented bound. Metronome's eval cadence is +~99% of the wall-clock wait; Stripe and our layer are noise. + +### The "empty window" + +When burn rate exceeds the eval cadence, balance can hit $0 before the +recharge lands. The gate primitive refuses calls during this window; +the recharge eventually lands; the system self-heals. + +This is **expected behavior**, not a bug. The prepaid-credits cookbook +explicitly accepts that customers can hit zero — the cookbook does not +commit to "balance won't burn during the recharge window." The +designed response is the gate refusing during the gap. + +### Empty window is unlikely in production + +Real AI burn is fractions of a cent per call, far below the eval +cadence. The empty window is observable during synthetic / test +traffic or true burst traffic, not during normal usage. Setting +`threshold_amount` substantially above peak burn-per-3-minutes is +sufficient to make the empty window practically unreachable. + +### Payment failure auto-disables the threshold config + +From [Metronome — Set prepaid balance thresholds](https://docs.metronome.com/guides/customers-billing/optimize-customer-experience/prepaid-balance-thresholds): + +> "the contract's `is_enabled` field is set to `false`" + +> "Metronome does not automatically retry failed payments." + +Recovery is manual: customer updates card, we re-enable the threshold +configuration. This is by design — Metronome does not want to retry a +declined card on a 3-minute loop. + +### Three webhook events for the recharge cycle, not two + +Production must handle all three: + +- `payment_gate.threshold_reached` — recharge is starting +- `payment_gate.payment_status` — paid or failed +- `payment_gate.payment_pending_action_required` — 3DS / SCA + authentication needed + +Skipping the third leaves SCA-required users stuck. + +### Drift between cache and live = dropped webhook + +The only way for the credit account cache to disagree with Metronome +is a dropped webhook (network failure, projector error, dedup row +inserted but state not applied). The drift detector exists to surface +exactly this. A refresh-from-live always reconciles. Monitor drift +incidence as a proxy for webhook delivery health. + +### Stripe is fast enough to be irrelevant + +Stripe processing + Metronome's post-charge webhook is consistently +~2 seconds, dominated entirely by Metronome's eval cadence. Tuning +Stripe-side latency is not where engineering effort goes. + +### Streaming billable metrics exist for sub-second eval + +Metronome offers streaming billable metrics with sub-second +evaluation, but they are not part of the prepaid-credits cookbook and +we are not using them. They use simpler aggregations (COUNT, SUM, MAX) +and would require a metric definition refactor. Defer until +call-cost-per-eval-window genuinely justifies it. + +### What we are NOT relying on + +The prepaid-credits cookbook does **not** commit to "balance will not +burn during the recharge window." The cookbook explicitly accepts that +customers can hit zero; the design response is the gate primitive +refusing during the gap. The next reader should understand that the +recharge gap is by-design, not a bug to fix. + +--- + +## Cookbook compliance + +Comparison against Metronome's [prepaid-credits launch guide](https://docs.metronome.com/launch-guides/prepaid-credits/): + +| Cookbook item | Status | +| ----------------------------------------------------- | -------------------------------------------------------------- | +| Cache `customer_entitled` in your DB | done | +| Pattern: `if (entitled) { do_action(); ingest() }` | done | +| Set $0-balance alert per customer | partial — exists but not auto-provisioned at customer creation | +| Webhook flips entitlement off when alert fires | done | +| Auto-recharge flips entitlement back on | done | +| Handle `payment_gate.payment_status: failed` | not yet | +| Handle `payment_gate.payment_pending_action_required` | not yet | + +Three known divergences from cookbook (each is a tracked gap, not a +deliberate deviation): + +- Failed-recharge handling is not yet wired +- 3DS / SCA event handling is not yet wired +- $0-balance alert is not auto-attached during provisioning + +--- + +## References + +External documentation (treat as canonical as long as Metronome +remains the billing engine): + +- [Metronome — Launch a prepaid credits business model](https://docs.metronome.com/launch-guides/prepaid-credits/) +- [Metronome — Set prepaid balance thresholds](https://docs.metronome.com/guides/customers-billing/optimize-customer-experience/prepaid-balance-thresholds) +- [Metronome — Create and manage alerts](https://docs.metronome.com/manage-product-access/create-manage-alerts/) +- [Metronome — Create billable metrics](https://docs.metronome.com/connect-metronome/create-billable-metrics/) +- [Metronome — Send usage events](https://docs.metronome.com/connect-metronome/send-usage-events/) + +Companion documents in this WG: + +- [AI Credits — Master Plan](./ai-credits.md) — the product feature + built on top of this substrate; maps the building blocks above to + current implementation files. + +--- + +## Longevity statement + +This document is expected to remain valid across: + +- Service module rewrites +- Schema iterations on the credit account +- Webhook projector refactors +- Agent turnover + +As long as: + +- Metronome remains the credit / contract / threshold engine +- Stripe remains the money-movement engine +- The drain priority scheme (PROMO → TOPUP) holds + +If any of those change, this document must be revisited. diff --git a/editor/.env.example b/editor/.env.example index bc5b9a625..9f7726bec 100644 --- a/editor/.env.example +++ b/editor/.env.example @@ -85,12 +85,17 @@ INTEGRATIONS_TEST_TOSSPAYMENTS_SECRET_KEY="test_sk_6BYq7GWPVvyJpbAv24nw3NE5vbo1" # ============================================================================ -# Billing — Stripe +# Billing — Stripe + Metronome # ============================================================================ -# Stripe is the system of record for money (payment intents, charges, refunds, -# subscription state). Webhooks land at /webhooks/stripe — see /SECURITY.md +# Grida Billing is split: +# - Stripe → payments (charges, refunds, subscriptions, customer portal) +# - Metronome → metering, credit grants, FIFO/expiration, invoice line items +# Stripe is the system of record for money. Metronome is the system of record +# for credit balance. Our DB is the gate cache. +# Webhooks for both land at /webhooks/ — see /SECURITY.md # (GRIDA-SEC-001) for the trust boundary. +# Stripe # Use a test key (sk_test_...) when BILLING_TEST_MODE=true. STRIPE_SECRET_KEY="sk_test_..." # Signing secret for the /webhooks/stripe endpoint. @@ -99,4 +104,18 @@ STRIPE_WEBHOOK_SECRET="whsec_..." # Set to "true" in dev/test. Refuses to run if STRIPE_SECRET_KEY is a live key. BILLING_TEST_MODE="true" # Set to "1" to opt into the live billing E2E suite. Off by default. -BILLING_E2E="0" \ No newline at end of file +BILLING_E2E="0" + +# Metronome +# Bearer token for the Metronome REST API (server-only secret). +# Sandbox tokens start with `mt_test_...`; live tokens with `mt_live_...`. +METRONOME_API_TOKEN="..." +# Signing secret for Metronome webhooks (balance updates, invoice events). +METRONOME_WEBHOOK_SECRET="..." +# Optional override; leave unset to use the SDK default (sandbox vs live picked +# from the token prefix). +# METRONOME_API_BASE_URL="https://api.metronome.com/v1" + +# Cloudflared named tunnel hostname for receiving Metronome webhooks locally. +# See docs/contributing/billing.md "Metronome webhooks" for one-time setup. +WEBHOOK_TUNNEL_HOSTNAME="metronome-dev.example.com" \ No newline at end of file From fe525697317835f32289f41173ccc29f05bcb4af Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 11 May 2026 04:29:45 +0900 Subject: [PATCH 10/21] refactor(docs): group billing WG docs under wg/platform/billing/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three flat docs under wg/platform/ are now grouped: wg/platform/ai-credits.md → wg/platform/billing/ai-credits.md wg/platform/billing-known-issues.md → wg/platform/billing/known-issues.md wg/platform/metronome.md → wg/platform/billing/metronome.md Plus a billing/index.md and _category_.json so Docusaurus renders the group as a labeled section. wg/platform/index.md links to the new section. Inside known-issues.md, the redundant `billing-` prefix is dropped since the directory now provides the namespace. All external references updated to the new paths: docs/contributing/billing.md, service module + fees comment headers, the consolidated migration, _actions.ts, the Stripe webhook receiver, and the scripts CLI README. Verified zero stale references via grep across docs/, editor/, supabase/. --- docs/contributing/billing.md | 2 +- docs/wg/platform/billing/_category_.json | 7 +++++ docs/wg/platform/{ => billing}/ai-credits.md | 2 +- docs/wg/platform/billing/index.md | 29 +++++++++++++++++++ .../known-issues.md} | 4 +-- docs/wg/platform/{ => billing}/metronome.md | 0 docs/wg/platform/index.md | 1 + editor/app/(ingest)/webhooks/stripe/route.ts | 2 +- .../settings/billing/_actions.ts | 10 +++---- editor/lib/billing/fees.ts | 2 +- editor/lib/billing/metronome.ts | 4 +-- editor/scripts/billing/README.md | 2 +- ...20260508130000_grida_billing_metronome.sql | 2 +- 13 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 docs/wg/platform/billing/_category_.json rename docs/wg/platform/{ => billing}/ai-credits.md (99%) create mode 100644 docs/wg/platform/billing/index.md rename docs/wg/platform/{billing-known-issues.md => billing/known-issues.md} (98%) rename docs/wg/platform/{ => billing}/metronome.md (100%) diff --git a/docs/contributing/billing.md b/docs/contributing/billing.md index 7f9e00ac4..dd105f33a 100644 --- a/docs/contributing/billing.md +++ b/docs/contributing/billing.md @@ -148,7 +148,7 @@ See the suite's own README for the contract. - **Service module**: `editor/lib/billing/metronome.ts` — `provisionOrg`, `addStripeChargedCommit`, `setAutoReload`, `getEntitlement`, `ingestUsageEvent`. - **`grida_billing.account.provisioning_uid`**: per-account UUID composed into Metronome aliases. `supabase db reset` produces fresh aliases — any orphan Metronome customers from previous instances are inert. No manual cleanup needed. -User-facing billing copy: [`docs/platform/billing.mdx`](../platform/billing.mdx). Design notes: [`docs/wg/platform/ai-credits.md`](../wg/platform/ai-credits.md), [`docs/wg/platform/metronome.md`](../wg/platform/metronome.md). Known issues: [`docs/wg/platform/billing-known-issues.md`](../wg/platform/billing-known-issues.md). CLI guide: [`editor/scripts/billing/README.md`](../../editor/scripts/billing/README.md). +User-facing billing copy: [`docs/platform/billing.mdx`](../platform/billing.mdx). Design notes: [`docs/wg/platform/billing/`](../wg/platform/billing/) (AI credits master plan, Metronome integration, known issues). CLI guide: [`editor/scripts/billing/README.md`](../../editor/scripts/billing/README.md). --- diff --git a/docs/wg/platform/billing/_category_.json b/docs/wg/platform/billing/_category_.json new file mode 100644 index 000000000..b8a16b923 --- /dev/null +++ b/docs/wg/platform/billing/_category_.json @@ -0,0 +1,7 @@ +{ + "label": "Billing", + "link": { + "type": "doc", + "id": "wg/platform/billing/index" + } +} diff --git a/docs/wg/platform/ai-credits.md b/docs/wg/platform/billing/ai-credits.md similarity index 99% rename from docs/wg/platform/ai-credits.md rename to docs/wg/platform/billing/ai-credits.md index a19ec719b..8d8170ea9 100644 --- a/docs/wg/platform/ai-credits.md +++ b/docs/wg/platform/billing/ai-credits.md @@ -75,7 +75,7 @@ changes. target $25–$500 (whole dollars). Recharge target must exceed threshold. Same markup formula applies on the _initial_ setup Checkout. **Silent recharges thereafter run at-cost** — see - [billing-known-issues.md KI-BILL-001](./billing-known-issues.md#ki-bill-001--silent-auto-recharge-runs-at-cost-markup-gap) + [KI-BILL-001](./known-issues.md#ki-bill-001--silent-auto-recharge-runs-at-cost-markup-gap) for cause + planned fix. v1 mitigation: **auto-reload is gated behind an active paid subscription** so the loss is bounded by base-plan margin. Manual top-up is unaffected and remains free-tier accessible. diff --git a/docs/wg/platform/billing/index.md b/docs/wg/platform/billing/index.md new file mode 100644 index 000000000..5684cd059 --- /dev/null +++ b/docs/wg/platform/billing/index.md @@ -0,0 +1,29 @@ +--- +title: Billing (WG) +tags: + - internal + - wg + - platform + - billing +--- + +# Billing (WG) + +Working group documents for the Grida billing surface (subscriptions, +AI credit, Stripe ↔ Metronome sync). + +## Documents + +- [AI Credits — Master Plan](./ai-credits) — design notes for the + Metronome-backed prepaid credit system, top-up + auto-reload flows, + drain order, refund pattern, gate primitive. +- [Metronome integration](./metronome) — the integration playbook: + substrate setup, payment-gate config, webhook event taxonomy. +- [Known issues](./known-issues) — living register of mitigated / + accepted issues across the billing surface (`KI-BILL-NNN`). + +## See also + +- [Contributor setup](../../../contributing/billing) — local dev (Stripe + - Metronome sandbox + cloudflared tunnel). +- User-facing billing copy: [docs/platform/billing.mdx](../../../platform/billing.mdx). diff --git a/docs/wg/platform/billing-known-issues.md b/docs/wg/platform/billing/known-issues.md similarity index 98% rename from docs/wg/platform/billing-known-issues.md rename to docs/wg/platform/billing/known-issues.md index 2b150e4c2..078dc54a3 100644 --- a/docs/wg/platform/billing-known-issues.md +++ b/docs/wg/platform/billing/known-issues.md @@ -101,8 +101,8 @@ we can cap at the product layer in five lines. threshold-config call (the at-cost path). - `editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts > assertAutoReloadAllowed` — subscription gate. -- `docs/wg/platform/ai-credits.md` "Auto-reload envelope" — references - this entry. +- `docs/wg/platform/billing/ai-credits.md` "Auto-reload envelope" — + references this entry. --- diff --git a/docs/wg/platform/metronome.md b/docs/wg/platform/billing/metronome.md similarity index 100% rename from docs/wg/platform/metronome.md rename to docs/wg/platform/billing/metronome.md diff --git a/docs/wg/platform/index.md b/docs/wg/platform/index.md index 22fcd3f68..4ba920a15 100644 --- a/docs/wg/platform/index.md +++ b/docs/wg/platform/index.md @@ -12,5 +12,6 @@ Working group documents for Grida platform and infrastructure topics. ## Documents +- [Billing](./billing) — subscriptions, AI credit, Stripe ↔ Metronome sync. - [Multi-tenant Custom Domains on Vercel](./multi-tenant-custom-domain-vercel) - [Universal Docs Routing](./universal-docs-routing) diff --git a/editor/app/(ingest)/webhooks/stripe/route.ts b/editor/app/(ingest)/webhooks/stripe/route.ts index 49823abe5..f3874922b 100644 --- a/editor/app/(ingest)/webhooks/stripe/route.ts +++ b/editor/app/(ingest)/webhooks/stripe/route.ts @@ -136,7 +136,7 @@ export async function POST(req: NextRequest) { // Subscription cancel → disable Metronome auto-reload. // // Auto-reload is gated behind an active paid subscription - // (KI-BILL-001 mitigation in `docs/wg/platform/billing-known-issues.md`). + // (KI-BILL-001 mitigation in `docs/wg/platform/billing/known-issues.md`). // When the subscription cancels, leaving auto-reload enabled means the // org keeps eating silent-recharge cost forever — exactly what the gate // was meant to prevent. Best-effort: log on failure but don't fail the diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts b/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts index bffc81fe4..52c2583c6 100644 --- a/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts @@ -738,7 +738,7 @@ export type AiCreditsSummary = { /** True when local cache disagrees with the live read. */ drifted: boolean; /** True when the org has an active paid (Pro/Team) subscription. Drives the - * auto-reload gate — see docs/wg/platform/billing-known-issues.md "Auto-reload + * auto-reload gate — see docs/wg/platform/billing/known-issues.md "Auto-reload * markup gap". Manual top-up is always available. */ has_active_subscription: boolean; }; @@ -884,7 +884,7 @@ export async function setAiAutoReload( * surface to a population whose base-plan margin already covers it. * * Manual top-up does NOT need this gate — it always goes through Checkout - * and pays the full markup. See docs/wg/platform/billing-known-issues.md. + * and pays the full markup. See docs/wg/platform/billing/known-issues.md. */ async function assertAutoReloadAllowed(org_id: number): Promise { const sub = await getActivePaidSubscription(org_id); @@ -913,7 +913,7 @@ export async function disableAiAutoReload( // --------------------------------------------------------------------------- // Checkout-based authorization flows (every NEW commitment goes through -// Stripe Checkout — see docs/wg/platform/metronome.md "card authorization"). +// Stripe Checkout — see docs/wg/platform/billing/metronome.md "card authorization"). // // Why: Stripe doesn't expose a perfect "PM ready for off-session?" signal, // and any cached PM can fail tomorrow (expiry, dispute, SCA invalidation). @@ -961,7 +961,7 @@ export async function startTopUpCheckout( // Pass through Stripe's processing fee — user pays $X plus the fee, // receives exactly $X of credit. See lib/billing/fees.ts and - // docs/wg/platform/ai-credits.md "Money model". + // docs/wg/platform/billing/ai-credits.md "Money model". const totalCents = totalChargeForCredit(params.cents); const idempotencyKey = `ai_topup:${org_id}:${params.cents}:${Math.floor(Date.now() / 60000)}`; @@ -1088,7 +1088,7 @@ export async function startEnableAutoReloadCheckout( // Checkout). Subsequent silent recharges via Metronome's // prepaid_balance_threshold_configuration run at-cost. v1 mitigation // is the subscription gate above; full fix is tracked as KI-BILL-001 - // in docs/wg/platform/billing-known-issues.md. + // in docs/wg/platform/billing/known-issues.md. const totalCents = totalChargeForCredit(params.recharge_to_cents); const idempotencyKey = `ai_auto_reload:${org_id}:${params.threshold_cents}:${params.recharge_to_cents}:${Math.floor(Date.now() / 60000)}`; diff --git a/editor/lib/billing/fees.ts b/editor/lib/billing/fees.ts index a94e1e78f..03c37655b 100644 --- a/editor/lib/billing/fees.ts +++ b/editor/lib/billing/fees.ts @@ -1,7 +1,7 @@ // Pass-through markup on AI-credit top-ups. // // Why: -// The AI-credits design (docs/wg/platform/ai-credits.md) commits to +// The AI-credits design (docs/wg/platform/billing/ai-credits.md) commits to // "AI is sold at cost." For the credit balance to actually be at-cost // we need to pass payment-processing costs through to the customer — // otherwise we lose money on every top-up. Grida is not a payment diff --git a/editor/lib/billing/metronome.ts b/editor/lib/billing/metronome.ts index cef079946..593de97ae 100644 --- a/editor/lib/billing/metronome.ts +++ b/editor/lib/billing/metronome.ts @@ -8,7 +8,7 @@ // All service functions take `organizationId: number` and persist Metronome- // side ids on `grida_billing.account`. Idempotent end-to-end. // -// Architectural rationale: docs/wg/platform/ai-credits.md. +// Architectural rationale: docs/wg/platform/billing/ai-credits.md. import * as crypto from "node:crypto"; import Metronome from "@metronome/sdk"; @@ -159,7 +159,7 @@ export async function getSubstrate(): Promise { } // --------------------------------------------------------------------------- -// drain-order priorities (see docs/wg/platform/ai-credits.md) +// drain-order priorities (see docs/wg/platform/billing/ai-credits.md) // --------------------------------------------------------------------------- export const COMMIT_PRIORITY = { diff --git a/editor/scripts/billing/README.md b/editor/scripts/billing/README.md index 508bf6424..d4f50fd77 100644 --- a/editor/scripts/billing/README.md +++ b/editor/scripts/billing/README.md @@ -5,7 +5,7 @@ your **sandbox** Stripe + Metronome accounts. > Setup guide (env, tunnel, accounts) lives in > [`docs/contributing/billing.md`](../../../docs/contributing/billing.md). -> Design notes: [`docs/wg/platform/ai-credits.md`](../../../docs/wg/platform/ai-credits.md). +> Design notes: [`docs/wg/platform/billing/ai-credits.md`](../../../docs/wg/platform/billing/ai-credits.md). ## Usage diff --git a/supabase/migrations/20260508130000_grida_billing_metronome.sql b/supabase/migrations/20260508130000_grida_billing_metronome.sql index 2f32c1a21..af51e6fae 100644 --- a/supabase/migrations/20260508130000_grida_billing_metronome.sql +++ b/supabase/migrations/20260508130000_grida_billing_metronome.sql @@ -15,7 +15,7 @@ -- org by metronome_customer_id, list recent webhook events, and -- atomically debit the balance cache after a successful ingest. -- --- See docs/wg/platform/ai-credits.md for the architectural rationale. +-- See docs/wg/platform/billing/ai-credits.md for the architectural rationale. BEGIN; From 36e98cb4c6ddb1eafe90ba6f8f4a14760a0e4889 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 11 May 2026 04:30:04 +0900 Subject: [PATCH 11/21] docs(wg/billing): fix bullet rendering in index.md The formatter parsed "(Stripe + Metronome ..." as a sub-bullet because of the leading hyphen. Reword to a single-bullet phrase. --- docs/wg/platform/billing/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/wg/platform/billing/index.md b/docs/wg/platform/billing/index.md index 5684cd059..942998ac0 100644 --- a/docs/wg/platform/billing/index.md +++ b/docs/wg/platform/billing/index.md @@ -24,6 +24,6 @@ AI credit, Stripe ↔ Metronome sync). ## See also -- [Contributor setup](../../../contributing/billing) — local dev (Stripe - - Metronome sandbox + cloudflared tunnel). +- [Contributor setup](../../../contributing/billing) — local dev with + Stripe + Metronome sandbox accounts and a cloudflared tunnel. - User-facing billing copy: [docs/platform/billing.mdx](../../../platform/billing.mdx). From 2a603fc1fb365a90939474884cd06a73a1db9152 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 11 May 2026 11:55:02 +0900 Subject: [PATCH 12/21] docs(wg/billing): escape `<100 ms` to fix MDX build --- docs/wg/platform/billing/metronome.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/wg/platform/billing/metronome.md b/docs/wg/platform/billing/metronome.md index 26d970f4e..a2e31daf1 100644 --- a/docs/wg/platform/billing/metronome.md +++ b/docs/wg/platform/billing/metronome.md @@ -364,7 +364,7 @@ Measured from our sandbox during spike testing: | ------------------------------------------------------- | --------------- | | Metronome eval pipeline (T0 ingest → T1 charge created) | ~3 min | | Stripe processing + Metronome's post-charge webhook | ~2 sec | -| Our projector + DB write | <100 ms | +| Our projector + DB write | sub-100 ms | | **Total observed** | **~3 min 12 s** | Well within the 5-minute documented bound. Metronome's eval cadence is From 863c0ef09559058227657a25bf0250bd9dac4702 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 11 May 2026 12:39:12 +0900 Subject: [PATCH 13/21] docs(billing): add known issue for plan-included credit not granted --- docs/wg/platform/billing/known-issues.md | 37 +++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/wg/platform/billing/known-issues.md b/docs/wg/platform/billing/known-issues.md index 078dc54a3..f76bd85d3 100644 --- a/docs/wg/platform/billing/known-issues.md +++ b/docs/wg/platform/billing/known-issues.md @@ -18,10 +18,11 @@ status: living > move them to the **Resolved** section with the PR / commit that closed > them. The history is the audit trail for "why did we do it that way." -| ID | Area | Severity | Status | -| ----------- | ----------------------- | -------- | --------- | -| KI-BILL-001 | AI credit · auto-reload | Medium | Mitigated | -| KI-BILL-002 | Subscriptions | Low | Accepted | +| ID | Area | Severity | Status | +| ----------- | -------------------------- | -------- | --------- | +| KI-BILL-001 | AI credit · auto-reload | Medium | Mitigated | +| KI-BILL-002 | Subscriptions | Low | Accepted | +| KI-BILL-003 | Subscriptions · plan grant | Medium | Accepted | --- @@ -138,6 +139,34 @@ manual onboarding; not worth the schema work yet. --- +## KI-BILL-003 — Plan-included credit not granted + +**Area.** Subscriptions · plan-included AI credit. + +**Cause.** [`marketing-plans.ts`](../../../../editor/lib/billing/marketing-plans.ts) +promises "$10 included" on paid plans; no grant mechanism exists. +Metronome's native `recurring_credits` requires Metronome contracts, but +v1 is Stripe-first (plan = Stripe Price, not rate-card scheduled charge). + +**Current behavior.** Paid orgs see the promise, get $0. Must top up +to use AI — gate behaves same as free. + +**Planned fix — Path B (Metronome-first contracts).** Move plan fee to +Metronome `scheduled_charge` + `recurring_credits`; Stripe becomes pure +payment rail. ~4 engineer-days, ~1.2k LOC added / ~400 deleted, 1 +migration, new `metronome-contracts.ts` service + `webhooks/metronome` +receiver, drop `customer.subscription.deleted` handler, migration script +cancels existing Stripe subs at period end. Adds `COMMIT_PRIORITY.PLAN_GRANT = 10`. + +Path A (manual grant on `subscription.created`) rejected — recreates +Metronome-native primitive in app code. + +**Why deferred.** Migration cost is constant whether done today or with +seat-based subs later; no debt accrues at the contract layer. Re-enters +scope with seat-based pricing. + +--- + ## Resolved _(none yet)_ From c34b205b2b1262fe24f35cb77f40c06fe994b802 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 12 May 2026 00:12:01 +0900 Subject: [PATCH 14/21] =?UTF-8?q?feat(security):=20GRIDA-SEC-003=20?= =?UTF-8?q?=E2=80=94=20verified=20org-id=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a single producer of verified `organizationId` for AI-seam callers. `requireOrganizationId` walks route slug → header → explicit input → session-derived org, returning only after membership is established (RPC-gated for the session path, `assertOrgMember` for the others). The session resolver intersects `user_project_access_state` (UX preference) with `public.get_organizations_for_user` (the SECURITY DEFINER membership primitive), so a stale access-state row left over after a membership revocation can never escalate into another org's billing context. Document the boundary in SECURITY.md as GRIDA-SEC-003 — what it protects, the prevented IDOR scenario, the producer/contract/single- entry-point design, and the files bound by the id. --- SECURITY.md | 69 +++++++++++ editor/lib/auth/organization.ts | 212 ++++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 editor/lib/auth/organization.ts diff --git a/SECURITY.md b/SECURITY.md index 48d836848..ee9d11acf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -183,6 +183,75 @@ harness, move it to `(site)/...` (with proper auth) or `(api)/...` --- +### `GRIDA-SEC-003` — AI seam org-id trust boundary + +**What it protects.** Every call into the AI provider SDKs (Vercel AI +SDK, Replicate, OpenAI, Anthropic) is gated and billed against an +`organizationId`. If that id reaches the seam unverified, an attacker +who can choose the id drains another org's credit balance. The +boundary is the rule that **every `organizationId` reaching +`editor/lib/ai/server.ts` has been verified as a member-org for the +calling user.** + +**Vulnerable scenario (prevented).** A developer adds a new AI route +handler that reads `organizationId` from the request body and forwards +it straight into the seam. An attacker enumerates `organization_id` +(sequential bigint) and submits requests with `organizationId = +`. Each request bills the victim's balance, eventually flips +their `customer_entitled = false`, and locks them out of AI until +they top up. Worse, the attacker's free-tier user enjoys the victim's +credit for as long as it lasts. Mass automation makes this an +asymmetric DoS-by-billing attack. + +**Why it's specifically risky here.** AI route handlers and server +actions sit on internal/private surfaces, but they are still HTTP +endpoints reachable by any authenticated user. Org membership is +checked by RLS on data reads, **not** on AI-seam writes — the seam +calls Metronome (an external service), not our own DB, so no RLS +gate fires. Without a structural producer-side rule, every new AI +endpoint is a fresh chance to forget the membership check. + +**How the code prevents it.** + +1. **One verified producer** — + [editor/lib/auth/organization.ts](editor/lib/auth/organization.ts) + exports `requireOrganizationId({ user_id, request, routeParams, +inputOrgId })`. It resolves from: route param slug → request + header `X-Grida-Organization-Id` → explicit input. Every resolved + id is verified via `assertOrgMember(user_id, org_id)` before + return. No "current org" is read from session blob / cookie. +2. **Runtime contract in the seam** — + [editor/lib/ai/\_seam/core.ts](editor/lib/ai/_seam/core.ts) + `withTransaction` (and the AI SDK middleware that wraps it) throw + `MissingOrgIdError` if `organizationId` is missing, non-integer, + or non-positive. Defense-in-depth — a caller that forgets to + verify still cannot pass a garbage value. +3. **Single seam entry point** — + [editor/lib/ai/server.ts](editor/lib/ai/server.ts) is the ONLY + file allowed to import `replicate`, `openai`, `@ai-sdk/*`, + `@anthropic-ai/sdk`. Enforced by oxlint + `no-restricted-imports` ([editor/.oxlintrc.jsonc](editor/.oxlintrc.jsonc)) + and the CI audit script + ([editor/scripts/audit-ai-seam.ts](editor/scripts/audit-ai-seam.ts)). + A new file that bypasses the seam fails at lint or CI. + +**Files bound by this id.** Run `grep -rn GRIDA-SEC-003 .` to enumerate. +Today: + +- [editor/lib/auth/organization.ts](editor/lib/auth/organization.ts) — `requireOrganizationId`. +- [editor/lib/ai/server.ts](editor/lib/ai/server.ts) — single seam entry. +- [editor/lib/ai/\_seam/core.ts](editor/lib/ai/_seam/core.ts) — runtime gate. +- [editor/.oxlintrc.jsonc](editor/.oxlintrc.jsonc) — import lint rule. +- [editor/scripts/audit-ai-seam.ts](editor/scripts/audit-ai-seam.ts) — CI audit. + +**What does NOT belong here.** Reading `organizationId` directly off a +request body in any AI-adjacent code. Even if you think you "trust" +the body — Next.js server-action hashes ship in the client bundle and +become public the moment they're shipped. Always go through +`requireOrganizationId`. + +--- + ## Adding a new GRIDA-SEC entry 1. Allocate the next sequential id (`GRIDA-SEC-003` for the next one). diff --git a/editor/lib/auth/organization.ts b/editor/lib/auth/organization.ts new file mode 100644 index 000000000..3f7cce224 --- /dev/null +++ b/editor/lib/auth/organization.ts @@ -0,0 +1,212 @@ +/** + * `GRIDA-SEC-003` — see [SECURITY.md](../../../SECURITY.md). + * + * Resolves an `organizationId` with verified membership for use by the + * AI seam (and any other org-scoped server code). Threading an org id + * into the seam is itself a security boundary: an attacker who can + * choose the id drains another org's credit. The seam refuses to + * figure out the org; the caller either produces a verified id here, or + * falls back to the user's session-resolved org (last-accessed project's + * org → first membership) — same priority as the dashboard route. + * + * Priority — first source that yields an id wins: + * 1. Route-param slug (`[organization_name]` / `[org]`) + * 2. `X-Grida-Organization-Id` request header + * 3. Explicit `inputOrgId` (server-action input field) + * 4. Session — org of the user's last-accessed project, or first + * membership row (cross-device since `user_project_access_state` is + * DB-backed) + */ + +import type { NextRequest } from "next/server"; +import { service_role } from "@/lib/supabase/server"; +import { assertOrgMember, BillingError } from "@/lib/billing"; + +const ORG_ID_HEADER = "x-grida-organization-id"; + +export type RequireOrganizationIdOptions = { + /** Authenticated user id. Required (call after `auth.getUser()`). */ + user_id: string; + /** Incoming request — used to read the `X-Grida-Organization-Id` header. */ + request?: NextRequest | Request; + /** Resolved route params for the current handler. */ + routeParams?: { organization_name?: string; org?: string }; + /** Explicit org id supplied by the caller (server-action input). */ + inputOrgId?: number | string | null; +}; + +/** + * Resolve an organization id with verified membership. Throws + * {@link BillingError} on any failure — codes: + * - `unauthorized` (401) — empty `user_id` + * - `missing_organization_id` (400) — no source supplied an id + * - `org_not_found` (404) — slug doesn't resolve to a member-org + * - `invalid_header` / `invalid_input` (400) — malformed values + * - `not_member` (403) — id resolved but user has no membership + */ +export async function requireOrganizationId( + opts: RequireOrganizationIdOptions +): Promise { + if (!opts.user_id) { + throw new BillingError( + "requireOrganizationId: missing user_id", + "unauthorized", + 401 + ); + } + + const slug = opts.routeParams?.organization_name ?? opts.routeParams?.org; + if (slug) { + // Single JOIN: returns a row IFF the user is a member of the org + // with this slug. Saves one Supabase round-trip vs. resolving the + // slug then checking membership. + const id = await resolveMemberOrgIdBySlug(opts.user_id, slug); + if (id !== null) return id; + throw new BillingError( + `organization "${slug}" not found or not a member`, + "org_not_found", + 404 + ); + } + + const header = opts.request?.headers.get(ORG_ID_HEADER); + if (header) { + const id = parsePositiveInt(header); + if (id === null) { + throw new BillingError( + `invalid ${ORG_ID_HEADER} header: "${header}"`, + "invalid_header", + 400 + ); + } + await assertOrgMember(opts.user_id, id); + return id; + } + + if (opts.inputOrgId != null && opts.inputOrgId !== "") { + const id = + typeof opts.inputOrgId === "number" + ? opts.inputOrgId + : parsePositiveInt(String(opts.inputOrgId)); + if (id === null) { + throw new BillingError( + `invalid organizationId input: "${String(opts.inputOrgId)}"`, + "invalid_input", + 400 + ); + } + await assertOrgMember(opts.user_id, id); + return id; + } + + // Session fallback — last-accessed org if still a current member, + // else first current membership. Membership is established by the + // resolver itself (gated on `get_organizations_for_user`), so no + // separate `assertOrgMember` round-trip is needed. + const sessionOrgId = await resolveSessionOrganizationId(opts.user_id); + if (sessionOrgId !== null) return sessionOrgId; + + throw new BillingError( + "no organizationId resolved (route param / header / input / session all missing)", + "missing_organization_id", + 400 + ); +} + +export type SessionOrganization = { id: number; name: string }; + +/** + * Resolve the user's "current organization" for the session, mirroring + * the dashboard route's priority while making stale-membership escalation + * structurally impossible: + * + * 1. The org of the user's last-accessed project — *only if* the user + * is still a current member. `user_project_access_state` is purely + * a UX preference and is never trusted on its own. + * 2. The first org from `public.get_organizations_for_user(user_id)`. + * + * The set of "current" memberships always comes from the + * `get_organizations_for_user` RPC (`SECURITY DEFINER`, joins + * `organization_member` filtered by `user_id`). Returns `null` when the + * user has no membership at all. Cross-device: state is DB-backed so a + * new device picks up the same preference without local cookies. + */ +export async function resolveSessionOrganization( + user_id: string +): Promise { + const { data: memberOrgIds } = await service_role.workspace.rpc( + "get_organizations_for_user", + { user_id } + ); + if (!memberOrgIds || memberOrgIds.length === 0) return null; + + const { data: state } = await service_role.workspace + .from("user_project_access_state") + .select("project:project(organization_id)") + .eq("user_id", user_id) + .maybeSingle(); + + const projectRow = state?.project; + const project = Array.isArray(projectRow) ? projectRow[0] : projectRow; + const lastOrgId = + typeof project?.organization_id === "number" + ? project.organization_id + : null; + + // Honour the last-accessed preference only if it intersects current + // membership — otherwise fall back to the first current membership. + const chosenId = + lastOrgId !== null && memberOrgIds.includes(lastOrgId) + ? lastOrgId + : memberOrgIds[0]!; + + const { data: org } = await service_role.workspace + .from("organization") + .select("id, name") + .eq("id", chosenId) + .maybeSingle(); + + return org ?? null; +} + +/** Id-only convenience around {@link resolveSessionOrganization}. */ +export async function resolveSessionOrganizationId( + user_id: string +): Promise { + const org = await resolveSessionOrganization(user_id); + return org?.id ?? null; +} + +function parsePositiveInt(value: string): number | null { + const n = Number(value); + if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) return null; + return n; +} + +async function resolveMemberOrgIdBySlug( + user_id: string, + slug: string +): Promise { + // `service_role` bypasses RLS for the slug→id lookup; the inner + // `organization_member` filter on `user_id` is what establishes trust. + const { data, error } = await service_role.workspace + .from("organization_member") + .select("organization!inner(id)") + .eq("user_id", user_id) + .eq("organization.name", slug) + .limit(1) + .maybeSingle(); + + if (error) { + throw new BillingError( + `org slug lookup failed: ${error.message}`, + "org_lookup_failed", + 500 + ); + } + const org = data?.organization; + if (!org) return null; + // Supabase nested-relation types occasionally widen to array; narrow + // back to a single row since the FK is many-to-one. + return Array.isArray(org) ? (org[0]?.id ?? null) : (org.id ?? null); +} From 3837b06f2eff7ee4e808a31757f9b7b346bf4119 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 12 May 2026 00:12:52 +0900 Subject: [PATCH 15/21] refactor(ai): consolidate API routes into lib/ai server actions + credits module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI surface used to live as a fan of Next.js route handlers under app/(api)/private/ai/*, each one re-implementing auth, rate-limiting, org resolution, balance debit, and provider plumbing in slightly different ways. Consolidate the whole surface into a single seam: lib/ai/server.ts Single entry point. Wraps every AI call in `withAiAuth` (membership + entitlement + balance gate) and the only file allowed to import provider SDKs (oxlint no-restricted-imports + audit-ai-seam.ts). lib/ai/error.ts Typed AiActionError envelope shared by all actions, with discriminated codes consumers can branch on. lib/ai/actions/ Server actions replacing the deleted route handlers — chat, audio, image (edit / remove-bg / upscale), image-generate, forms-schema, models. Each goes through withAiAuth; none re-implements auth. lib/ai/credits/ New module: controller (refresh / debit / format), provider/hook (`useAiCredits`), and server actions (`preloadAiCredits`, `resolveInitialAiCredits`, `refreshAiCredits`). Replaces ad-hoc `use-credits` / `use-generate-*` hooks. Backed by tests for the controller state machine and the cents formatter. Migrate every consumer to the new actions: canvas tools (ai/generate, use-models), playground/image, tools/remove-bg, ai/music + music playground, scaffolds/ai (form-field schema assistant), scaffolds/playground-forms, grida-canvas-hosted (server-agent + canvas-use tool), and the starterkit image toolbar. Supporting changes: - `editor/scripts/audit-ai-seam.ts` greps for provider-SDK imports outside lib/ai/server.ts so a future bypass fails CI. - `.oxlintrc.jsonc` adds the matching `no-restricted-imports` rule. - `vitest.config.ts` + `lib/__tests__/server-only.shim.ts` let server-only modules load under vitest without `import "server-only"` blowing up. - `lib/billing/metronome.ts` exposes the helpers the new credits controller needs (`getEntitlement`, `refreshBalance`). - `lib/ai/ai.ts` and `lib/ai/models.ts` are trimmed to the registry + pricing surfaces actually used by the new actions. - `app/(api)/private/ai/chat/route.ts` is the only remaining AI route (SSE streaming) and now delegates to `lib/ai/actions/chat.ts`. --- editor/.oxlintrc.jsonc | 56 ++ editor/app/(api)/private/ai/audio/actions.ts | 133 --- .../(api)/private/ai/audio/generate/route.ts | 44 - editor/app/(api)/private/ai/chat/route.ts | 39 +- editor/app/(api)/private/ai/credits/route.ts | 21 - .../(api)/private/ai/generate/image/route.ts | 226 ----- editor/app/(api)/private/ai/image/actions.ts | 180 ---- .../ai/image/remove-background/route.ts | 40 - .../(api)/private/ai/image/upscale/route.ts | 39 - .../(api)/private/ai/models/openai/route.ts | 11 - editor/app/(api)/private/ai/models/route.ts | 6 - editor/app/(api)/private/ai/ratelimit.ts | 65 -- .../(api)/private/editor/ai/schema/route.ts | 80 -- .../canvas/tools/ai/_hooks/use-models.ts | 12 +- .../app/(canvas)/canvas/tools/ai/generate.ts | 18 +- .../(playground)/playground/image/_page.tsx | 45 +- .../(playground)/playground/image/layout.tsx | 7 +- editor/app/(tools)/tools/remove-bg/_page.tsx | 27 +- editor/app/(www)/(ai)/ai/music/page.tsx | 7 +- .../(www)/(ai)/ai/music/playground/_page.tsx | 43 +- .../ai/agent/server-agent.ts | 28 +- .../ai/tools/canvas-use.ts | 50 +- .../starterkit-toolbar/image-toolbar.tsx | 35 +- editor/lib/__tests__/server-only.shim.ts | 3 + editor/lib/ai/__tests__/error.test.ts | 213 +++++ editor/lib/ai/__tests__/server.test.ts | 334 ++++++++ editor/lib/ai/actions/audio.ts | 66 ++ editor/lib/ai/actions/chat.ts | 144 ++++ editor/lib/ai/actions/forms-schema.ts | 98 +++ editor/lib/ai/actions/image-generate.ts | 219 +++++ editor/lib/ai/actions/image.ts | 82 ++ editor/lib/ai/actions/models.ts | 18 + editor/lib/ai/ai.ts | 236 +---- editor/lib/ai/credits/README.md | 186 ++++ .../ai/credits/__tests__/controller.test.ts | 251 ++++++ .../lib/ai/credits/__tests__/format.test.ts | 62 ++ editor/lib/ai/credits/actions.ts | 67 ++ editor/lib/ai/credits/controller.ts | 171 ++++ editor/lib/ai/credits/format.ts | 37 + editor/lib/ai/credits/index.ts | 34 + editor/lib/ai/credits/provider.tsx | 78 ++ editor/lib/ai/error.ts | 226 +++++ editor/lib/ai/hooks/index.ts | 3 - editor/lib/ai/hooks/use-credits.ts | 35 - editor/lib/ai/hooks/use-generate-audio.ts | 82 -- editor/lib/ai/hooks/use-generate-image.ts | 81 -- editor/lib/ai/models.ts | 35 +- editor/lib/ai/server.ts | 809 ++++++++++++++++++ editor/lib/billing/metronome.ts | 43 +- editor/package.json | 1 + .../ai/form-field-schema-assistant.tsx | 53 +- editor/scaffolds/playground-forms/actions.ts | 17 +- .../scaffolds/playground-forms/playground.tsx | 6 +- editor/scripts/audit-ai-seam.ts | 177 ++++ editor/vitest.config.ts | 6 + 55 files changed, 3605 insertions(+), 1480 deletions(-) delete mode 100644 editor/app/(api)/private/ai/audio/actions.ts delete mode 100644 editor/app/(api)/private/ai/audio/generate/route.ts delete mode 100644 editor/app/(api)/private/ai/credits/route.ts delete mode 100644 editor/app/(api)/private/ai/generate/image/route.ts delete mode 100644 editor/app/(api)/private/ai/image/actions.ts delete mode 100644 editor/app/(api)/private/ai/image/remove-background/route.ts delete mode 100644 editor/app/(api)/private/ai/image/upscale/route.ts delete mode 100644 editor/app/(api)/private/ai/models/openai/route.ts delete mode 100644 editor/app/(api)/private/ai/models/route.ts delete mode 100644 editor/app/(api)/private/ai/ratelimit.ts delete mode 100644 editor/app/(api)/private/editor/ai/schema/route.ts create mode 100644 editor/lib/__tests__/server-only.shim.ts create mode 100644 editor/lib/ai/__tests__/error.test.ts create mode 100644 editor/lib/ai/__tests__/server.test.ts create mode 100644 editor/lib/ai/actions/audio.ts create mode 100644 editor/lib/ai/actions/chat.ts create mode 100644 editor/lib/ai/actions/forms-schema.ts create mode 100644 editor/lib/ai/actions/image-generate.ts create mode 100644 editor/lib/ai/actions/image.ts create mode 100644 editor/lib/ai/actions/models.ts create mode 100644 editor/lib/ai/credits/README.md create mode 100644 editor/lib/ai/credits/__tests__/controller.test.ts create mode 100644 editor/lib/ai/credits/__tests__/format.test.ts create mode 100644 editor/lib/ai/credits/actions.ts create mode 100644 editor/lib/ai/credits/controller.ts create mode 100644 editor/lib/ai/credits/format.ts create mode 100644 editor/lib/ai/credits/index.ts create mode 100644 editor/lib/ai/credits/provider.tsx create mode 100644 editor/lib/ai/error.ts delete mode 100644 editor/lib/ai/hooks/use-credits.ts delete mode 100644 editor/lib/ai/hooks/use-generate-audio.ts delete mode 100644 editor/lib/ai/hooks/use-generate-image.ts create mode 100644 editor/lib/ai/server.ts create mode 100644 editor/scripts/audit-ai-seam.ts diff --git a/editor/.oxlintrc.jsonc b/editor/.oxlintrc.jsonc index ff6bfb38e..e2c8732a0 100644 --- a/editor/.oxlintrc.jsonc +++ b/editor/.oxlintrc.jsonc @@ -21,6 +21,44 @@ "message": "Use editor/lib/billing instead.", "allowTypeImports": true, }, + { + "name": "replicate", + "message": "Use editor/lib/ai/server (grida, runPrediction) instead. GRIDA-SEC-003.", + "allowTypeImports": true, + }, + { + "name": "openai", + "message": "Use editor/lib/ai/server instead. GRIDA-SEC-003.", + "allowTypeImports": true, + }, + { + "name": "@anthropic-ai/sdk", + "message": "Use editor/lib/ai/server instead. GRIDA-SEC-003.", + "allowTypeImports": true, + }, + ], + "patterns": [ + { + // Provider-specific AI SDK packages. Excludes `@ai-sdk/rsc` + // (server-component streaming utilities) and `@ai-sdk/react` + // (client hooks) — neither makes billed provider calls. + "group": [ + "@ai-sdk/openai", + "@ai-sdk/anthropic", + "@ai-sdk/google", + "@ai-sdk/google-vertex", + "@ai-sdk/amazon-bedrock", + "@ai-sdk/azure", + "@ai-sdk/cohere", + "@ai-sdk/groq", + "@ai-sdk/mistral", + "@ai-sdk/perplexity", + "@ai-sdk/replicate", + "@ai-sdk/xai", + ], + "message": "Use editor/lib/ai/server (grida) instead. GRIDA-SEC-003.", + "allowTypeImports": true, + }, ], }, ], @@ -39,5 +77,23 @@ "no-restricted-imports": "off", }, }, + { + // The AI provider seam — GRIDA-SEC-003. + // Only these files may import provider SDKs directly. + "files": [ + "lib/ai/server.ts", + "lib/ai/models.ts", + // The canvas agent constructs an OpenAI `tools.webSearch` factory + // (not a billed provider call). Keeping it inside the allowlist + // avoids a special-case eslint comment per tool. + "grida-canvas-hosted/ai/agent/server-agent.ts", + // Metadata endpoint — lists OpenAI's available models for the + // model selector UI. Not a billed inference call. + "app/(api)/private/ai/models/openai/route.ts", + ], + "rules": { + "no-restricted-imports": "off", + }, + }, ], } diff --git a/editor/app/(api)/private/ai/audio/actions.ts b/editor/app/(api)/private/ai/audio/actions.ts deleted file mode 100644 index ab08e960d..000000000 --- a/editor/app/(api)/private/ai/audio/actions.ts +++ /dev/null @@ -1,133 +0,0 @@ -"use server"; - -import { createLibraryClient } from "@/lib/supabase/server"; -import { ai_budget_deduct } from "../ratelimit"; -import { Env } from "@/env"; -import ai from "@/lib/ai"; - -type AuthRateLimitError = { - success: false; - message: string; - status: number; - limit?: number; - reset?: number; - remaining?: number; -}; - -async function validateAuthAndRateLimit( - cost_mills: number -): Promise { - if (Env.web.IS_LOCALDEV_SUPERUSER) { - return null; - } - - const client = await createLibraryClient(); - - const { data: userdata } = await client.auth.getUser(); - if (!userdata.user) { - return { - success: false, - message: "login required", - status: 401, - }; - } - - const rate = await ai_budget_deduct(cost_mills); - if (!rate) { - return { - success: false, - message: "something went wrong", - status: 500, - }; - } - - if (!rate.success) { - return { - success: false, - message: "ratelimit exceeded", - status: 429, - limit: rate.limit, - reset: rate.reset, - remaining: rate.remaining, - }; - } - - return null; -} - -export type GenerateAudioActionInput = { - model: ai.audio.AudioModelId; - prompt: string; - image_inputs?: string[]; - language?: string; - negative_prompt?: string; - seed?: number; -}; - -export type GenerateAudioActionResult = { - success: true; - data: { - url: string; - modelId: ai.audio.AudioModelId; - timestamp: string; - }; -}; - -export type GenerateAudioActionError = AuthRateLimitError; - -export type GenerateAudioActionResponse = - | GenerateAudioActionResult - | GenerateAudioActionError; - -export async function generateAudio( - input: GenerateAudioActionInput -): Promise { - if (!input.prompt || input.prompt.trim() === "") { - return { - success: false, - message: "prompt is required", - status: 400, - }; - } - - if (!Object.hasOwn(ai.audio.models, input.model)) { - return { - success: false, - message: "invalid model", - status: 400, - }; - } - const card = ai.audio.models[input.model]; - - const cost_mills = ai.toMills(card.avg_cost_usd); - const authError = await validateAuthAndRateLimit(cost_mills); - if (authError) { - return authError; - } - - try { - const result = await ai.server.methods.generateAudio(input.model, { - prompt: input.prompt, - image_inputs: input.image_inputs, - language: input.language, - negative_prompt: input.negative_prompt, - seed: input.seed, - }); - - return { - success: true, - data: { - url: result.url, - modelId: input.model, - timestamp: new Date().toISOString(), - }, - }; - } catch (error) { - console.error("[generateAudio] Error:", error); - return { - success: false, - message: "something went wrong", - status: 500, - }; - } -} diff --git a/editor/app/(api)/private/ai/audio/generate/route.ts b/editor/app/(api)/private/ai/audio/generate/route.ts deleted file mode 100644 index 024804d3c..000000000 --- a/editor/app/(api)/private/ai/audio/generate/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { generateAudio } from "../actions"; -import type { - GenerateAudioActionInput, - GenerateAudioActionResult, -} from "../actions"; - -export type GenerateAudioApiRequestBody = GenerateAudioActionInput; - -export type GenerateAudioApiResponse = { - data: GenerateAudioActionResult["data"]; -}; - -export async function POST(req: NextRequest) { - let body: GenerateAudioApiRequestBody; - try { - body = (await req.json()) as GenerateAudioApiRequestBody; - } catch { - return NextResponse.json({ message: "invalid json" }, { status: 400 }); - } - - const result = await generateAudio(body); - - if (!result.success) { - const errorResponse: { - message: string; - limit?: number; - reset?: number; - remaining?: number; - } = { - message: result.message, - }; - if (result.limit !== undefined) { - errorResponse.limit = result.limit; - errorResponse.reset = result.reset; - errorResponse.remaining = result.remaining; - } - return NextResponse.json(errorResponse, { status: result.status }); - } - - return NextResponse.json({ - data: result.data, - } satisfies GenerateAudioApiResponse); -} diff --git a/editor/app/(api)/private/ai/chat/route.ts b/editor/app/(api)/private/ai/chat/route.ts index 851712544..ad4f8a2bb 100644 --- a/editor/app/(api)/private/ai/chat/route.ts +++ b/editor/app/(api)/private/ai/chat/route.ts @@ -8,19 +8,42 @@ import { canvasDesignAgent } from "@/grida-canvas-hosted/ai/agent/server-agent"; import { Env } from "@/env"; import { createClient } from "@/lib/supabase/server"; import { modelSpecById } from "@/lib/ai/models"; +import { requireOrganizationId } from "@/lib/auth/organization"; +import { aiErrorResponse, orgErrorToAiError } from "@/lib/ai/error"; import type { AgentMessageMetadata } from "@/grida-canvas-hosted/ai/types"; type AgentChatRequestBody = { messages: UIMessage[]; }; +// TODO(ai-credits): this route inlines its own auth+gate and emits a +// streaming SSE response rather than the `AiActionResult` envelope +// produced by `withAiAuth`. The credits module (`@/lib/ai/credits`) +// folds `balanceCents` from that envelope into UI state. To bring this +// route into the same contract, emit a trailing SSE event carrying +// `balanceCents` (read via `refreshBalance(orgId)` after the agent +// finishes) so the canvas UI can `consume()` it the same way. export async function POST(req: NextRequest) { try { + // GRIDA-SEC-003: resolve the calling org with verified membership. + let organizationId: number | null = null; if (!Env.web.IS_LOCALDEV_SUPERUSER) { const client = await createClient(); const { data: userdata, error: authError } = await client.auth.getUser(); if (authError || !userdata.user) { - return new Response("Unauthorized", { status: 401 }); + return aiErrorResponse({ + code: "unauthorized", + status: 401, + message: "login required", + }); + } + try { + organizationId = await requireOrganizationId({ + user_id: userdata.user.id, + request: req, + }); + } catch (err) { + return aiErrorResponse(orgErrorToAiError(err)); } } @@ -31,9 +54,23 @@ export async function POST(req: NextRequest) { let lastModelId: string | undefined; let lastStepUsage: LanguageModelUsage | undefined; + // organizationId is required unless we're in the local-dev superuser + // mode (no auth/billing). The agent's `prepareCall` injects this into + // providerOptions.grida — see GRIDA-SEC-003. + if (organizationId === null && !Env.web.IS_LOCALDEV_SUPERUSER) { + return aiErrorResponse({ + code: "no_organization", + status: 412, + message: "organizationId required", + }); + } return createAgentUIStreamResponse({ agent: canvasDesignAgent, uiMessages: messages, + options: + organizationId !== null + ? { organizationId, feature: "canvas/agent/chat" } + : ({} as { organizationId: number; feature?: string }), sendReasoning: true, messageMetadata: ({ part }): AgentMessageMetadata | undefined => { if (part.type === "finish-step") { diff --git a/editor/app/(api)/private/ai/credits/route.ts b/editor/app/(api)/private/ai/credits/route.ts deleted file mode 100644 index 0a2ce4b96..000000000 --- a/editor/app/(api)/private/ai/credits/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createClient } from "@/lib/supabase/server"; -import { ai_budget_remaining } from "../ratelimit"; -import { NextResponse } from "next/server"; - -export async function GET() { - const client = await createClient(); - const u = await client.auth.getUser(); - if (u.error) { - return new NextResponse("Unauthorized", { status: 401 }); - } - - const data = await ai_budget_remaining({ user_id: u.data.user.id }); - return NextResponse.json( - { - data, - }, - { - status: 200, - } - ); -} diff --git a/editor/app/(api)/private/ai/generate/image/route.ts b/editor/app/(api)/private/ai/generate/image/route.ts deleted file mode 100644 index 7ec5e042a..000000000 --- a/editor/app/(api)/private/ai/generate/image/route.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - generateImage, - type GeneratedFile, - type GenerateImageResult, - type ImageModel, -} from "ai"; -import { createLibraryClient, service_role } from "@/lib/supabase/server"; -import { v4 } from "uuid"; -import { ai_budget_deduct } from "../../ratelimit"; -import mime from "mime-types"; -import imageSize from "image-size"; -import ai from "@/lib/ai"; -import { Env } from "@/env"; - -export type GenerateImageApiRequestBody = { - prompt: string; - width?: number; - height?: number; - aspect_ratio?: ai.image.AspectRatioString; - model: ai.image.ProviderModel | ai.image.ImageModelId; -}; - -export type GenerateImageApiResponse = { - data: { - object: { - id: string; - bytes: number; - width: number; - height: number; - mimetype: string; - }; - width: number; - height: number; - publicUrl: string; - timestamp: string; - modelId: string; - }; -}; - -export async function POST(req: NextRequest) { - const body = (await req.json()) as GenerateImageApiRequestBody; - const client = await createLibraryClient(); - - const model = ai.image.getSDKImageModel(body.model); - if (!model) { - return NextResponse.json( - { - message: "invalid model", - errors: { - field: "model", - value: body.model, - allowed_values: ai.image.image_model_ids, - }, - }, - { status: 400 } - ); - } - - // auth & rate limit - if (!Env.web.IS_LOCALDEV_SUPERUSER) { - // base auth - const { data: userdata } = await client.auth.getUser(); - if (!userdata.user) { - return NextResponse.json({ message: "login required" }, { status: 401 }); - } - - // TODO: track real cost from generation response instead of avg_cost_usd. - // Per-image models: compute exact cost from request params (quality × size - // for tiered, flat rate for others). Per-token models: use - // generation.usage.outputTokens × pricing.output / 1M after the call. - const rate = await ai_budget_deduct(ai.toMills(model.card.avg_cost_usd)); - if (!rate) { - return NextResponse.json( - { message: "something went wrong" }, - { status: 500 } - ); - } - - if (!rate.success) { - return NextResponse.json( - { - message: "ratelimit exceeded", - limit: rate.limit, - reset: rate.reset, - remaining: rate.remaining, - }, - { - status: 429, - headers: { ...rate.headers }, - } - ); - } - } - - // generate image - let generation: GenerateImageResult; - try { - generation = await generateImageWithSize({ - prompt: body.prompt, - width: body.width, - height: body.height, - aspect_ratio: body.aspect_ratio, - model: model.model, - }); - } catch (error) { - // the error from generateImage is mostly format related, non-sensitive, its okay to ping back to client with full error. - return NextResponse.json( - { message: "something went wrong", error: String(error) }, - { status: 500 } - ); - } - - const meta = generation.responses[0]; - - const { width, height } = imageSize(generation.image.uint8Array); - - // save to library - const { object, publicUrl } = await upload_generated_to_library({ - client: service_role.library, - request: { - model: model.card.id, - prompt: body.prompt, - width: width, - height: height, - }, - file: generation.image, - }); - - // response - return NextResponse.json({ - data: { - object, - publicUrl, - width: width, - height: height, - modelId: meta.modelId, - timestamp: meta.timestamp.toISOString(), - }, - } satisfies GenerateImageApiResponse); -} - -async function upload_generated_to_library({ - client, - file, - request, -}: { - client: Awaited>; - file: GeneratedFile; - request: { - model: ai.image.ImageModelId; - prompt: string; - width: number; - height: number; - }; -}) { - const { mediaType, uint8Array } = file; - - const ext = mime.extension(mediaType); - const name = v4(); - const folder = "generated"; - const path = `${folder}/${name}${ext ? `.${ext}` : ""}`; - - const { data: uploaded, error: upload_err } = - await service_role.library.storage - .from("library") - .upload(path, uint8Array, { - contentType: mediaType, - }); - - if (upload_err) throw new Error(upload_err.message); - - const { data: object, error: object_err } = await client - .from("object") - .insert({ - id: uploaded.id, - bytes: uint8Array.length, - category: "generated", - path: uploaded.path, - mimetype: mediaType, - // - generator: request.model, - prompt: request.prompt, - // - width: request.width, - height: request.height, - transparency: false, - }) - .select() - .single(); - - if (object_err) throw new Error(object_err.message); - - const publicUrl = client.storage.from("library").getPublicUrl(object.path) - .data.publicUrl; - - return { - object, - publicUrl, - }; -} - -async function generateImageWithSize({ - model, - prompt, - width, - height, - aspect_ratio, -}: { - model: ImageModel; - prompt: string; - width?: number; - height?: number; - aspect_ratio?: ai.image.AspectRatioString; -}): Promise { - const size: ai.image.SizeString | undefined = - width && height ? `${width}x${height}` : undefined; - - return await generateImage({ - model: model, - prompt: prompt, - size: size, - aspectRatio: aspect_ratio, - n: 1, - }); -} diff --git a/editor/app/(api)/private/ai/image/actions.ts b/editor/app/(api)/private/ai/image/actions.ts deleted file mode 100644 index 33fe4a43f..000000000 --- a/editor/app/(api)/private/ai/image/actions.ts +++ /dev/null @@ -1,180 +0,0 @@ -"use server"; - -import { createLibraryClient } from "@/lib/supabase/server"; -import { ai_budget_deduct } from "../ratelimit"; -import { Env } from "@/env"; -import ai from "@/lib/ai"; - -type AuthRateLimitError = { - success: false; - message: string; - status: number; - limit?: number; - reset?: number; - remaining?: number; -}; - -/** - * Validates authentication and rate limits for AI image operations - * @param cost_mills - Cost in mills (thousandths of USD) for the operation - * @returns Error response if auth/rate limit fails, null if OK - */ -async function validateAuthAndRateLimit( - cost_mills: number -): Promise { - if (Env.web.IS_LOCALDEV_SUPERUSER) { - return null; // Skip auth/rate limit for local dev superuser - } - - const client = await createLibraryClient(); - - // Check authentication - const { data: userdata } = await client.auth.getUser(); - if (!userdata.user) { - return { - success: false, - message: "login required", - status: 401, - }; - } - - // Check rate limit - const rate = await ai_budget_deduct(cost_mills); - if (!rate) { - return { - success: false, - message: "something went wrong", - status: 500, - }; - } - - if (!rate.success) { - return { - success: false, - message: "ratelimit exceeded", - status: 429, - limit: rate.limit, - reset: rate.reset, - remaining: rate.remaining, - }; - } - - return null; // All checks passed -} - -export type UpscaleImageActionInput = { - image: ai.server.methods.ImageData | string; // ImageData or string (for backward compatibility) - scale?: number; // optional, default: 4, max: 10 -}; - -export type UpscaleImageActionResult = { - success: true; - data: ai.server.methods.RealEsrganResult; -}; - -export type UpscaleImageActionError = AuthRateLimitError; - -export type UpscaleImageActionResponse = - | UpscaleImageActionResult - | UpscaleImageActionError; - -export async function upscaleImage( - input: UpscaleImageActionInput -): Promise { - // Validate input - if (!input.image) { - return { - success: false, - message: "image is required", - status: 400, - }; - } - - // Validate auth & rate limit - const cost_mills = ai.toMills( - ai.image_tools.models["nightmareai/real-esrgan"].cost_usd - ); - const authError = await validateAuthAndRateLimit(cost_mills); - if (authError) { - return authError; - } - - try { - const result = await ai.server.methods.upscale({ - image: ai.server.methods.toImageData(input.image), - scale: input.scale, - }); - - return { - success: true, - data: result, - }; - } catch (error) { - console.error("[upscaleImage] Error:", error); - return { - success: false, - message: "something went wrong", - status: 500, - }; - } -} - -export type RemoveBackgroundImageActionInput = { - image: ai.server.methods.ImageData | string; // ImageData or string (for backward compatibility) - // Note: format and background_type are no longer used (bria model doesn't support them) - // Kept for backward compatibility but ignored - format?: string; - background_type?: string; -}; - -export type RemoveBackgroundImageActionResult = { - success: true; - data: ai.server.methods.BackgroundRemoverResult; -}; - -export type RemoveBackgroundImageActionError = AuthRateLimitError; - -export type RemoveBackgroundImageActionResponse = - | RemoveBackgroundImageActionResult - | RemoveBackgroundImageActionError; - -export async function removeBackgroundImage( - input: RemoveBackgroundImageActionInput -): Promise { - // Validate input - if (!input.image) { - return { - success: false, - message: "image is required", - status: 400, - }; - } - - // Validate auth & rate limit - const modelId = ai.server.methods.MODEL_ID_RECRAFT_REMOVE_BACKGROUND; - const cost_mills = ai.toMills(ai.image_tools.models[modelId].cost_usd); - const authError = await validateAuthAndRateLimit(cost_mills); - if (authError) { - return authError; - } - - try { - // Use recraft-ai/recraft-remove-background as the default model - const result = await ai.server.methods.removeBackground( - input.image, - modelId - ); - - return { - success: true, - data: result, - }; - } catch (error) { - console.error("[removeBackgroundImage] Error:", error); - return { - success: false, - message: "something went wrong", - status: 500, - }; - } -} diff --git a/editor/app/(api)/private/ai/image/remove-background/route.ts b/editor/app/(api)/private/ai/image/remove-background/route.ts deleted file mode 100644 index 80aaf6976..000000000 --- a/editor/app/(api)/private/ai/image/remove-background/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { removeBackgroundImage } from "../actions"; -import type { RemoveBackgroundImageActionInput } from "../actions"; -import ai from "@/lib/ai"; - -export type RemoveBackgroundImageApiRequestBody = - RemoveBackgroundImageActionInput; - -export type RemoveBackgroundImageApiResponse = { - data: ai.server.methods.BackgroundRemoverResult; -}; - -export async function POST(req: NextRequest) { - const body = (await req.json()) as RemoveBackgroundImageApiRequestBody; - - // Call the server action - it handles validation, auth, rate limiting, and business logic - const result = await removeBackgroundImage(body); - - // Convert server action response to API response format - if (!result.success) { - const errorResponse: { - message: string; - limit?: number; - reset?: number; - remaining?: number; - } = { - message: result.message, - }; - if (result.limit !== undefined) { - errorResponse.limit = result.limit; - errorResponse.reset = result.reset; - errorResponse.remaining = result.remaining; - } - return NextResponse.json(errorResponse, { status: result.status }); - } - - return NextResponse.json({ - data: result.data, - } satisfies RemoveBackgroundImageApiResponse); -} diff --git a/editor/app/(api)/private/ai/image/upscale/route.ts b/editor/app/(api)/private/ai/image/upscale/route.ts deleted file mode 100644 index 03b80ee02..000000000 --- a/editor/app/(api)/private/ai/image/upscale/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { upscaleImage } from "../actions"; -import type { UpscaleImageActionInput } from "../actions"; -import ai from "@/lib/ai"; - -export type UpscaleImageApiRequestBody = UpscaleImageActionInput; - -export type UpscaleImageApiResponse = { - data: ai.server.methods.RealEsrganResult; -}; - -export async function POST(req: NextRequest) { - const body = (await req.json()) as UpscaleImageApiRequestBody; - - // Call the server action - it handles validation, auth, rate limiting, and business logic - const result = await upscaleImage(body); - - // Convert server action response to API response format - if (!result.success) { - const errorResponse: { - message: string; - limit?: number; - reset?: number; - remaining?: number; - } = { - message: result.message, - }; - if (result.limit !== undefined) { - errorResponse.limit = result.limit; - errorResponse.reset = result.reset; - errorResponse.remaining = result.remaining; - } - return NextResponse.json(errorResponse, { status: result.status }); - } - - return NextResponse.json({ - data: result.data, - } satisfies UpscaleImageApiResponse); -} diff --git a/editor/app/(api)/private/ai/models/openai/route.ts b/editor/app/(api)/private/ai/models/openai/route.ts deleted file mode 100644 index 1dd2c6be5..000000000 --- a/editor/app/(api)/private/ai/models/openai/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import OpenAI from "openai"; -import { NextResponse } from "next/server"; - -const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, -}); - -export async function GET() { - const result = await openai.models.list(); - return NextResponse.json({ data: result.data }); -} diff --git a/editor/app/(api)/private/ai/models/route.ts b/editor/app/(api)/private/ai/models/route.ts deleted file mode 100644 index dc5878844..000000000 --- a/editor/app/(api)/private/ai/models/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { NextResponse } from "next/server"; -import ai from "@/lib/ai"; - -export async function GET() { - return NextResponse.json({ data: ai.image.models }); -} diff --git a/editor/app/(api)/private/ai/ratelimit.ts b/editor/app/(api)/private/ai/ratelimit.ts deleted file mode 100644 index f3dea0aa5..000000000 --- a/editor/app/(api)/private/ai/ratelimit.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { createClient } from "@/lib/supabase/server"; -import { Ratelimit } from "@upstash/ratelimit"; -import { Redis } from "@upstash/redis"; - -/** - * AI usage budget enforced via Upstash sliding-window rate limiter. - * - * Units are **mills** (1 mill = $0.001 USD). - * Budget: 1000 mills = $1.00 per 30-day rolling window. - * - * Each API call deducts `Math.ceil(cost_usd * 1000)` mills from the - * user's budget. - */ -const ratelimit = new Ratelimit({ - redis: Redis.fromEnv(), - limiter: Ratelimit.slidingWindow(1000, "30d"), -}); - -/** - * Read the user's remaining budget (in mills) without consuming any. - */ -export async function ai_budget_remaining({ user_id }: { user_id: string }) { - const { reset, remaining } = await ratelimit.getRemaining( - `ratelimit_u_${user_id}` - ); - - return { reset, remaining }; -} - -/** - * Deduct `cost_mills` from the authenticated user's budget. - * - * @param cost_mills - integer cost in mills (`Math.ceil(cost_usd * 1000)`) - */ -export async function ai_budget_deduct(cost_mills: number) { - if (!Number.isFinite(cost_mills) || cost_mills <= 0) { - throw new Error("cost_mills must be a positive finite number"); - } - - const client = await createClient(); - - const { data: userdata, error: auth_err } = await client.auth.getUser(); - if (auth_err) throw new Error(auth_err.message); - if (!userdata.user) throw new Error("Unauthorized"); - const user_id = userdata.user.id; - - const { success, limit, reset, remaining } = await ratelimit.limit( - `ratelimit_u_${user_id}`, - { rate: cost_mills } - ); - - const ratelimit_headers = { - "x-ratelimit-limit": limit.toString(), - "x-ratelimit-reset": reset.toString(), - "x-ratelimit-remaining": remaining.toString(), - }; - - return { - success, - limit, - reset, - remaining, - headers: ratelimit_headers, - }; -} diff --git a/editor/app/(api)/private/editor/ai/schema/route.ts b/editor/app/(api)/private/editor/ai/schema/route.ts deleted file mode 100644 index cb9a02fb0..000000000 --- a/editor/app/(api)/private/editor/ai/schema/route.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { generateObject } from "ai"; -import { z } from "zod/v3"; -import { NextRequest, NextResponse } from "next/server"; -import { supported_field_types } from "@/k/supported_field_types"; -import { model } from "@/lib/ai/models"; -import type { FormInputType } from "@/grida-forms-hosted/types"; - -/** - * Strict-mode-compatible Zod schema for AI structured output. - * - * Uses `.nullable()` instead of `.optional()` so every property appears in the - * JSON Schema `required` array (OpenAI structured output requirement). - * - * The `type` enum is built dynamically from `supported_field_types` so it stays - * in sync with the rest of the codebase. - */ -const formFieldSchema = z.object({ - name: z - .string() - .describe( - "The input's name identifier. Use lowercase with underscores, e.g. column_name" - ), - label: z.string().describe("Human-readable label for the field"), - type: z - .enum(supported_field_types as [FormInputType, ...FormInputType[]]) - .describe("HTML5 + extended input type"), - placeholder: z.string().describe("Placeholder text"), - required: z.boolean().describe("Whether the field is required"), - help_text: z.string().describe("Help text displayed below the field"), - pattern: z - .string() - .nullable() - .describe( - "Regular expression pattern for validation (HTML input pattern attribute)" - ), - options: z - .array( - z.object({ - label: z.string(), - value: z.string(), - }) - ) - .nullable() - .describe("Options for select / radio fields"), -}); - -export async function POST(req: NextRequest) { - const requestBody = await req.json(); - const description = requestBody.description; - - if (!description) { - return NextResponse.json( - { error: "Description is required" }, - { status: 400 } - ); - } - - try { - const { object: schema } = await generateObject({ - model: model("nano"), - schema: formFieldSchema, - system: - "Generate a form field definition based on the user's description. " + - "Users might not speak English — localise label, placeholder, and help_text to match their input language.", - prompt: description, - }); - - return NextResponse.json(schema); - } catch (error: unknown) { - console.error(error); - return NextResponse.json( - { - error: - (error instanceof Error ? error.message : null) || - "Failed to generate form field schema", - }, - { status: 500 } - ); - } -} diff --git a/editor/app/(canvas)/canvas/tools/ai/_hooks/use-models.ts b/editor/app/(canvas)/canvas/tools/ai/_hooks/use-models.ts index 116858df2..36181de02 100644 --- a/editor/app/(canvas)/canvas/tools/ai/_hooks/use-models.ts +++ b/editor/app/(canvas)/canvas/tools/ai/_hooks/use-models.ts @@ -1,15 +1,9 @@ import useSWR from "swr"; import type OpenAI from "openai"; +import { listOpenAiModels } from "@/lib/ai/actions/models"; export function useModels() { - return useSWR<{ data: OpenAI.Models.Model[] }>( - "/private/ai/models/openai", - async () => { - const res = await fetch("/private/ai/models/openai"); - if (!res.ok) { - throw new Error("Failed to fetch models"); - } - return res.json(); - } + return useSWR<{ data: OpenAI.Models.Model[] }>("ai/models/openai", () => + listOpenAiModels() ); } diff --git a/editor/app/(canvas)/canvas/tools/ai/generate.ts b/editor/app/(canvas)/canvas/tools/ai/generate.ts index 59bfdde57..111969cae 100644 --- a/editor/app/(canvas)/canvas/tools/ai/generate.ts +++ b/editor/app/(canvas)/canvas/tools/ai/generate.ts @@ -12,7 +12,7 @@ import { } from "ai"; import { createStreamableValue } from "@ai-sdk/rsc"; import { request_schema, type StreamingResponse } from "./schema"; -import { gateway, model as tieredModel } from "@/lib/ai/models"; +import { grida, model as tieredModel } from "@/lib/ai/server"; import assert from "assert"; export type UserAttachment = { @@ -23,6 +23,7 @@ export type UserAttachment = { }; export async function generate({ + organizationId, system, user, prompt, @@ -31,6 +32,16 @@ export async function generate({ temperature = undefined, topP = undefined, }: { + /** + * Verified organizationId — billed for this call. Caller is + * responsible for threading from a verified context (workspace + * shell / route param). See GRIDA-SEC-003. + * + * Optional only to make the dev-tool harness compile without a + * workspace; when omitted in non-superuser mode the seam middleware + * throws `MissingOrgIdError` at the first AI call. + */ + organizationId?: number; system?: string; prompt?: string; user?: { @@ -44,7 +55,7 @@ export async function generate({ }) { // Dev tool: allow user-selected model override via the model selector UI; // fall back to the "mini" tier default. - const model = modelId ? gateway(modelId) : tieredModel("mini"); + const model = modelId ? grida(modelId) : tieredModel("mini"); const model_config = { maxOutputTokens: maxOutputTokens, temperature: temperature, @@ -95,6 +106,9 @@ export async function generate({ (async () => { const { partialOutputStream } = streamText({ model, + providerOptions: { + grida: { organizationId, feature: "canvas/generate" }, + }, ...model_config, system, ...(message diff --git a/editor/app/(tools)/(playground)/playground/image/_page.tsx b/editor/app/(tools)/(playground)/playground/image/_page.tsx index 97e19a73f..2cf70f9e2 100644 --- a/editor/app/(tools)/(playground)/playground/image/_page.tsx +++ b/editor/app/(tools)/(playground)/playground/image/_page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useMemo } from "react"; +import React, { useMemo, useTransition } from "react"; import { ChatBoxFooter, ChatBox, @@ -22,11 +22,9 @@ import { SelectGroup, SelectLabel, } from "@/components/ui/select"; -import { - useImageModelConfig, - useGenerateImage, - useCredits, -} from "@/lib/ai/hooks"; +import { useImageModelConfig } from "@/lib/ai/hooks"; +import { generateAiImage } from "@/lib/ai/actions/image-generate"; +import { useAiCredits } from "@/lib/ai/credits"; import { Selection, Zoom, @@ -101,10 +99,10 @@ export default function ImagePlayground() { function CanvasConsumer() { const { withAuth, session } = useContinueWithAuth(); - const credits = useCredits(); + const credits = useAiCredits(); const editor = useCurrentEditor(); const model = useImageModelConfig("openai/gpt-image-1-mini"); - const { generate, loading } = useGenerateImage(); + const [loading, startGenerate] = useTransition(); const onCommit = (value: { text: string }) => { const id = editor.commands.insertNode({ @@ -114,19 +112,18 @@ function CanvasConsumer() { layout_target_height: model.height, fit: "cover", }); - generate({ - model: model.modelId, - width: model.width, - height: model.height, - aspect_ratio: model.aspect_ratio, - prompt: value.text, - }) - .then((image) => { - editor.commands.changeNodePropertySrc(id, image.src); - }) - .finally(() => { - credits.refresh(); + startGenerate(async () => { + const env = await generateAiImage({ + model: model.modelId, + width: model.width, + height: model.height, + aspect_ratio: model.aspect_ratio, + prompt: value.text, }); + const data = credits.consume(env, { next: "/playground/image" }); + if (!data) return; // gate / redirect handled + editor.commands.changeNodePropertySrc(id, data.publicUrl); + }); }; return ( @@ -207,7 +204,7 @@ function BudgetBadge({ credits, className, }: { - credits: ReturnType; + credits: ReturnType; className?: string; }) { return ( @@ -219,12 +216,12 @@ function BudgetBadge({ className )} > - {credits.remainingUSD} + {credits.formatted ?? "—"}
- {credits.remainingUSD} free budget remaining + {credits.formattedExact ?? "—"} balance
@@ -242,7 +239,7 @@ function Chat({ onCommit: (value: { text: string; }) => Promise | Promise | void | false; - credits: ReturnType | null; + credits: ReturnType | null; }) { const sizeGroups = useMemo(() => { const groups = { diff --git a/editor/app/(tools)/(playground)/playground/image/layout.tsx b/editor/app/(tools)/(playground)/playground/image/layout.tsx index 8cc642e92..bf8c2e903 100644 --- a/editor/app/(tools)/(playground)/playground/image/layout.tsx +++ b/editor/app/(tools)/(playground)/playground/image/layout.tsx @@ -1,14 +1,17 @@ import type { Metadata } from "next"; +import { AiCredits } from "@/lib/ai/credits"; +import { resolveInitialAiCredits } from "@/lib/ai/credits/actions"; export const metadata: Metadata = { title: "Image Playground", description: "Playground for generating images", }; -export default function Layout({ +export default async function Layout({ children, }: Readonly<{ children: React.ReactNode; }>) { - return <>{children}; + const initial = await resolveInitialAiCredits(); + return {children}; } diff --git a/editor/app/(tools)/tools/remove-bg/_page.tsx b/editor/app/(tools)/tools/remove-bg/_page.tsx index 66b83a795..9fb9b50c9 100644 --- a/editor/app/(tools)/tools/remove-bg/_page.tsx +++ b/editor/app/(tools)/tools/remove-bg/_page.tsx @@ -14,10 +14,7 @@ import { RotateCcwIcon, UploadIcon, } from "lucide-react"; -import type { - RemoveBackgroundImageApiRequestBody, - RemoveBackgroundImageApiResponse, -} from "@/app/(api)/private/ai/image/remove-background/route"; +import { removeBackgroundImage } from "@/lib/ai/actions/image"; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB @@ -88,25 +85,15 @@ function Workspace() { setLoading(true); setError(null); try { - const res = await fetch(`/private/ai/image/remove-background`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - image: sourceUrl, - } satisfies RemoveBackgroundImageApiRequestBody), - }); - if (!res.ok) { - const data = (await res.json().catch(() => ({}))) as { - message?: string; - }; - setError(data.message ?? "Something went wrong."); + const env = await removeBackgroundImage({ image: sourceUrl }); + if (!env.success) { + setError(env.message ?? "Something went wrong."); return; } - const data = (await res.json()) as RemoveBackgroundImageApiResponse; const url = - data.data.image.kind === "url" - ? data.data.image.url - : data.data.image.base64; + env.data.image.kind === "url" + ? env.data.image.url + : env.data.image.base64; setResultUrl(url); } catch (e) { setError(String(e)); diff --git a/editor/app/(www)/(ai)/ai/music/page.tsx b/editor/app/(www)/(ai)/ai/music/page.tsx index 4e51a6dd6..b1c07b338 100644 --- a/editor/app/(www)/(ai)/ai/music/page.tsx +++ b/editor/app/(www)/(ai)/ai/music/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import Link from "next/link"; +import Script from "next/script"; import Header from "@/www/header"; import Footer from "@/www/footer"; import { Button } from "@/components/ui/button"; @@ -114,11 +115,13 @@ export default function MusicLandingPage() { return (
-