diff --git a/.agents/skills/database/SKILL.md b/.agents/skills/database/SKILL.md new file mode 100644 index 0000000000..7c6f3f2e59 --- /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. diff --git a/.agents/skills/security/SKILL.md b/.agents/skills/security/SKILL.md new file mode 100644 index 0000000000..2dd9b5f503 --- /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 0000000000..ee9d11acf6 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,273 @@ +# 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. **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 +under `(api)/private/**`. Anything user-facing goes under +`(api)/(public)/v1/**`. Mixing categories breaks the trust contract. + +--- + +### `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. + +--- + +### `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). +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/database/database-generated.types.ts b/database/database-generated.types.ts index 225e26aeac..709aa7e1ad 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/docs/contributing/billing.md b/docs/contributing/billing.md index 319334e8a8..dd105f33a3 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/billing/`](../wg/platform/billing/) (AI credits master plan, Metronome integration, known issues). 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/billing/_category_.json b/docs/wg/platform/billing/_category_.json new file mode 100644 index 0000000000..b8a16b9234 --- /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/billing/ai-credits.md b/docs/wg/platform/billing/ai-credits.md new file mode 100644 index 0000000000..8d8170ea95 --- /dev/null +++ b/docs/wg/platform/billing/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 + [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. +- 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/index.md b/docs/wg/platform/billing/index.md new file mode 100644 index 0000000000..942998ac0c --- /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 with + Stripe + Metronome sandbox accounts and a 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 new file mode 100644 index 0000000000..f76bd85d33 --- /dev/null +++ b/docs/wg/platform/billing/known-issues.md @@ -0,0 +1,189 @@ +--- +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-003 | Subscriptions · plan grant | Medium | 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/billing/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. + +--- + +## 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)_ + +--- + +## 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/billing/metronome.md b/docs/wg/platform/billing/metronome.md new file mode 100644 index 0000000000..a2e31daf1d --- /dev/null +++ b/docs/wg/platform/billing/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 | sub-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/docs/wg/platform/index.md b/docs/wg/platform/index.md index 22fcd3f682..4ba920a15e 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/.env.example b/editor/.env.example index 6dd98ec25d..9f7726bec7 100644 --- a/editor/.env.example +++ b/editor/.env.example @@ -81,4 +81,41 @@ 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 + Metronome +# ============================================================================ +# 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. +# `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" + +# 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 diff --git a/editor/.oxlintrc.jsonc b/editor/.oxlintrc.jsonc index ff6bfb38e8..ca79f45bf4 100644 --- a/editor/.oxlintrc.jsonc +++ b/editor/.oxlintrc.jsonc @@ -21,6 +21,72 @@ "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. + // Patterns also cover subpath imports (e.g. `@ai-sdk/openai/internal`). + "group": [ + "@ai-sdk/openai", + "@ai-sdk/openai/**", + "@ai-sdk/anthropic", + "@ai-sdk/anthropic/**", + "@ai-sdk/google", + "@ai-sdk/google/**", + "@ai-sdk/google-vertex", + "@ai-sdk/google-vertex/**", + "@ai-sdk/amazon-bedrock", + "@ai-sdk/amazon-bedrock/**", + "@ai-sdk/azure", + "@ai-sdk/azure/**", + "@ai-sdk/cohere", + "@ai-sdk/cohere/**", + "@ai-sdk/groq", + "@ai-sdk/groq/**", + "@ai-sdk/mistral", + "@ai-sdk/mistral/**", + "@ai-sdk/perplexity", + "@ai-sdk/perplexity/**", + "@ai-sdk/replicate", + "@ai-sdk/replicate/**", + "@ai-sdk/xai", + "@ai-sdk/xai/**", + ], + "message": "Use editor/lib/ai/server (grida) instead. GRIDA-SEC-003.", + "allowTypeImports": true, + }, + { + // Block subpath imports of provider SDKs (e.g. `openai/resources`, + // `@anthropic-ai/sdk/foo`, `replicate/lib/...`). The bare-name + // imports are already blocked above via `paths`. + "group": ["openai/**", "replicate/**", "@anthropic-ai/sdk/**"], + "message": "Use editor/lib/ai/server instead. GRIDA-SEC-003.", + "allowTypeImports": true, + }, + { + // Block subpath imports of the Stripe SDK (e.g. `stripe/lib/...`). + // The bare-name import is already blocked above via `paths`. + "group": ["stripe/**"], + "message": "Use editor/lib/billing instead.", + "allowTypeImports": true, + }, ], }, ], @@ -39,5 +105,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)/internal/cron/billing-reconcile/route.ts b/editor/app/(api)/internal/cron/billing-reconcile/route.ts new file mode 100644 index 0000000000..2f790eeca9 --- /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/(api)/private/ai/audio/actions.ts b/editor/app/(api)/private/ai/audio/actions.ts deleted file mode 100644 index ab08e960dc..0000000000 --- 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 024804d3c6..0000000000 --- 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 851712544b..918a45a900 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") { @@ -60,6 +97,10 @@ export async function POST(req: NextRequest) { }); } catch (error) { console.error("Error in agent chat:", error); - return new Response("Internal error", { status: 500 }); + return aiErrorResponse({ + code: "internal", + status: 500, + message: "internal error", + }); } } 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 0a2ce4b96e..0000000000 --- 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 7ec5e042a5..0000000000 --- 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 33fe4a43f5..0000000000 --- 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 80aaf6976e..0000000000 --- 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 03b80ee026..0000000000 --- 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 1dd2c6be53..0000000000 --- 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 dc58788440..0000000000 --- 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 f3dea0aa50..0000000000 --- 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 cb9a02fb01..0000000000 --- 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/(api)/private/webhooks/stripe/route.ts b/editor/app/(api)/private/webhooks/stripe/route.ts deleted file mode 100644 index 534138cbef..0000000000 --- a/editor/app/(api)/private/webhooks/stripe/route.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * 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`. - * - * Pipeline: - * 1. Read raw body (signature verification needs raw bytes). - * 2. Verify Stripe signature → 400 on failure. - * 3. Hand the event to `dispatchStripeEvent`, which calls - * `public.fn_billing_apply_stripe_event(...)`. The RPC handles - * idempotency (insert into `grida_billing.stripe_event` with ON CONFLICT - * DO NOTHING; replays return `result='replayed'`), event projection, - * and stamping `processed_at` on success. - * 4. On error: catch, call `stampStripeEventFailure` (separate transaction) - * so the forensic record survives the projector's RAISE-driven - * rollback. Return 500 → Stripe retries. - */ - -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { - stripe, - dispatchStripeEvent, - stampStripeEventFailure, - type Stripe, -} from "@/lib/billing"; - -// Make sure Next doesn't try to parse the body before we can verify the signature. -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function POST(req: NextRequest) { - const sig = req.headers.get("stripe-signature"); - if (!sig) { - return NextResponse.json( - { error: "missing stripe-signature header" }, - { status: 400 } - ); - } - - const secret = process.env.STRIPE_WEBHOOK_SECRET; - if (!secret) { - console.error("[webhook/stripe] STRIPE_WEBHOOK_SECRET not configured"); - return NextResponse.json( - { error: "webhook secret not configured" }, - { status: 500 } - ); - } - - // Raw bytes preserve the exact body Stripe signed. - const rawBody = await req.text(); - - let event: Stripe.Event; - try { - event = await stripe.webhooks.constructEventAsync(rawBody, sig, secret); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.warn("[webhook/stripe] signature verification failed:", message); - return NextResponse.json( - { error: "invalid signature", detail: message }, - { status: 400 } - ); - } - - let result; - try { - result = await dispatchStripeEvent(event); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error( - `[webhook/stripe] handler error for ${event.type} (${event.id}):`, - msg - ); - await stampStripeEventFailure(event.id, event.type, msg); - return NextResponse.json( - { error: "handler_failed", detail: msg }, - { status: 500 } - ); - } - - if (result.result === "replayed") { - return NextResponse.json({ received: true, replayed: true }); - } - return NextResponse.json({ - received: true, - type: event.type, - handler: result.handler, - }); -} diff --git a/editor/app/(canvas)/canvas/slides/page.tsx b/editor/app/(canvas)/canvas/slides/page.tsx index 11ec763b57..2731f24e75 100644 --- a/editor/app/(canvas)/canvas/slides/page.tsx +++ b/editor/app/(canvas)/canvas/slides/page.tsx @@ -24,6 +24,7 @@ import { WorkbenchUI } from "@/components/workbench"; import { cn } from "@/components/lib/utils"; import { PlaygroundToolbar } from "@/grida-canvas-hosted/playground/uxhost-toolbar"; import { ToolbarPosition } from "@/grida-canvas-react-starter-kit/starterkit-toolbar"; +import { StarterKitOrgIdProvider } from "@/grida-canvas-react-starter-kit/starterkit-host/org-id-provider"; import { Sidebar, SidebarContent, @@ -81,29 +82,31 @@ export default function SlidesPlaygroundPage() { - -
- - - -
- - - - - - - -
-
- {presentationData && ( - - )} -
-
+ + +
+ + + +
+ + + + + + + +
+
+ {presentationData && ( + + )} +
+
+
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 116858df2c..36181de02d 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 59bfdde574..111969cae5 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/(ingest)/README.md b/editor/app/(ingest)/README.md new file mode 100644 index 0000000000..e70e9891a1 --- /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/(ingest)/webhooks/metronome/route.ts b/editor/app/(ingest)/webhooks/metronome/route.ts new file mode 100644 index 0000000000..75f414be34 --- /dev/null +++ b/editor/app/(ingest)/webhooks/metronome/route.ts @@ -0,0 +1,200 @@ +/** + * 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 }); + } + // Symmetric ±5 min freshness window. Reject stale events (replay-resistance) + // AND far-future timestamps (clock-skew/forgery defense). + if (Math.abs(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 new file mode 100644 index 0000000000..e06b677795 --- /dev/null +++ b/editor/app/(ingest)/webhooks/stripe/route.ts @@ -0,0 +1,204 @@ +/** + * Stripe webhook receiver — single endpoint for all event types. + * + * `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). + * 2. Verify Stripe signature → 400 on failure. + * 3. Hand the event to `dispatchStripeEvent`, which calls + * `public.fn_billing_apply_stripe_event(...)`. The RPC handles + * idempotency (insert into `grida_billing.stripe_event` with ON CONFLICT + * DO NOTHING; replays return `result='replayed'`), event projection, + * and stamping `processed_at` on success. + * 4. AI-credit Checkout post-processor runs INDEPENDENTLY of the + * projector's replayed/handled distinction, gated by a separate + * per-event marker `stripe_event.ai_credit_processed_at` (read via + * `readAiCreditMarker`, stamped via `stampAiCreditMarker`). This is + * the retry-recovery path: if a previous delivery completed the + * projector but failed the post-processor (Metronome 500, network + * blip), the next Stripe retry sees `replayed` from the projector + * but `marker IS NULL` from the DB, re-runs the post-processor, and + * lands the Metronome commit. Without this split, replays + * short-circuit before the post-processor and the customer pays + * with no balance. + * 5. On error (projector OR post-processor): catch, call + * `stampStripeEventFailure` (separate transaction) so the forensic + * record survives the projector's RAISE-driven rollback. Return 500 + * → Stripe retries. + */ + +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { + stripe, + dispatchStripeEvent, + stampStripeEventFailure, + readAiCreditMarker, + stampAiCreditMarker, + 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"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const sig = req.headers.get("stripe-signature"); + if (!sig) { + return NextResponse.json( + { error: "missing stripe-signature header" }, + { status: 400 } + ); + } + + 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" }, + { status: 500 } + ); + } + + // Raw bytes preserve the exact body Stripe signed. + const rawBody = await req.text(); + + let event: Stripe.Event; + try { + event = await stripe.webhooks.constructEventAsync(rawBody, sig, secret); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn("[webhook/stripe] signature verification failed:", message); + return NextResponse.json( + { error: "invalid signature", detail: message }, + { status: 400 } + ); + } + + let result; + try { + result = await dispatchStripeEvent(event); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error( + `[webhook/stripe] handler error for ${event.type} (${event.id}):`, + msg + ); + await stampStripeEventFailure(event.id, event.type, msg); + return NextResponse.json( + { error: "handler_failed", detail: msg }, + { status: 500 } + ); + } + + // AI credit Checkout post-processor — runs independently of `result.result`. + // + // The projector and the post-processor have distinct idempotency markers: + // - projector → `stripe_event.processed_at` (set inside the RPC) + // - post-processor → `stripe_event.ai_credit_processed_at` (set below) + // + // We MUST consult the post-processor marker even on `replayed` projector + // results: a previous delivery may have completed the projector but failed + // the post-processor (Metronome 500, network blip). 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. + // + // No-op for any session without the right metadata.kind — the handler + // returns `result: 'noop'` and we still set the marker so future replays + // skip cleanly. + if (event.type === "checkout.session.completed") { + const aiAlreadyProcessed = await readAiCreditMarker(event.id); + if (aiAlreadyProcessed === true) { + return NextResponse.json({ + received: true, + replayed: result.result === "replayed", + ai_credit: "already_processed", + }); + } + + 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 ?? ""}` + ); + } + // Mark on success (including noop — noop means the event is not an + // AI-credit event and there's nothing to recover on retry). + await stampAiCreditMarker(event.id, event.type); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error( + `[webhook/stripe] ai-credit post-processor failed for ${event.id}:`, + msg + ); + // Do NOT stamp the marker — Stripe will retry, the replay will see + // marker IS NULL, and we re-enter this branch. + return NextResponse.json( + { error: "ai_credit_post_processor_failed", detail: msg }, + { status: 500 } + ); + } + } + + // Replayed-and-not-AI-credit (or AI-credit just completed via the branch + // above): nothing more to do for replays. + if (result.result === "replayed") { + return NextResponse.json({ received: true, replayed: true }); + } + + // 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, + handler: result.handler, + }); +} diff --git a/editor/app/(insiders)/insiders/billing/_view.tsx b/editor/app/(insiders)/insiders/billing/_view.tsx new file mode 100644 index 0000000000..4494ef7892 --- /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 0000000000..38715bded6 --- /dev/null +++ b/editor/app/(insiders)/insiders/billing/actions.ts @@ -0,0 +1,271 @@ +"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> { + // GRIDA-SEC-002: defense-in-depth. Server actions are addressable by their + // generated hash from any page that imports them, regardless of route group; + // the proxy + (insiders) layout `notFound()` are the primary gates, but this + // runtime guard ensures these unauthenticated mutators refuse to execute + // outside local development even if an import accidentally crosses the + // boundary. + if (process.env.NODE_ENV !== "development") { + return { ok: false, error: "insiders actions are dev-only" }; + } + 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 0000000000..50af22a4b2 --- /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 da5eabd5b5..0c402926a9 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/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts b/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts index 7adfe3b0a1..52c2583c6a 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/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). +// 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/billing/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 abd43fb56d..03df529342 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 e18eb0d854..fb3bd9a5ce 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 9ac05b6893..c1c65d1166 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"; diff --git a/editor/app/(tools)/(playground)/playground/image/_page.tsx b/editor/app/(tools)/(playground)/playground/image/_page.tsx index 97e19a73f5..70b862cf8d 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,27 @@ 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 () => { + try { + 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) { + // gate / redirect handled by credits.consume; remove orphan node + editor.commands.delete([id]); + return; + } + editor.commands.changeNodePropertySrc(id, data.publicUrl); + } catch (e) { + console.error(e); + editor.commands.delete([id]); + } + }); }; return ( @@ -207,7 +213,7 @@ function BudgetBadge({ credits, className, }: { - credits: ReturnType; + credits: ReturnType; className?: string; }) { return ( @@ -219,12 +225,12 @@ function BudgetBadge({ className )} > - {credits.remainingUSD} + {credits.formatted ?? "—"}
- {credits.remainingUSD} free budget remaining + {credits.formattedExact ?? "—"} balance
@@ -242,7 +248,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 8cc642e928..bf8c2e9039 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 66b83a795e..9fb9b50c95 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/_page.tsx b/editor/app/(www)/(ai)/ai/_page.tsx new file mode 100644 index 0000000000..6df715ec38 --- /dev/null +++ b/editor/app/(www)/(ai)/ai/_page.tsx @@ -0,0 +1,466 @@ +"use client"; + +import React, { useCallback, useEffect, useState, useTransition } from "react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { Loader2Icon, RefreshCwIcon, SparklesIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from "@/components/ai-elements/conversation"; +import { Message, MessageContent } from "@/components/ai-elements/message"; +import { Response } from "@/components/ai-elements/response"; +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + type PromptInputMessage, +} from "@/components/ai-elements/prompt-input"; +import { cn } from "@/components/lib/utils"; +import { resolveAiError } from "@/lib/ai/error"; +import { AiCredits, useAiCredits } from "@/lib/ai/credits"; +import { AI_GATE_FLOOR_CENTS, fmtUsd } from "@/lib/billing/fees"; +import { + runChat, + type ChatTurn, + type RunChatData, +} from "@/lib/ai/actions/chat"; +import type { AiPageContext } from "./page"; + +type Props = { + authed: boolean; + context: AiPageContext | null; +}; + +// --------------------------------------------------------------------------- +// Model picker — only the four tiered models (nano / mini / pro / max). +// Non-tiered catalog entries (e.g. gpt-5.5, gpt-5.5-pro) are intentionally +// hidden — they're either too expensive for blanket exposure or reserved +// for specific call sites. Tier → model mapping lives in lib/ai/models.ts. +// --------------------------------------------------------------------------- +type ModelTier = "nano" | "mini" | "pro" | "max"; +type ModelOption = { + id: string; + label: string; + tier: ModelTier; + inputUsd: number; + outputUsd: number; +}; +const MODEL_OPTIONS: readonly ModelOption[] = [ + { + id: "openai/gpt-5.4-nano", + label: "GPT-5.4 Nano", + tier: "nano", + inputUsd: 0.2, + outputUsd: 1.25, + }, + { + id: "openai/gpt-5.4-mini", + label: "GPT-5.4 Mini", + tier: "mini", + inputUsd: 0.75, + outputUsd: 4.5, + }, + { + id: "anthropic/claude-sonnet-4.6", + label: "Claude Sonnet 4.6", + tier: "pro", + inputUsd: 3, + outputUsd: 15, + }, + { + id: "anthropic/claude-opus-4.7", + label: "Claude Opus 4.7", + tier: "max", + inputUsd: 5, + outputUsd: 25, + }, +] as const; +const DEFAULT_MODEL_ID = "openai/gpt-5.4-mini"; + +// --------------------------------------------------------------------------- +// Debug flag — keyboard shortcut (Cmd/Ctrl+Shift+D) + `?debug=1` URL param. +// Persists across reloads via localStorage. There is no on-screen affordance +// to enable it; it's intentionally a hidden developer-mode toggle. +// --------------------------------------------------------------------------- +const DEBUG_STORAGE_KEY = "grida.ai.debug"; + +function useDebugFlag(): [boolean, () => void] { + const [debug, setDebug] = useState(false); + + useEffect(() => { + const url = new URL(window.location.href); + const fromUrl = url.searchParams.get("debug"); + if (fromUrl === "1") { + localStorage.setItem(DEBUG_STORAGE_KEY, "1"); + setDebug(true); + return; + } + if (fromUrl === "0") { + localStorage.removeItem(DEBUG_STORAGE_KEY); + setDebug(false); + return; + } + setDebug(localStorage.getItem(DEBUG_STORAGE_KEY) === "1"); + }, []); + + const toggle = useCallback(() => { + setDebug((cur) => { + const next = !cur; + if (next) localStorage.setItem(DEBUG_STORAGE_KEY, "1"); + else localStorage.removeItem(DEBUG_STORAGE_KEY); + toast.message(next ? "Debug mode on" : "Debug mode off", { + description: next + ? "Model picker + per-call cost details are visible." + : "Default product view restored.", + }); + return next; + }); + }, []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ( + e.key.toLowerCase() === "d" && + e.shiftKey && + (e.metaKey || e.ctrlKey) + ) { + e.preventDefault(); + toggle(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [toggle]); + + return [debug, toggle]; +} + +export default function Page({ authed, context }: Props) { + const credits = useAiCredits(); + const [history, setHistory] = useState([]); + const [modelId, setModelId] = useState(DEFAULT_MODEL_ID); + const [lastCall, setLastCall] = useState<{ + model_id: string; + costMills: number; + realCostUsd: number; + usage: RunChatData["usage"]; + } | null>(null); + const [pending, startTransition] = useTransition(); + const [refreshing, startRefresh] = useTransition(); + const [debug] = useDebugFlag(); + + const organizationId = context?.organizationId; + const billingHref = context + ? `/organizations/${context.organizationSlug}/settings/billing` + : null; + const lowBalance = + credits.cents !== null && credits.cents < AI_GATE_FLOOR_CENTS; + + const onRefresh = useCallback(() => { + if (!organizationId) return; + startRefresh(async () => { + await credits.refresh(); + }); + }, [organizationId, credits]); + + const handleSubmit = useCallback( + (message: PromptInputMessage) => { + const text = message.text?.trim(); + if (!text || pending) return; + + const next: ChatTurn[] = [...history, { role: "user", content: text }]; + setHistory(next); + + startTransition(async () => { + const env = await runChat({ + organizationId, + model_id: modelId, + history, + message: text, + }); + + const data = credits.consume(env, { next: "/ai" }); + if (data) { + setHistory([...next, { role: "assistant", content: data.reply }]); + setLastCall({ + model_id: data.model_id, + costMills: data.costMills, + realCostUsd: data.realCostUsd, + usage: data.usage, + }); + return; + } + + // Failure — `consume` handled redirects; we own the toast UX + // (bespoke `blocked` styling with a Top-up CTA). Roll back the + // optimistic user turn so the prompt can be retried. + setHistory((h) => h.slice(0, -1)); + if (env.success === false) { + const action = resolveAiError(env, { next: "/ai" }); + if (action.kind === "toast") { + if (env.code === "blocked" && billingHref) { + toast.warning(action.message, { + action: { + label: "Top up", + onClick: () => { + window.location.href = billingHref; + }, + }, + }); + } else { + toast.error(action.message); + } + } + } + }); + }, + [history, organizationId, modelId, pending, billingHref, credits] + ); + + const selectedModel = + MODEL_OPTIONS.find((m) => m.id === modelId) ?? MODEL_OPTIONS[1]!; + + return ( +
+ {/* ----- Header ------------------------------------------------------ */} +
+
+
+ + AI Chat +
+ +
+ {credits.cents !== null && billingHref ? ( +
+ + + + + {credits.formatted ?? "—"} + + + +
+ {credits.formattedExact ?? "—"} +
+
+ live ·{" "} + {lowBalance + ? `below floor (${fmtUsd(AI_GATE_FLOOR_CENTS)}) — click to top up` + : "click to manage billing"} +
+
+
+
+ +
+ ) : ( + + no balance + + )} +
+
+
+ + {/* ----- Guest / no-org banner -------------------------------------- */} + {(!authed || !context) && ( +
+
+ {!authed ? ( + <> + You’re browsing as a guest. Sending a message will + redirect you to sign-in. + + ) : ( + <> + You’re signed in but not in any organization. Sending a + message will redirect you to onboarding. + + )} +
+
+ )} + + {/* ----- Conversation ----------------------------------------------- */} +
+ + + {history.length === 0 && !pending ? ( + } + title="Start a conversation" + description="Ask anything. Each turn is billed at provider cost from your AI credit balance." + /> + ) : ( + <> + {history.map((turn, i) => ( + + + {/* `mode="static"` is critical here. Streamdown's + default streaming mode wraps its block-state + update in `useTransition`; that transition + gets blocked by our outer chat transition, + leaving the bubble empty until the response + lands. Our backend returns complete strings + per call, so static rendering is correct. */} + {turn.content} + + + ))} + {pending && ( + + +
+ + Thinking… +
+
+
+ )} + + )} +
+ +
+ + {/* ----- Last-call diagnostics (debug only) ----------------------- */} + {debug && lastCall && ( +
+ + model: {lastCall.model_id} + + + real:{" "} + {AiCredits.format.usd(lastCall.realCostUsd)} + + + metered: {lastCall.costMills}m{" "} + ({AiCredits.format.usd(lastCall.costMills / 1000)}) + + {lastCall.realCostUsd > 0 && ( + + Δ: + + {( + (lastCall.costMills / 1000 / lastCall.realCostUsd - 1) * + 100 + ).toFixed(0)} + % + + )} + + tokens:{" "} + {lastCall.usage.inputTokens}in / {lastCall.usage.outputTokens}out + {lastCall.usage.cacheReadTokens + ? ` / ${lastCall.usage.cacheReadTokens}cache` + : ""} + +
+ )} + + {/* ----- Prompt input ------------------------------------------- */} + + + + + + + + + + + + + {context && + !credits.allowed && + credits.cents !== null && + billingHref && ( +

+ Your AI balance is below the {fmtUsd(AI_GATE_FLOOR_CENTS)} floor — + calls will be blocked.{" "} + + Top up + + . +

+ )} +
+
+ ); +} diff --git a/editor/app/(www)/(ai)/ai/music/page.tsx b/editor/app/(www)/(ai)/ai/music/page.tsx index 4e51a6dd6e..b1c07b338b 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 (
-