diff --git a/.gitignore b/.gitignore index 291ad9d62b..4bb6ae57be 100644 --- a/.gitignore +++ b/.gitignore @@ -107,8 +107,10 @@ typings/ # dotenv environment variables file .env -.env.test .env*.local +# Note: `.env.test` is intentionally NOT ignored — it holds non-secret, +# deterministic test defaults (e.g. local Supabase URL, BILLING_E2E gate). +# Per-developer secrets go in `.env.test.local`, which is covered above. # parcel-bundler cache (https://parceljs.org/) .cache @@ -232,3 +234,6 @@ __pycache__/ .claude/worktrees .claude/scheduled_tasks.lock CLAUDE.local.md + +# Local-only tracking docs +/TODO.md diff --git a/database/database-generated.types.ts b/database/database-generated.types.ts index f03d85f262..225e26aeac 100644 --- a/database/database-generated.types.ts +++ b/database/database-generated.types.ts @@ -4403,9 +4403,9 @@ export type Database = { created_at: string description: string | null display_name: string - display_plan: Database["public"]["Enums"]["pricing_tier"] email: string | null id: number + is_enterprise: boolean name: string owner_id: string } @@ -4415,9 +4415,9 @@ export type Database = { created_at?: string description?: string | null display_name?: string - display_plan?: Database["public"]["Enums"]["pricing_tier"] email?: string | null id?: number + is_enterprise?: boolean name: string owner_id?: string } @@ -4427,9 +4427,9 @@ export type Database = { created_at?: string description?: string | null display_name?: string - display_plan?: Database["public"]["Enums"]["pricing_tier"] email?: string | null id?: number + is_enterprise?: boolean name?: string owner_id?: string } @@ -4632,7 +4632,103 @@ export type Database = { } } Views: { - [_ in never]: never + v_billing_audit: { + Row: { + amount_cents: number | null + attempt_count: number | null + billing_reason: string | null + created_at: string | null + event_type: string | null + id: number | null + member_user_id: string | null + new_quantity: number | null + note: string | null + operation: string | null + organization_id: number | null + plan: string | null + prev_quantity: number | null + status: string | null + stripe_customer_id: string | null + stripe_event_id: string | null + stripe_invoice_id: string | null + stripe_subscription_id: string | null + user_id: string | null + } + Insert: { + amount_cents?: number | null + attempt_count?: number | null + billing_reason?: string | null + created_at?: string | null + event_type?: string | null + id?: number | null + member_user_id?: string | null + new_quantity?: number | null + note?: string | null + operation?: string | null + organization_id?: number | null + plan?: string | null + prev_quantity?: number | null + status?: string | null + stripe_customer_id?: string | null + stripe_event_id?: string | null + stripe_invoice_id?: string | null + stripe_subscription_id?: string | null + user_id?: string | null + } + Update: { + amount_cents?: number | null + attempt_count?: number | null + billing_reason?: string | null + created_at?: string | null + event_type?: string | null + id?: number | null + member_user_id?: string | null + new_quantity?: number | null + note?: string | null + operation?: string | null + organization_id?: number | null + plan?: string | null + prev_quantity?: number | null + status?: string | null + stripe_customer_id?: string | null + stripe_event_id?: string | null + stripe_invoice_id?: string | null + stripe_subscription_id?: string | null + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "audit_organization_id_fkey" + columns: ["organization_id"] + isOneToOne: false + referencedRelation: "organization" + referencedColumns: ["id"] + }, + ] + } + v_billing_subscription: { + Row: { + cancel_at_period_end: boolean | null + current_period_end: string | null + current_period_start: string | null + is_free: boolean | null + organization_id: number | null + plan: string | null + quantity: number | null + status: string | null + stripe_customer_id: string | null + stripe_subscription_id: string | null + } + Relationships: [ + { + foreignKeyName: "subscription_organization_id_fkey" + columns: ["organization_id"] + isOneToOne: false + referencedRelation: "organization" + referencedColumns: ["id"] + }, + ] + } } Functions: { delete_project: { @@ -4656,6 +4752,59 @@ export type Database = { } } flatten_jsonb_object_values: { Args: { obj: Json }; Returns: string } + fn_billing_apply_stripe_event: { + Args: { p_event_id: string; p_event_type: string; p_payload: Json } + Returns: { + handler: string + result: string + }[] + } + fn_billing_attach_stripe_customer: { + Args: { p_org_id: number; p_stripe_customer_id: string } + Returns: { + attached: boolean + stripe_customer_id: string + }[] + } + fn_billing_get_active_subscription: { + Args: { p_org_id: number } + Returns: { + cancel_at_period_end: boolean + current_period_end: string + current_period_start: string + plan: string + quantity: number + status: string + stripe_subscription_id: string + }[] + } + fn_billing_get_catalogue: { + Args: { p_id: string } + Returns: { + stripe_price_id: string + stripe_product_id: string + }[] + } + fn_billing_get_customer_id: { + Args: { p_org_id: number } + Returns: string + } + fn_billing_setup_product: { + Args: { + p_grida_billing_id: string + p_stripe_price_id: string + p_stripe_product_id: string + } + Returns: { + id: string + stripe_price_id: string + stripe_product_id: string + }[] + } + fn_billing_stamp_failure: { + Args: { p_event_id: string; p_event_type: string; p_reason: string } + Returns: undefined + } gen_random_slug: { Args: never; Returns: string } generate_combinations: | { @@ -4746,7 +4895,6 @@ export type Database = { | "ar" | "hi" | "nl" - pricing_tier: "free" | "v0_pro" | "v0_team" | "v0_enterprise" } CompositeTypes: { [_ in never]: never @@ -5240,8 +5388,6 @@ export const Constants = { "hi", "nl", ], - pricing_tier: ["free", "v0_pro", "v0_team", "v0_enterprise"], }, }, } as const - diff --git a/docs/contributing/billing.md b/docs/contributing/billing.md new file mode 100644 index 0000000000..319334e8a8 --- /dev/null +++ b/docs/contributing/billing.md @@ -0,0 +1,115 @@ +# 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. + +> **We don't share Stripe credentials.** Every contributor uses their own free Stripe test account. + +--- + +## 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). + +--- + +## 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 + +```bash +supabase start +supabase db reset +``` + +### 3. Secrets in `.env.test.local` + +Committed defaults live in `editor/.env.test`. Secrets go in `editor/.env.test.local` (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 +``` + +### 4. Provision Stripe products + portal config + +```bash +pnpm tsx editor/scripts/billing/setup-stripe-test.ts +``` + +Idempotent. Creates products/prices in your sandbox and writes the resulting Stripe IDs into the catalog. Re-run after every `supabase db reset`. + +### 5. Forward webhooks + +In a dedicated terminal, kept open during development: + +```bash +stripe listen --forward-to localhost:3000/private/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. + +### 6. 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. + +--- + +## E2E suite + +Three integration tests against your real Stripe sandbox. Refuses to start unless every channel is demonstrably test-mode. + +```bash +pnpm --filter editor vitest run lib/billing/__tests__/e2e +``` + +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. + +User-facing billing docs: [`docs/platform/billing.mdx`](../platform/billing.mdx). Behaviour test cases live at `test/billing-*.md` in the repo root. + +--- + +## 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. + +--- + +## Required env reference + +| Variable | Where | +| --------------------------------------------- | ------------------------------ | +| `NEXT_PUBLIC_SUPABASE_URL` | `editor/.env.test` (committed) | +| `SUPABASE_SECRET_KEY` | `editor/.env.test.local` | +| `STRIPE_SECRET_KEY` | `editor/.env.test.local` | +| `STRIPE_WEBHOOK_SECRET` | `editor/.env.test.local` | +| `BILLING_E2E`, `BILLING_TEST_MODE`, `APP_URL` | `editor/.env.test` (committed) | diff --git a/docs/platform/billing.mdx b/docs/platform/billing.mdx new file mode 100644 index 0000000000..2c0829ffd7 --- /dev/null +++ b/docs/platform/billing.mdx @@ -0,0 +1,231 @@ +--- +title: How Grida billing works +description: A plain-English guide to Grida's plans, AI credits, and billing. No legalese. +keywords: + - billing + - pricing + - plans + - AI credits + - subscription + - top-up + - Pro + - Team +sidebar_label: Billing +sidebar_position: 1 +--- + +# How Grida billing works + +This page explains how Grida charges you, how AI credits work, and what to expect on your bill. If you have a specific question, jump to the [Common questions](#common-questions) at the bottom. + +> Heads up: specific dollar amounts on this page (plan price, monthly credit allotment, top-up range) can change as we tune things. We'll update this doc when they do. + +## Plans + +Grida has four plans. + +| Plan | Price | Monthly AI credit | +| ---------- | -------------------------- | -------------------------------------- | +| Free | $0 | $0.50 (shared by everyone in your org) | +| Pro | **$20 / month** | **$10**, pooled across the org | +| Team | **$60 / month** | **$35**, pooled across the org | +| Enterprise | Custom (from $599 / month) | Custom | + +**Today, Pro and Team are flat-rate plans.** One subscription per organization at a single price — Pro is $20/month, Team is $60/month — regardless of how many teammates you've invited. Everyone in the org collaborates on the same projects and shares the same AI credit pool. Per-seat billing (with prorated invites and credit that scales with headcount) is on the roadmap; until it ships, your invoice doesn't change when teammates join or leave. + +The difference between Pro and Team is what's bundled with the base price: Team gets a bigger AI credit allotment, more storage, more monthly active users on published projects, and chat support. You can switch between them whenever your team's needs change — Stripe prorates the difference automatically. + +Annual billing on Pro and Team is available at a **20% discount** ($192/yr for Pro, $576/yr for Team). The annual discount comes out of platform margin — your monthly AI credit is the same on monthly or annual. + +## How AI credits work + +Every AI feature in Grida — generating an image, generating audio, and so on — costs credit. Your balance drops by exactly what we paid the model provider for that call. + +A few things to know: + +- **AI is sold at cost.** We don't mark it up. If a provider charges us $0.04 for an image, you pay $0.04. +- **Monthly credit doesn't roll over.** Whatever's left of your monthly credit at the end of your billing period is gone. This is how monthly plans normally work — same idea as Vercel, v0, Linear, or Cursor. +- **Top-up credit never expires.** If you bought $25 of credit and only used $3, the other $22 stays in your account. Even if you cancel and come back next year, it's still there. +- **Monthly credit is spent first.** Because monthly credit is about to expire, we burn it before touching your top-up balance. That way your top-ups stick around as long as possible. + +### The $0.25 minimum balance + +If your balance drops below **$0.25**, AI calls are blocked until you top up or your monthly credit refreshes. + +This is a safety floor. No AI feature in Grida today costs more than $0.25 per call, so the floor guarantees no single call can drive you into the negative. You're never going to get a surprise bill from one expensive request. + +If we add models in the future that cost more per call (video, for instance), we'll raise the floor for those features and clearly mark it. + +## Top-ups + +When your monthly credit runs out, you have two choices: wait for the next month, or buy more credit on demand. + +A top-up is a one-time purchase. You pick any amount between **$5 and $1000** and that exact amount lands in your balance. + +Card processors take a small fee on every transaction. We pass that through transparently — we don't pad it or absorb it into the credit you receive. So if you top up $25: + +- You receive **$25.00** of credit. +- Your card is charged about **$26.06** ($25.00 + roughly 2.9% + $0.30 for the processor). + +Both numbers are shown on the checkout page and on your receipt before you confirm. Nothing surprising. + +Top-up credit never expires. + +## What happens when you run out + +If your balance hits the $0.25 floor: + +- AI features pause. +- Everything else keeps working — saving, editing, designing, exporting, all of it. Only the AI buttons stop responding. +- You can either wait for next month's credit to land, or top up. + +We will never charge your card to "cover" an AI call. You only ever spend credit you've already paid for or that came with your plan. + +## What happens if your card fails + +This section is for paid plans only. Free users have nothing to renew. + +Each month we try to charge your card for the renewal. If the charge fails (expired card, insufficient funds, anything), here's what happens: + +- **Your subscription pauses quickly.** Not in 24 hours, not after a 7-day grace period — usually within a few minutes of the failed charge. +- **Your monthly AI credit is suspended** until the card succeeds. +- **Top-up credit keeps working.** You bought it, you own it. Use it whenever. +- **We retry the card automatically** a handful of times over the next two weeks. The moment a retry succeeds, your plan turns back on and the monthly credit lands. +- You can update your card any time from the billing settings page. As soon as the new card succeeds, you're back. + +> Good to know: while your plan is paused for non-payment, you do **not** drop down to the Free $0.50 monthly credit. A pause is a "payment broken" state, not a downgrade. To return to Free properly (and get the $0.50 monthly), you need to actually cancel. + +## Working with a team + +Everyone in your org collaborates on the same projects and shares the same AI credit pool — the way Cursor or Linear's free tier handles teamwork. + +A few things to know: + +- **The subscription is one flat price** — Pro $20/month, Team $60/month — regardless of how many teammates you invite. Your invoice doesn't change when people join or leave. +- **Inviting and removing teammates is free** today. Add as many as you want. +- **AI credit pools at the org level.** A teammate's heavy week doesn't strand yours; everyone draws from the same balance. +- **Free orgs can have multiple members.** They share the $0.50 monthly credit pool — same total whether your org has 1 person or 5. +- **Only the org owner can manage billing** today. Member-role permissions are on the roadmap. +- **Per-seat billing is coming.** When it ships, prorated invites, prorated removals, and credit that scales with headcount will turn on for new and existing paid orgs alike. Until then, the flat plan price is what you pay. + +## Pro vs. Team: how to choose + +Both plans pool credit at the org level. Pick based on how much AI your team uses: + +- **Pro** ($20/month) is for individuals and small teams whose AI usage fits comfortably in $10/month, or who plan to top up occasionally. +- **Team** ($60/month) is for teams that lean heavily on AI — the larger $35 credit pool means less time spent topping up, and the plan also raises storage and monthly-active-user limits. + +You can switch between Pro and Team at any time. Stripe prorates the difference automatically, and your top-up balance carries across the switch unchanged. + +## Cancel anytime + +You can cancel from your account settings at any time, no questions asked. + +When you cancel: + +- You keep your plan and the current month's credit until the end of the period you've already paid for. +- After that, you switch to Free and start getting $0.50 of credit each month (shared across the org if you have multiple members). +- Any top-up credit you have stays in your account. + +There's nothing to "downgrade" manually. Cancellation handles it. + +## Examples + +A few quick walkthroughs to make this concrete. + +### Maya is on Free + +Maya has $0.50 of credit at the start of the month. + +- She generates 5 images at $0.04 each → spends $0.20. Balance: $0.30. +- She generates an audio clip at $0.04 → balance: $0.26. +- She tries one more image. Blocked — she's below the $0.25 floor. + +She waits a week. The new month rolls over. Her balance refreshes to $0.50 and she's back in business. + +### Jordan upgrades to Pro + +Jordan upgrades the org to Pro — $20/month, $10 of monthly AI credit. + +Jordan generates a lot of images and a lot of audio tracks throughout the month. Late one night, the balance is at $0.20 — blocked by the floor. + +Jordan tops up $25. + +- The card is charged about **$26.06** (the $25 top-up plus the processor fee). +- Jordan's balance becomes **$25.20**. + +Jordan keeps working. When the next month begins, $10 of fresh Pro credit lands on top of whatever top-up credit is still left. + +### Priya's team upgrades to Team + +Priya runs a 4-person design team on Free. They share $0.50/month — clearly not enough for the AI work they want to do. + +Priya considers Pro ($20/month with $10 of pooled credit) but the team generates a lot of images, so she upgrades the org to **Team** instead. + +- The first invoice is **$60** — the flat Team subscription. +- The team's credit pool jumps to **$35/month**, shared across all 4 people. Anyone on the team can spend any of it. + +A week later, they invite a fifth person. The invoice doesn't change — the flat $60/month covers the whole org. + +A month later, someone leaves the team. Priya removes them. Still $60/month; nothing else changes. (When per-seat billing ships, this is the section that will gain prorated math.) + +### Sam's card fails + +Sam is on Pro and has $25.00 in top-up credit sitting in the account. + +The monthly Pro renewal hits Sam's card. The charge fails (expired card). + +- Pro pauses. Sam's $10 monthly credit is suspended. +- Sam's $25 top-up credit keeps working — Sam can still use AI features against that balance. + +Three days later, the card is renewed and the next retry succeeds. + +- Pro resumes immediately. +- The next monthly $10 lands on schedule on Sam's normal renewal date. + +## Common questions + +**Do my unused monthly credits roll over?** +No. Monthly credit (Free, Pro, or Team) resets each billing period. This is how all monthly plans work. + +**What about credits I bought as a top-up?** +Top-up credit never expires. It stays in your balance until you spend it, even if you cancel and come back later. + +**Can I get a refund?** +See our refund policy at `/support/refund-policy`. The short version: we'll work with you on accidental top-ups and obvious billing errors. + +**What happens if I cancel mid-month?** +You keep your plan and your remaining monthly credit until the end of the period you've already paid for. After that, you're on Free. Top-ups stay. + +**Why is there a $0.25 minimum balance?** +So no single AI call can ever take you negative. It's a safety floor, not a fee. + +**Are AI prices marked up?** +No. We charge you exactly what the model provider charges us. We make our money on the plan base price (the part that isn't AI credit), not on AI usage. + +**Can I see how much I've used?** +Yes — your billing settings show your current balance, what was spent this month, and a per-call history. + +**What payment methods do you accept?** +Major credit and debit cards. Apple Pay and Google Pay where supported by your browser. + +**Do you charge tax?** +We collect VAT/GST/sales tax where the law requires it, based on your billing address. Tax (if any) is shown on the checkout page before you confirm and itemized on your receipt. + +**I run a team — how does pricing work?** +Today, Pro and Team are flat-rate plans — one subscription per org at one price ($20 or $60 per month) regardless of how many teammates you've invited. AI credit is shared across the team. Per-seat billing is on the roadmap. + +**What's the difference between Pro and Team?** +Both pool credit at the org level. Team raises the AI credit ($35 vs $10), storage, and monthly active users on published projects, and adds chat support. The decision usually comes down to how much AI your team actually uses. + +**Is the credit really shared, or is each person locked to their seat's allotment?** +Shared. We pool it at the org level so a teammate's heavy week doesn't stop yours, and an admin's spending doesn't strand junior team members. If you need stricter per-user controls, that's on the roadmap. + +**Who can manage billing for a team?** +The org owner today. Member-role permissions (admin, billing-only, etc.) are coming. + +**Is there an annual plan?** +Yes — Pro and Team are both available annually at a 20% discount. The discount comes out of platform margin, not your AI credit; monthly credit is the same either way. + +**What if a model's price changes?** +Providers occasionally adjust their prices. When they do, we update what we charge you to match. We never charge you more than we pay the provider. diff --git a/editor/.env.test b/editor/.env.test new file mode 100644 index 0000000000..f39f50ed6b --- /dev/null +++ b/editor/.env.test @@ -0,0 +1,57 @@ +# ============================================================================= +# Test environment defaults for the editor package. +# +# This file is COMMITTED and holds non-secret, deterministic defaults that +# apply to every developer's test runs. Secrets (Stripe keys, etc.) belong +# in `.env.test.local`, which is gitignored. +# +# Vitest picks both files up automatically via `vitest.config.ts`, so +# `pnpm vitest run lib/billing/__tests__/e2e` works with no shell ceremony. +# +# Loading precedence (highest priority wins; existing process.env is never +# overridden — see `loadEnvFile()` in vitest.config.ts): +# 1. process.env (from your shell) ← highest priority +# 2. .env.test.local ← per-developer secrets (gitignored) +# 3. .env.test ← this file (committed defaults) +# ============================================================================= + +NODE_ENV=test + +# ----------------------------------------------------------------------------- +# Billing E2E suite gates +# ----------------------------------------------------------------------------- +# `BILLING_E2E=1` is intentionally NOT set here — the suite is opt-in +# because it requires Stripe credentials in `.env.test.local`. Enable it +# locally with `BILLING_E2E=1 pnpm vitest run lib/billing/__tests__/e2e`, +# or by adding `BILLING_E2E=1` to your `.env.test.local`. +BILLING_TEST_MODE=true + +# Local dev server. The signed webhooks the suite generates are POSTed here. +APP_URL=http://localhost:3000 + +# ----------------------------------------------------------------------------- +# Supabase — must be a local instance. The safety guard refuses any other +# host. +# ----------------------------------------------------------------------------- +NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 + +# `SUPABASE_SECRET_KEY` (sb_secret_…) is generated per-machine by the +# Supabase CLI on first `supabase start`. Copy it into `.env.test.local`: +# supabase status -o env | grep SUPABASE_SECRET_KEY >> editor/.env.test.local + +# ----------------------------------------------------------------------------- +# Stripe — fill in `.env.test.local` (gitignored). Each contributor uses +# their own free Stripe test account; we don't share credentials. +# +# STRIPE_SECRET_KEY=sk_test_... +# STRIPE_WEBHOOK_SECRET=whsec_... +# +# Setup flow for a fresh contributor: +# 1. Sign up at https://dashboard.stripe.com (free, no payment info) +# 2. Switch to test mode → Developers → API keys → copy `sk_test_...` +# 3. `stripe listen --forward-to localhost:3000/private/webhooks/stripe` +# → copy the `whsec_...` it prints +# 4. Drop both into `editor/.env.test.local` +# 5. Run `pnpm tsx editor/scripts/billing/setup-stripe-test.ts` once to +# provision per-account products/prices in your Stripe sandbox. +# ----------------------------------------------------------------------------- diff --git a/editor/.oxlintrc.jsonc b/editor/.oxlintrc.jsonc index 76753dca2d..ff6bfb38e8 100644 --- a/editor/.oxlintrc.jsonc +++ b/editor/.oxlintrc.jsonc @@ -4,6 +4,26 @@ "rules": { // --- react-hooks (all OFF — progressive enablement) ------------ "react/exhaustive-deps": "off", + + // --- billing single-seam enforcement ------------------------------- + // The Stripe SDK may only be imported by `editor/lib/billing/index.ts`. + // Other call sites must go through that seam so billing-side concerns + // (test-mode guard, money-safety, audit) are on by default. + // + // Type-only imports are allowed everywhere; only value imports are + // restricted. + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "stripe", + "message": "Use editor/lib/billing instead.", + "allowTypeImports": true, + }, + ], + }, + ], }, "ignorePatterns": [ // shadcn / generated UI (no control over code): @@ -11,4 +31,13 @@ "components/ui2/**", "components/ai-elements/**", ], + "overrides": [ + { + // The single Stripe seam. + "files": ["lib/billing/index.ts"], + "rules": { + "no-restricted-imports": "off", + }, + }, + ], } diff --git a/editor/app/(api)/private/webhooks/stripe/route.ts b/editor/app/(api)/private/webhooks/stripe/route.ts new file mode 100644 index 0000000000..534138cbef --- /dev/null +++ b/editor/app/(api)/private/webhooks/stripe/route.ts @@ -0,0 +1,91 @@ +/** + * 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/(api)/private/workspace/[organization_id]/route.ts b/editor/app/(api)/private/workspace/[organization_id]/route.ts index 4652d8caeb..a28880e430 100644 --- a/editor/app/(api)/private/workspace/[organization_id]/route.ts +++ b/editor/app/(api)/private/workspace/[organization_id]/route.ts @@ -28,6 +28,23 @@ export async function GET( return notFound(); } + // Resolve plan per org from the Stripe-backed billing source. Orgs without + // an active subscription fall back to "free". + const orgIds = organizations.map((o) => o.id); + const { data: billingRows } = await client + .from("v_billing_subscription") + .select("organization_id, plan") + .in("organization_id", orgIds); + const planByOrg = new Map(); + for (const r of billingRows ?? []) { + if (r.organization_id != null && r.plan) { + const p = r.plan; + if (p === "free" || p === "pro" || p === "team") { + planByOrg.set(r.organization_id, p); + } + } + } + const { data: projects, error: __projects_err } = await client .from("project") .select("*") @@ -60,6 +77,7 @@ export async function GET( organizations: organizations.map((org) => ({ ...org, avatar_url: org.avatar_path ? avatar_url(org.avatar_path) : null, + plan: planByOrg.get(org.id) ?? "free", })), projects, documents, diff --git a/editor/app/(insiders)/insiders/auth/basic/sign-in/route.ts b/editor/app/(insiders)/insiders/auth/basic/sign-in/route.ts index 367f381078..1d01bbf303 100644 --- a/editor/app/(insiders)/insiders/auth/basic/sign-in/route.ts +++ b/editor/app/(insiders)/insiders/auth/basic/sign-in/route.ts @@ -28,7 +28,7 @@ export async function POST(req: NextRequest) { console.log("[INSIDER] Sign in successful"); if (redirect_uri) { - return NextResponse.redirect(redirect_uri, { + return NextResponse.redirect(new URL(redirect_uri, requestUrl.origin), { status: 302, }); } diff --git a/editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx b/editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx new file mode 100644 index 0000000000..d315cf3e74 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx @@ -0,0 +1,98 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { ArrowLeftIcon } from "lucide-react"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarSeparator, +} from "@/components/ui/sidebar"; + +type SettingsCategory = { href: string; label: string }; + +export default function SettingsShell({ + orgName, + plan, + children, +}: { + orgName: string; + plan: string; + children: React.ReactNode; +}) { + const pathname = usePathname(); + const settingsBase = `/organizations/${orgName}/settings`; + const isPaidPlan = plan === "pro" || plan === "team"; + + const categories: ReadonlyArray = [ + { href: `${settingsBase}/profile`, label: "Profile" }, + { href: `${settingsBase}/billing`, label: "Billing" }, + ...(isPaidPlan + ? [] + : [{ href: `${settingsBase}/billing/upgrade`, label: "Upgrade plan" }]), + ]; + + return ( + + + + + {orgName} + + + + Back to organization + + + + + + Settings + + + {categories.map((c) => { + // A parent's prefix-match would also light up when a child + // route is active (e.g. `/billing` matching on `/billing/upgrade`). + // Disable prefix-match when any sibling category is a child of + // this one — the more specific category will mark itself active. + const hasSiblingChild = categories.some( + (other) => + other !== c && other.href.startsWith(`${c.href}/`) + ); + const isActive = hasSiblingChild + ? pathname === c.href + : pathname === c.href || + (pathname?.startsWith(`${c.href}/`) ?? false); + return ( + + + {c.label} + + + ); + })} + + + + + + {children} + + ); +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/@modal/(.)upgrade/page.tsx b/editor/app/(site)/organizations/[organization_name]/settings/billing/@modal/(.)upgrade/page.tsx new file mode 100644 index 0000000000..cf2a7e5747 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/@modal/(.)upgrade/page.tsx @@ -0,0 +1,34 @@ +import { notFound, redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; +import UpgradeView from "../../upgrade/_view"; +import { BillingModal } from "../../_modal-shell"; + +type Params = { organization_name: string }; + +export default async function UpgradeModalIntercept({ + params, +}: { + params: Promise; +}) { + const { organization_name } = await params; + const client = await createClient(); + const { data: auth } = await client.auth.getUser(); + if (!auth.user) return redirect("/sign-in"); + + const { data: org } = await client + .from("organization") + .select("id, name") + .eq("name", organization_name) + .single(); + + if (!org) return notFound(); + + return ( + + + + ); +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/@modal/default.tsx b/editor/app/(site)/organizations/[organization_name]/settings/billing/@modal/default.tsx new file mode 100644 index 0000000000..d1cc1448ba --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/@modal/default.tsx @@ -0,0 +1,6 @@ +// Parallel route slot fallback. Returns null when no intercepting route matches +// (i.e., on the `/billing` page itself). Required by Next.js for parallel slots +// to render predictably during navigation. +export default function ModalDefault() { + return null; +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts b/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts new file mode 100644 index 0000000000..7adfe3b0a1 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts @@ -0,0 +1,687 @@ +"use server"; + +// Reads use `createClient()` (user-authed, RLS-aware) against `v_billing_*`. +// Mutations and Stripe-side reads go through `service_role.workspace`. + +import { createClient } from "@/lib/supabase/server"; +import { + stripe, + BillingError, + assertOrgMember, + assertOrgOwner, + resolveOrCreateStripeCustomer, + getCatalogueStripeIds, + getActivePaidSubscription, + getCustomerId, + assertAllowedRedirect, +} from "@/lib/billing"; +import { + price_catalogue_id, + type Interval, + type PaidPlanId, + type PlanId, +} from "@/lib/billing/plans"; +import { headers } from "next/headers"; + +function asPlanId(raw: string | null | undefined): PlanId { + return raw === "pro" || raw === "team" ? raw : "free"; +} + +async function requireUserId(): Promise { + const sb = await createClient(); + const { data } = await sb.auth.getUser(); + if (!data.user) { + throw new BillingError("unauthorized", "unauthorized", 401); + } + return data.user.id; +} + +async function getOrigin(): Promise { + const h = await headers(); + const fromHeader = h.get("origin"); + if (fromHeader) return fromHeader; + const proto = h.get("x-forwarded-proto") ?? "https"; + const host = h.get("x-forwarded-host") ?? h.get("host"); + if (host) return `${proto}://${host}`; + const base = process.env.NEXT_PUBLIC_BASE_URL; + if (base) return new URL(base).origin; + throw new Error("could not determine request origin"); +} + +// --------------------------------------------------------------------------- +// getBillingSummary +// --------------------------------------------------------------------------- + +export type BillingSummary = { + org_id: number; + plan: PlanId; + status: string; + is_free: boolean; + current_period_start: string | null; + current_period_end: string | null; + cancel_at_period_end: boolean; + has_active_subscription: boolean; + /** "month" | "year" for paid subs; null for free. Read from Stripe. */ + interval: Interval | null; + /** Server-side test-mode signal (BILLING_TEST_MODE env). Drives the sandbox + * disclaimer on the billing page — never default to true client-side. */ + is_test_mode: boolean; +}; + +export async function getBillingSummary( + org_id: number +): Promise { + const user_id = await requireUserId(); + await assertOrgMember(user_id, org_id); + + const sb = await createClient(); + const subRes = await sb + .from("v_billing_subscription") + .select("*") + .eq("organization_id", org_id) + .maybeSingle(); + if (subRes.error) { + throw new Error(`v_billing_subscription: ${subRes.error.message}`); + } + + const sub = subRes.data; + let interval: Interval | null = null; + if (sub?.stripe_subscription_id) { + const stripeSub = await stripe.subscriptions + .retrieve(sub.stripe_subscription_id) + .catch(() => null); + const i = stripeSub?.items.data[0]?.price.recurring?.interval; + if (i === "month" || i === "year") interval = i; + } + + return { + org_id, + plan: asPlanId(sub?.plan), + status: sub?.status ?? "active", + is_free: sub?.is_free ?? true, + current_period_start: sub?.current_period_start ?? null, + current_period_end: sub?.current_period_end ?? null, + cancel_at_period_end: sub?.cancel_at_period_end ?? false, + has_active_subscription: + !!sub?.stripe_subscription_id && sub.status !== "canceled", + interval, + is_test_mode: process.env.BILLING_TEST_MODE === "true", + }; +} + +// --------------------------------------------------------------------------- +// listInvoices +// --------------------------------------------------------------------------- + +type PaymentMethodSummary = { + brand: string; + last4: string; + exp_month: number; + exp_year: number; +} | null; + +type UpcomingSummary = { + amount_due_cents: number; + period_end_unix: number | null; + line_count: number; +} | null; + +type PastInvoice = { + id: string; + status: string; + amount_paid_cents: number; + created_unix: number; + hosted_invoice_url: string | null; + invoice_pdf: string | null; +}; + +export type InvoicesPayload = { + upcoming: UpcomingSummary; + past: PastInvoice[]; + payment_method: PaymentMethodSummary; + billing_email: string | null; +}; + +const TTL_MS = 30_000; +const invoicesCache = new Map(); + +export async function listInvoices(org_id: number): Promise { + const user_id = await requireUserId(); + await assertOrgMember(user_id, org_id); + + const now = Date.now(); + const cached = invoicesCache.get(org_id); + if (cached && now - cached.at < TTL_MS) return cached.data; + + const customerId = await getCustomerId(org_id); + if (!customerId) { + const empty: InvoicesPayload = { + upcoming: null, + past: [], + payment_method: null, + billing_email: null, + }; + invoicesCache.set(org_id, { at: now, data: empty }); + return empty; + } + + const [upcoming, pastList, customer] = await Promise.all([ + stripe.invoices + .createPreview({ customer: customerId }) + .then( + (inv): UpcomingSummary => ({ + amount_due_cents: inv.amount_due ?? 0, + period_end_unix: inv.period_end ?? null, + line_count: inv.lines?.data?.length ?? 0, + }) + ) + .catch(() => null), + stripe.invoices + .list({ customer: customerId, limit: 10 }) + .then((res) => + res.data.map( + (inv): PastInvoice => ({ + id: inv.id ?? "", + status: inv.status ?? "unknown", + amount_paid_cents: inv.amount_paid ?? 0, + created_unix: inv.created ?? 0, + hosted_invoice_url: inv.hosted_invoice_url ?? null, + invoice_pdf: inv.invoice_pdf ?? null, + }) + ) + ) + .catch((): PastInvoice[] => []), + stripe.customers + .retrieve(customerId, { + expand: ["invoice_settings.default_payment_method"], + }) + .catch(() => null), + ]); + + let payment_method: PaymentMethodSummary = null; + let billing_email: string | null = null; + + if (customer && !("deleted" in customer && customer.deleted)) { + billing_email = customer.email ?? null; + const def = customer.invoice_settings?.default_payment_method; + if (def && typeof def !== "string" && def.card) { + payment_method = { + brand: def.card.brand ?? "card", + last4: def.card.last4 ?? "", + exp_month: def.card.exp_month ?? 0, + exp_year: def.card.exp_year ?? 0, + }; + } + } + + const data: InvoicesPayload = { + upcoming, + past: pastList, + payment_method, + billing_email, + }; + invoicesCache.set(org_id, { at: now, data }); + return data; +} + +// --------------------------------------------------------------------------- +// Portal-flow helpers +// +// Every portal session we create is a deep-link `flow_data` session — the +// user lands on a single Stripe-hosted screen scoped to one intent (update +// card, change plan, etc). We deliberately do not expose the generic +// portal dashboard. +// --------------------------------------------------------------------------- + +async function v1ConfigId(): Promise { + const list = await stripe.billingPortal.configurations.list({ limit: 100 }); + return list.data.find((c) => c.metadata?.grida_billing_id === "portal.v1") + ?.id; +} + +export type PortalFlowResult = { portal_url: string }; + +export async function startPaymentMethodUpdate( + org_id: number, + params: { return_url: string } +): Promise { + const user_id = await requireUserId(); + await assertOrgOwner(user_id, org_id); + + const origin = await getOrigin(); + const return_url = assertAllowedRedirect(params.return_url, origin); + + const customerId = await getCustomerId(org_id); + if (!customerId) { + throw new BillingError( + "No Stripe customer for this organization yet.", + "no_stripe_customer", + 400, + "billing/upgrade" + ); + } + + const configuration = await v1ConfigId(); + const session = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url, + ...(configuration ? { configuration } : {}), + flow_data: { + type: "payment_method_update", + after_completion: { + type: "redirect", + redirect: { return_url }, + }, + }, + }); + + return { portal_url: session.url }; +} + +// --------------------------------------------------------------------------- +// startPlanChangeConfirm +// +// Existing-subscription mutation via Stripe Portal's `flow_data` deep link. +// Server picks the target price; Stripe shows a single confirm page (no +// plan picker) with the prorated total. After completion, redirects back. +// Used for paid→paid transitions: plan switch (Pro↔Team) and/or interval +// switch (monthly↔annual). +// --------------------------------------------------------------------------- + +export type PlanChangeConfirmResult = { portal_url: string }; + +export async function startPlanChangeConfirm( + org_id: number, + params: { + plan: PaidPlanId; + interval: Interval; + return_url: string; + } +): Promise { + const user_id = await requireUserId(); + await assertOrgOwner(user_id, org_id); + + if (params.plan !== "pro" && params.plan !== "team") { + throw new BillingError( + `plan must be 'pro' or 'team' (got '${params.plan}').`, + "invalid_plan", + 400 + ); + } + if (params.interval !== "month" && params.interval !== "year") { + throw new BillingError( + `interval must be 'month' or 'year' (got '${params.interval}').`, + "invalid_interval", + 400 + ); + } + + const origin = await getOrigin(); + const return_url = assertAllowedRedirect(params.return_url, origin); + + const sub = await getActivePaidSubscription(org_id); + if (!sub) { + throw new BillingError( + "No active paid subscription to change. Upgrade first.", + "not_subscribed", + 400, + "billing/upgrade" + ); + } + // Mirror the UI's `isDegraded` gate: never open the plan-change Portal flow + // for a sub that is past_due / unpaid / paused / incomplete*. The recovery + // path is "Update payment method" — surface that, don't let Stripe's Portal + // be the final defender. + if (sub.status !== "active" && sub.status !== "trialing") { + throw new BillingError( + "Resolve the current billing issue before changing plans.", + "subscription_degraded", + 409, + "billing" + ); + } + + const customerId = await getCustomerId(org_id); + if (!customerId) { + throw new BillingError( + "This organization does not have a Stripe customer.", + "no_stripe_customer", + 400, + "billing/upgrade" + ); + } + + const id = price_catalogue_id(params.plan, params.interval); + const cat = await getCatalogueStripeIds(id); + if (!cat) { + throw new BillingError( + `Stripe price for ${id} is not yet wired.`, + "billing_not_provisioned", + 500 + ); + } + + // Fetch the current subscription's first item — Portal flow_data + // requires its id to know which item to update, and we preserve the + // existing quantity (v1 = 1) so a plan/interval change doesn't reset it. + const stripeSub = await stripe.subscriptions.retrieve( + sub.stripe_subscription_id + ); + const item = stripeSub.items.data[0]; + if (!item) { + throw new Error( + `Stripe subscription ${sub.stripe_subscription_id} has no items` + ); + } + + const configuration = await v1ConfigId(); + const session = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url, + ...(configuration ? { configuration } : {}), + flow_data: { + type: "subscription_update_confirm", + subscription_update_confirm: { + subscription: sub.stripe_subscription_id, + items: [ + { + id: item.id, + price: cat.stripe_price_id, + quantity: item.quantity ?? 1, + }, + ], + }, + after_completion: { + type: "redirect", + redirect: { return_url }, + }, + }, + }); + + return { portal_url: session.url }; +} + +// --------------------------------------------------------------------------- +// startSubscribeCheckout +// --------------------------------------------------------------------------- + +export type SubscribeCheckoutResult = { + checkout_url: string | null; + session_id: string; +}; + +export async function startSubscribeCheckout( + org_id: number, + params: { + plan: PaidPlanId; + interval?: Interval; + success_url: string; + cancel_url: string; + } +): Promise { + const user_id = await requireUserId(); + await assertOrgOwner(user_id, org_id); + + if (params.plan !== "pro" && params.plan !== "team") { + throw new BillingError( + `plan must be 'pro' or 'team' (got '${params.plan}').`, + "invalid_plan", + 400 + ); + } + const interval: Interval = params.interval ?? "month"; + if (interval !== "month" && interval !== "year") { + throw new BillingError( + `interval must be 'month' or 'year' (got '${params.interval}').`, + "invalid_interval", + 400 + ); + } + + if (await getActivePaidSubscription(org_id)) { + throw new BillingError( + "Organization already has an active paid subscription.", + "already_subscribed", + 409 + ); + } + + const origin = await getOrigin(); + const success_url = assertAllowedRedirect(params.success_url, origin); + const cancel_url = assertAllowedRedirect(params.cancel_url, origin); + + const id = price_catalogue_id(params.plan, interval); + const cat = await getCatalogueStripeIds(id); + if (!cat) { + throw new BillingError( + `Stripe price for ${id} is not yet wired.`, + "billing_not_provisioned", + 500 + ); + } + + // v1 ships single-seat only. Multi-seat billing is deferred — when it + // lands, the quantity will come from a "Manage seats" UI that pushes + // through Stripe, never inferred from member count here. + const quantity = 1; + + // KNOWN ISSUE (TC-BILLING-SUB-059): the local-only check above does not + // prevent two concurrent `startSubscribeCheckout` calls (e.g. user opens + // Checkout in two tabs and pays in both) from producing two live Stripe + // subscriptions. The second `customer.subscription.created` webhook is + // rejected by `subscription_one_active_per_org_idx`, so locally we see one + // sub while Stripe has two. Acceptable for v1 — risk is to Grida (we + // refund manually), not the customer. Closure tracked in GRIDA-60. + + const customer = await resolveOrCreateStripeCustomer(org_id); + const idempotencyKey = `subscribe:${org_id}:${params.plan}:${interval}:${Math.floor(Date.now() / 60000)}`; + + const session = await stripe.checkout.sessions.create( + { + mode: "subscription", + customer, + line_items: [{ price: cat.stripe_price_id, quantity }], + subscription_data: { + metadata: { + grida_organization_id: String(org_id), + grida_plan: params.plan, + grida_interval: interval, + }, + }, + success_url, + cancel_url, + metadata: { + grida_organization_id: String(org_id), + kind: "subscribe", + plan: params.plan, + interval, + }, + allow_promotion_codes: true, + }, + { idempotencyKey } + ); + + return { + checkout_url: session.url, + session_id: session.id, + }; +} + +// --------------------------------------------------------------------------- +// startCancelSubscription +// +// Stripe Portal `subscription_cancel` flow_data — single confirm screen, +// "Cancel at period end" by config. After completion, Stripe fires +// `customer.subscription.updated` with `cancel_at_period_end=true`, which +// the projector mirrors to the local subscription row. +// --------------------------------------------------------------------------- + +export async function startCancelSubscription( + org_id: number, + params: { return_url: string } +): Promise { + const user_id = await requireUserId(); + await assertOrgOwner(user_id, org_id); + + const origin = await getOrigin(); + const return_url = assertAllowedRedirect(params.return_url, origin); + + const sub = await getActivePaidSubscription(org_id); + if (!sub) { + throw new BillingError( + "No active paid subscription to cancel.", + "not_subscribed", + 400, + "billing" + ); + } + + const customerId = await getCustomerId(org_id); + if (!customerId) { + throw new BillingError( + "This organization does not have a Stripe customer.", + "no_stripe_customer", + 400 + ); + } + + const configuration = await v1ConfigId(); + const session = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url, + ...(configuration ? { configuration } : {}), + flow_data: { + type: "subscription_cancel", + subscription_cancel: { subscription: sub.stripe_subscription_id }, + after_completion: { + type: "redirect", + redirect: { return_url }, + }, + }, + }); + + return { portal_url: session.url }; +} + +// --------------------------------------------------------------------------- +// resumeSubscription +// +// Undoes a `cancel_at_period_end=true` flag set by an earlier cancellation, +// while the period is still active. Stripe charges nothing — the existing +// subscription simply continues on its current schedule. +// +// No Portal flow exists for this (Stripe ships `subscription_cancel`, +// `subscription_update_confirm`, `payment_method_update`, but no +// `subscription_resume`), so this is the one Stripe-mutation server action +// that bypasses the Portal. The webhook (`customer.subscription.updated` +// with `cancel_at_period_end=false`) projects the flip back into the local +// row, keeping the "webhook is sole source of truth" rule intact for state. +// --------------------------------------------------------------------------- + +export async function resumeSubscription(org_id: number): Promise { + const user_id = await requireUserId(); + await assertOrgOwner(user_id, org_id); + + const sub = await getActivePaidSubscription(org_id); + if (!sub) { + throw new BillingError( + "No active subscription to resume.", + "not_subscribed", + 400, + "billing" + ); + } + + await stripe.subscriptions.update(sub.stripe_subscription_id, { + cancel_at_period_end: false, + }); +} + +// --------------------------------------------------------------------------- +// listBillingAudit +// +// Owner-only paginated read of the audit feed. Backed by `v_billing_audit` +// which RLS-filters to the org's owner. +// --------------------------------------------------------------------------- + +export type AuditRow = { + id: number; + organization_id: number; + user_id: string | null; + operation: string; + amount_cents: number | null; + stripe_event_id: string | null; + stripe_subscription_id: string | null; + stripe_invoice_id: string | null; + event_type: string | null; + plan: string | null; + status: string | null; + note: string | null; + created_at: string; +}; + +export type AuditListResult = { + rows: AuditRow[]; + next_cursor: string | null; + limit: number; +}; + +export async function listBillingAudit( + org_id: number, + params: { cursor?: string; limit?: number } = {} +): Promise { + const user_id = await requireUserId(); + await assertOrgOwner(user_id, org_id); + + const cursor = params.cursor ?? null; + const limitRaw = Number(params.limit ?? 50); + const limit = Math.max( + 1, + Math.min(Number.isFinite(limitRaw) ? limitRaw : 50, 200) + ); + + // Composite cursor "|" — pure created_at would skip rows + // sharing a boundary timestamp because the page filter would race with the + // `(created_at DESC, id DESC)` order. + let cursorAt: string | null = null; + let cursorId: number | null = null; + if (cursor) { + const sep = cursor.indexOf("|"); + if (sep > 0) { + cursorAt = cursor.slice(0, sep); + const parsed = Number(cursor.slice(sep + 1)); + cursorId = Number.isFinite(parsed) ? parsed : null; + } + } + + const sb = await createClient(); + let q = sb + .from("v_billing_audit") + .select( + "id, organization_id, user_id, operation, amount_cents, stripe_event_id, stripe_subscription_id, stripe_invoice_id, event_type, plan, status, note, created_at" + ) + .eq("organization_id", org_id) + .order("created_at", { ascending: false }) + .order("id", { ascending: false }) + .limit(limit); + + if (cursorAt && cursorId !== null) { + // (created_at, id) lexicographic seek: strictly older timestamp, OR same + // timestamp with strictly smaller id. Expressed as a postgrest `or`. + q = q.or( + `created_at.lt.${cursorAt},and(created_at.eq.${cursorAt},id.lt.${cursorId})` + ); + } else if (cursorAt) { + q = q.lt("created_at", cursorAt); + } + + const { data, error } = await q; + if (error) throw new Error(`audit list: ${error.message}`); + + const rows = (data ?? []) as AuditRow[]; + const last = rows[rows.length - 1]; + const next_cursor = + rows.length === limit && last ? `${last.created_at}|${last.id}` : null; + + return { rows, next_cursor, limit }; +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/_modal-shell.tsx b/editor/app/(site)/organizations/[organization_name]/settings/billing/_modal-shell.tsx new file mode 100644 index 0000000000..d1cb5f2829 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/_modal-shell.tsx @@ -0,0 +1,45 @@ +"use client"; + +import React from "react"; +import { useRouter } from "next/navigation"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +/** + * Wraps an intercepted route's body in a shadcn Dialog. Closing the dialog + * calls router.back() so the user lands back on the billing page they came + * from. Direct navigation (or refresh) bypasses this wrapper entirely and + * renders the full page underneath. + */ +export function BillingModal({ + title, + description, + children, +}: { + title: string; + description?: string; + children: React.ReactNode; +}) { + const router = useRouter(); + return ( + { + if (!open) router.back(); + }} + > + + + {title} + {description && {description}} + + {children} + + + ); +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx b/editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx new file mode 100644 index 0000000000..abd43fb56d --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx @@ -0,0 +1,549 @@ +"use client"; + +import React, { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { CreditCardIcon, ExternalLinkIcon, FileTextIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Separator } from "@/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { format } from "date-fns"; +import { + getBillingSummary, + listInvoices, + resumeSubscription, + startCancelSubscription, + startPaymentMethodUpdate, +} from "./_actions"; +import { + PAID_PLANS, + price_dollars, + price_monthly_equivalent_dollars, + type Interval, + type PaidPlanId, + type PlanId, +} from "@/lib/billing/plans"; + +type BillingState = { + org_id: number; + plan: PlanId; + status: string; + interval: Interval | null; + current_period_start: string | null; + current_period_end: string | null; + cancel_at_period_end: boolean; + has_active_subscription: boolean; + is_test_mode: boolean; +}; + +type PaymentMethod = { + brand: string; + last4: string; + exp_month: number; + exp_year: number; +} | null; + +type UpcomingInvoice = { + amount_due_cents: number; + period_end_unix: number | null; + line_count: number; +} | null; + +type PastInvoice = { + id: string; + status: string; + amount_paid_cents: number; + created_unix: number; + hosted_invoice_url: string | null; + invoice_pdf: string | null; +}; + +type InvoicesState = { + upcoming: UpcomingInvoice; + past: PastInvoice[]; + payment_method: PaymentMethod; + billing_email: string | null; +}; + +function fmtCents(cents: number, decimals = 2): string { + return `$${(cents / 100).toFixed(decimals)}`; +} + +function fmtUnix(unix: number | null): string { + if (!unix) return "—"; + return format(new Date(unix * 1000), "PP"); +} + +function SectionShell({ + id, + title, + description, + children, +}: { + id: string; + title: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {children} +
+ ); +} + +export default function BillingView({ + orgId, + orgName, +}: { + orgId: number; + orgName: string; +}) { + const [state, setState] = useState(null); + const [invoices, setInvoices] = useState(null); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + const baseUrl = `/organizations/${orgName}/settings/billing`; + + const refresh = useCallback(async () => { + setLoading(true); + setErr(null); + try { + // Run the two actions in parallel. The summary is the source of truth + // for the page; invoices is best-effort (soft-fail). + const [summaryResult, invoicesResult] = await Promise.allSettled([ + getBillingSummary(orgId), + listInvoices(orgId), + ]); + + if (summaryResult.status === "rejected") { + const e = summaryResult.reason; + throw e instanceof Error ? e : new Error(String(e)); + } + setState(summaryResult.value); + + if (invoicesResult.status === "fulfilled") { + setInvoices(invoicesResult.value); + } else { + setInvoices({ + upcoming: null, + past: [], + payment_method: null, + billing_email: null, + }); + } + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [orgId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // Post-Stripe-flow waiting (subscribe success, payment-method update, + // etc.) lives on the dedicated `/billing/return` callback page, NOT here. + // This page's only responsibility is rendering the current billing state. + + const updatePaymentMethod = useCallback(async () => { + try { + // Stripe Portal completion → dedicated callback page that polls until + // the webhook flips status from past_due → active, then forwards back. + const result = await startPaymentMethodUpdate(orgId, { + return_url: `${window.location.origin}${baseUrl}/return?intent=payment_method`, + }); + window.location.href = result.portal_url; + } catch (e) { + toast.error("Could not open payment method update", { + description: e instanceof Error ? e.message : String(e), + }); + } + }, [orgId, baseUrl]); + + const cancelSubscription = useCallback(async () => { + try { + const result = await startCancelSubscription(orgId, { + return_url: `${window.location.origin}${baseUrl}`, + }); + window.location.href = result.portal_url; + } catch (e) { + toast.error("Could not open cancellation", { + description: e instanceof Error ? e.message : String(e), + }); + } + }, [orgId, baseUrl]); + + // Undo a pending cancellation. Optimistic update: we flip + // `cancel_at_period_end` locally on click so the badge clears and the + // Resume button vanishes immediately — the user sees zero latency. The + // Stripe write happens in the background; the webhook projects the same + // value into the DB shortly after. If the Stripe call fails (rare), we + // revert local state and surface the error. + // + // No `refresh()` loop / loading flag: that pattern repaints the whole + // page through the skeleton gate, which is awful UX for a one-bool flip. + const resume = useCallback(async () => { + if (!state) return; + const previous = state; + setState({ ...state, cancel_at_period_end: false }); + try { + await resumeSubscription(orgId); + } catch (e) { + setState(previous); + toast.error("Could not resume subscription", { + description: e instanceof Error ? e.message : String(e), + }); + } + }, [orgId, state]); + + // First-load failure: state is still null AND err is set. Render the error + // panel ahead of the skeleton gate so the Retry button is reachable. Once + // a successful load has happened, transient refresh errors are toasted by + // refresh() and the existing UI keeps rendering. + if (err && !state) { + return ( +
+

Billing

+

Failed to load billing: {err}

+ +
+ ); + } + + if (loading || !state) { + return ( +
+
+

Billing

+
+
+ + + +
+
+ ); + } + + const paidPlan: PaidPlanId | null = + state.plan === "pro" || state.plan === "team" ? state.plan : null; + const isPaid = paidPlan !== null; + const planLabel = paidPlan ? PAID_PLANS[paidPlan].name : "Free"; + // v1: single-seat. Prices come from the catalogue source of truth. Annual + // shows the monthly equivalent inline so users can sanity-check the discount. + const priceLabel = paidPlan + ? state.interval === "year" + ? `$${price_dollars(paidPlan, "year")}/yr (~$${price_monthly_equivalent_dollars(paidPlan, "year").toFixed(0)}/mo)` + : `$${price_dollars(paidPlan, "month")}/mo` + : "$0/mo"; + // Destructive payment-failure states. `incomplete` / `incomplete_expired` + // mean the *first* invoice never settled (e.g. test card 4000 0000 0000 0002); + // UX is the same as a renewal failure — point the user at the Stripe portal + // to fix the payment method. + const isIncomplete = + state.status === "incomplete" || state.status === "incomplete_expired"; + const isPastDue = + state.status === "past_due" || state.status === "unpaid" || isIncomplete; + const isPaused = state.status === "paused"; + // Period dates are stale while the first invoice has not settled. Hide them + // for `incomplete*` states; keep them for `past_due`/`unpaid`/`paused` + // (those carry a real prior period the user paid for once). + const showPeriodDates = !isIncomplete; + const periodEndLabel = + state.current_period_end && showPeriodDates + ? format(new Date(state.current_period_end), "PP") + : null; + const periodStartLabel = + state.current_period_start && showPeriodDates + ? format(new Date(state.current_period_start), "PP") + : null; + + return ( +
+
+

Billing

+

+ Manage your subscription and invoices for{" "} + {orgName} +

+
+ + {isPastDue && ( + + + + {isIncomplete ? "Payment incomplete" : "Payment failed"} + + + {isIncomplete + ? "Your first invoice has not been paid. Update your payment method to activate the subscription." + : "Your last invoice could not be charged. Update your payment method to keep the subscription active."} + + + + + + + )} + + {isPaused && ( + + + Subscription suspended + + Your subscription is on hold while a payment dispute is being + reviewed. Contact support if you believe this is in error. + + + + )} + +
+ {/* 1. Subscription Plan */} + + + +
+ + {planLabel} + + {state.status} + + {state.cancel_at_period_end && ( + cancels at period end + )} + +
+ + {isPaid ? priceLabel : "$0/mo"} + +
+ {isPaid && (periodStartLabel || periodEndLabel) && ( + + {periodStartLabel && periodEndLabel && ( +

+ Current period: {periodStartLabel} – {periodEndLabel} +

+ )} + {periodEndLabel && ( +

+ {state.cancel_at_period_end ? "Cancels" : "Renews"}{" "} + {periodEndLabel} +

+ )} +
+ )} + + {/* Plan changes blocked while billing is in a degraded state — + Stripe rejects price-change on past_due/incomplete subs. + Cancellation lives in the Danger zone at the bottom of the + page; intentionally not surfaced alongside everyday actions. */} + {!isPastDue && !isPaused && ( + + )} + {/* Resume = undo a pending `cancel_at_period_end`. Stripe charges + nothing — the existing sub continues on its current schedule. + Visible alongside everyday actions on purpose: undoing a + destructive action should be at least as easy as taking it. + No loading state needed — the click optimistically flips + `cancel_at_period_end` so the button vanishes instantly. */} + {state.cancel_at_period_end && ( + + )} + +
+
+ + {/* 2. Past Invoices */} + + {!invoices?.past.length ? ( +

+ No past invoices.{" "} + {isPaid ? "" : "Upgrade to a paid plan to begin billing."} +

+ ) : ( +
+ + + + Date + Amount + Status + Links + + + + {invoices.past.map((inv) => ( + + {fmtUnix(inv.created_unix)} + + {fmtCents(inv.amount_paid_cents)} + + + + {inv.status} + + + + {inv.invoice_pdf && ( + + + PDF + + )} + {inv.hosted_invoice_url && ( + + + View + + )} + + + ))} + +
+
+ )} +
+ + {/* 3. Payment Methods */} + +
+ {invoices?.payment_method ? ( +
+ +
+

+ {invoices.payment_method.brand} •••• + {invoices.payment_method.last4} +

+

+ Expires{" "} + {invoices.payment_method.exp_month + .toString() + .padStart(2, "0")} + /{invoices.payment_method.exp_year} +

+
+
+ ) : ( +

+ No payment method on file. +

+ )} + +
+
+ + {/* 4. Danger zone — destructive actions are pushed to the bottom of + the page on purpose, behind a visually distinct boundary. Hidden + when there's nothing to cancel (free plans, already-canceling, + past_due/paused subs that need recovery first). */} + {isPaid && !state.cancel_at_period_end && !isPastDue && !isPaused && ( + +
+
+
+

Cancel subscription

+

+ Your plan stays active until the end of the current billing + period, then reverts to Free. Top-up balance is preserved. +

+
+ +
+
+
+ )} +
+ + {state.is_test_mode && ( + <> + +

+ Charges in test mode use Stripe's sandbox and never bill a real + card. +

+ + )} +
+ ); +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/layout.tsx b/editor/app/(site)/organizations/[organization_name]/settings/billing/layout.tsx new file mode 100644 index 0000000000..80b116a571 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/layout.tsx @@ -0,0 +1,17 @@ +// Receives the `@modal` parallel slot for intercepting routes +// (e.g. /settings/billing/upgrade → opens as a Dialog overlay when navigated +// in-app). The shared sidebar comes from `../layout.tsx` (SettingsShell). +export default function BillingLayout({ + children, + modal, +}: { + children: React.ReactNode; + modal: React.ReactNode; +}) { + return ( + <> + {children} + {modal} + + ); +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/page.tsx b/editor/app/(site)/organizations/[organization_name]/settings/billing/page.tsx new file mode 100644 index 0000000000..21f2061f3d --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/page.tsx @@ -0,0 +1,26 @@ +import { notFound, redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; +import BillingView from "./_view"; + +type Params = { organization_name: string }; + +export default async function OrganizationBillingPage({ + params, +}: { + params: Promise; +}) { + const { organization_name } = await params; + const client = await createClient(); + const { data: auth } = await client.auth.getUser(); + if (!auth.user) return redirect("/sign-in"); + + const { data: org } = await client + .from("organization") + .select("id, name") + .eq("name", organization_name) + .single(); + + if (!org) return notFound(); + + return ; +} 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 new file mode 100644 index 0000000000..e18eb0d854 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/return/_view.tsx @@ -0,0 +1,121 @@ +"use client"; + +// Dedicated post-Stripe-flow callback page. Stripe Checkout / Portal flows +// redirect here; this view polls `getBillingSummary` until the webhook-driven +// state change lands (or a short timeout elapses), then forwards to the main +// billing page. The main billing page is intentionally unaware of post-flow +// concerns — single responsibility per page. +// +// We never reach out to Stripe from here — the webhook is the only source of +// truth. If the webhook is slow or missing (e.g., `stripe listen` not running +// in dev), polling times out and we forward to billing with a soft warning. +// +// The visual chrome is intentionally minimal: a centered spinner. Confirmation +// of what happened belongs on the destination page, where the user can see the +// new plan badge / status flip directly. A loud "Welcome to Pro!" interstitial +// here would be redundant noise. + +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"; + +type Intent = "subscribe" | "payment_method" | "generic"; + +const POLL_INTERVAL_MS = 3_000; +const MAX_ATTEMPTS = 10; // ~30s total + +function isSettled(summary: BillingSummary, intent: Intent): boolean { + switch (intent) { + case "subscribe": + // 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" + ); + case "payment_method": + // 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"; + case "generic": + // No predicate — just exhaust the poll window. + return false; + } +} + +export default function BillingReturnView({ + orgId, + orgName, + intent, +}: { + orgId: number; + orgName: string; + intent: Intent; +}) { + const router = useRouter(); + const doneRef = useRef(false); + const billingHref = `/organizations/${orgName}/settings/billing`; + + useEffect(() => { + let cancelled = false; + let attempts = 0; + + const finish = (success: boolean) => { + if (doneRef.current) return; + doneRef.current = true; + if (!success) { + // Diagnostic-only: surface staleness so the user knows to refresh + // if the destination page hasn't caught up. Success path is silent; + // the destination page reflects the new state on its own. + toast.message("This is taking longer than expected", { + description: + "Your billing page may still be updating — refresh in a moment.", + }); + } + router.replace(billingHref); + }; + + const tick = async () => { + if (cancelled || doneRef.current) return; + attempts += 1; + try { + const summary = await getBillingSummary(orgId); + if (cancelled || doneRef.current) return; + if (isSettled(summary, intent)) { + finish(true); + return; + } + } catch { + // Network/RLS hiccup mid-poll — swallow and let the next tick retry. + // A persistent failure exhausts the attempt budget and we forward + // anyway with the timeout toast. + } + if (attempts >= MAX_ATTEMPTS) { + finish(false); + } + }; + + // Fire the first probe immediately so a webhook that already landed + // before the user got back doesn't waste 1.5s. + void tick(); + const interval = setInterval(() => void tick(), POLL_INTERVAL_MS); + + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [orgId, intent, router, billingHref]); + + // Fullscreen overlay (covers the settings-layout sidebar) so this page is + // a pure loading interstitial — no chrome, no copy, just a centered spinner. + 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 new file mode 100644 index 0000000000..9ac05b6893 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/return/page.tsx @@ -0,0 +1,39 @@ +import { notFound, redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; +import BillingReturnView from "./_view"; + +type Params = { organization_name: string }; +type Search = { intent?: string }; + +export default async function BillingReturnPage({ + params, + searchParams, +}: { + params: Promise; + searchParams: Promise; +}) { + const { organization_name } = await params; + const { intent: rawIntent } = await searchParams; + + const client = await createClient(); + const { data: auth } = await client.auth.getUser(); + if (!auth.user) return redirect("/sign-in"); + + const { data: org } = await client + .from("organization") + .select("id, name") + .eq("name", organization_name) + .single(); + if (!org) return notFound(); + + // 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" + ? rawIntent + : "generic"; + + return ( + + ); +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx b/editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx new file mode 100644 index 0000000000..a7c003e093 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx @@ -0,0 +1,341 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { CheckIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + getBillingSummary, + startSubscribeCheckout, + startPlanChangeConfirm, +} from "../_actions"; +import { + PAID_PLAN_LIST, + PLAN_RANK, + price_monthly_equivalent_dollars, + price_dollars, + type Interval, + type PaidPlanDefinition, + type PlanId, +} from "@/lib/billing/plans"; + +// Free is intentionally not listed here. Downgrading to Free means +// canceling the paid subscription, which is handled in the Stripe Customer +// Portal — not from this upgrade page. + +type CurrentState = { + plan: PlanId; + status: string; + interval: Interval | null; +} | null; + +export default function UpgradeView({ + orgId, + orgName, + embedded = false, +}: { + orgId: number; + orgName: string; + /** When true, omit page-level chrome (header, back link, outer
). For modal embedding. */ + embedded?: boolean; +}) { + const [current, setCurrent] = useState(null); + const [submittingPlan, setSubmittingPlan] = useState(null); + const [interval, setInterval] = useState("month"); + + const baseUrl = `/organizations/${orgName}/settings/billing`; + + useEffect(() => { + let cancel = false; + getBillingSummary(orgId) + .then((data) => { + if (cancel) return; + setCurrent({ + plan: data.plan ?? "free", + status: data.status ?? "active", + interval: data.interval ?? null, + }); + if (data.interval) setInterval(data.interval); + }) + .catch(() => + setCurrent({ + plan: "free", + status: "active", + interval: null, + }) + ); + return () => { + cancel = true; + }; + }, [orgId]); + + const subscribe = async (plan: PaidPlanDefinition) => { + if (!current) return; + setSubmittingPlan(plan.id); + try { + const origin = window.location.origin; + const result = await startSubscribeCheckout(orgId, { + plan: plan.id, + interval, + // Stripe-side completion → dedicated callback page that polls until + // the webhook lands, then forwards to the billing dashboard. + success_url: `${origin}${baseUrl}/return?intent=subscribe`, + cancel_url: `${origin}${baseUrl}?subscribe=canceled`, + }); + if (!result.checkout_url) { + throw new Error("checkout error"); + } + window.location.href = result.checkout_url; + } catch (e) { + toast.error("Could not start checkout", { + description: e instanceof Error ? e.message : String(e), + }); + setSubmittingPlan(null); + } + }; + + // Paid→paid plan/interval change. Server picks the price; Stripe Portal + // shows a single confirm page (no plan picker) with the prorated total. + const changePlan = async (plan: PaidPlanDefinition) => { + setSubmittingPlan(plan.id); + try { + const result = await startPlanChangeConfirm(orgId, { + plan: plan.id, + interval, + return_url: `${window.location.origin}${baseUrl}`, + }); + window.location.href = result.portal_url; + } catch (e) { + toast.error("Could not start plan change", { + description: e instanceof Error ? e.message : String(e), + }); + setSubmittingPlan(null); + } + }; + + const renderPlanAction = (plan: PaidPlanDefinition) => { + if (!current) return ; + const currentRank = PLAN_RANK[current.plan] ?? 0; + const planRank = PLAN_RANK[plan.id]; + const samePlan = plan.id === current.plan; + const sameInterval = current.interval === interval; + const isCurrent = samePlan && sameInterval; + const isUpgrade = planRank > currentRank; + const isDowngrade = planRank < currentRank; + const isPaidToPaid = currentRank > 0 && planRank > 0; + // Plan changes blocked while billing is degraded — Stripe rejects + // price-change on past_due / incomplete subs and dispute-paused subs + // can't be safely mutated. + const isDegraded = + current.status === "past_due" || + current.status === "unpaid" || + current.status === "paused" || + current.status === "incomplete" || + current.status === "incomplete_expired"; + + if (isCurrent) { + return ( + + ); + } + if (isDegraded) { + return ( + + ); + } + + // Paid→paid: same plan + different interval, OR different plan ± any + // interval. All routed through `subscription_update_confirm` flow_data + // — Stripe shows a single confirm page with the prorated total. + if (isPaidToPaid) { + const labelKind = isUpgrade + ? "Switch to" + : isDowngrade + ? "Downgrade to" + : "Switch to"; + const intervalSuffix = interval === "year" ? " Annual" : ""; + return ( + + ); + } + + // Free → Paid: new subscription via Stripe Checkout. + if (isUpgrade) { + return ( + + ); + } + return null; + }; + + const headerTitle = (() => { + if (!current) return "Plans"; + const currentRank = PLAN_RANK[current.plan] ?? 0; + const hasUpgrade = PAID_PLAN_LIST.some( + (p) => PLAN_RANK[p.id] > currentRank + ); + return hasUpgrade ? "Upgrade" : "Adjust plan"; + })(); + + const headerSubtitle = (() => { + if (!current) return "Choose a plan that fits your work."; + const currentRank = PLAN_RANK[current.plan] ?? 0; + const hasUpgrade = PAID_PLAN_LIST.some( + (p) => PLAN_RANK[p.id] > currentRank + ); + return hasUpgrade + ? "Pick a plan that fits your work." + : "You're on the highest plan. Switch interval or downgrade anytime."; + })(); + + const intervalToggle = ( +
+ + +
+ ); + + const body = ( + <> +
{intervalToggle}
+ +
+ {PAID_PLAN_LIST.map((plan) => { + // Match plan AND interval so a plan card doesn't claim "Current" + // when the user has the same plan on a different interval (and a + // switch is actually being offered). + const isCurrent = + current?.plan === plan.id && current?.interval === interval; + const monthlyEquivalent = price_monthly_equivalent_dollars( + plan.id, + interval + ); + const annualSticker = price_dollars(plan.id, "year"); + return ( + + + + {plan.name} + {isCurrent && Current} + + {plan.description} + + +
+

+ ${monthlyEquivalent.toFixed(0)} + + {" "} + / mo + +

+ {interval === "year" && ( +

+ Billed ${annualSticker.toFixed(0)} annually +

+ )} +
+
    + {plan.features.map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ {renderPlanAction(plan)} +
+ ); + })} +
+ +

+ Test mode: use card 4242 4242 4242 4242. After payment, + you'll be redirected back here. Provisioning takes a few seconds. +

+ + ); + + if (embedded) return body; + + return ( +
+
+ + ← Back to billing + +

{headerTitle}

+

{headerSubtitle}

+
+ {body} +
+ ); +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/page.tsx b/editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/page.tsx new file mode 100644 index 0000000000..eaff549945 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/page.tsx @@ -0,0 +1,26 @@ +import { notFound, redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; +import UpgradeView from "./_view"; + +type Params = { organization_name: string }; + +export default async function OrganizationBillingUpgradePage({ + params, +}: { + params: Promise; +}) { + const { organization_name } = await params; + const client = await createClient(); + const { data: auth } = await client.auth.getUser(); + if (!auth.user) return redirect("/sign-in"); + + const { data: org } = await client + .from("organization") + .select("id, name") + .eq("name", organization_name) + .single(); + + if (!org) return notFound(); + + return ; +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/layout.tsx b/editor/app/(site)/organizations/[organization_name]/settings/layout.tsx new file mode 100644 index 0000000000..27b5a7cc77 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/layout.tsx @@ -0,0 +1,40 @@ +import { notFound, redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; +import SettingsShell from "./_shell"; + +type Params = { organization_name: string }; + +export default async function SettingsLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise; +}) { + const { organization_name } = await params; + const client = await createClient(); + const { data: auth } = await client.auth.getUser(); + if (!auth.user) return redirect("/sign-in"); + + const { data: org } = await client + .from("organization") + .select("id, name") + .eq("name", organization_name) + .single(); + + if (!org) return notFound(); + + const { data: sub } = await client + .from("v_billing_subscription") + .select("plan") + .eq("organization_id", org.id) + .maybeSingle(); + + const plan = sub?.plan ?? "free"; + + return ( + + {children} + + ); +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/profile/page.tsx b/editor/app/(site)/organizations/[organization_name]/settings/profile/page.tsx index 03e307bc40..6be1a58e9f 100644 --- a/editor/app/(site)/organizations/[organization_name]/settings/profile/page.tsx +++ b/editor/app/(site)/organizations/[organization_name]/settings/profile/page.tsx @@ -1,11 +1,4 @@ import { Button } from "@/components/ui/button"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb"; import { Card, CardContent, @@ -16,7 +9,6 @@ import { import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { notFound, redirect } from "next/navigation"; -import { GridaLogo } from "@/components/grida-logo"; import { DeleteOrganizationConfirm } from "./delete"; import { Badge } from "@/components/ui/badge"; import { createClient } from "@/lib/supabase/server"; @@ -53,8 +45,7 @@ export default async function OrganizationsSettingsProfilePage({ const iamowner = data.owner_id === auth.user.id; return ( -
-
diff --git a/editor/host/url.ts b/editor/host/url.ts index d534ca7b39..4159f64ce5 100644 --- a/editor/host/url.ts +++ b/editor/host/url.ts @@ -34,7 +34,7 @@ import * as ERR from "@/k/error"; // #region ─── Route registry ────────────────────────────────────────────────── -export type UniversalRouteScope = "project" | "document"; +export type UniversalRouteScope = "project" | "document" | "organization"; type UniversalRouteConfig = { scope: UniversalRouteScope; @@ -44,6 +44,23 @@ type UniversalRouteConfig = { requiredDoctypes?: ReadonlyArray; }; +/** + * Organization-scoped routes — pages that live under + * `/organizations//` and require only org context (no project, + * no document). Settings sub-pages, members, billing all belong here. + * + * Note: the bare `settings` segment is intentionally **not** registered here + * because it collides with the document-scoped `settings` route. Universal + * shorthand `/_/settings` resolves to a document; `/organizations//settings` + * is reachable only by direct link. + */ +const ORGANIZATION_ROUTE_CONFIGS = { + "settings/billing": { scope: "organization" }, + "settings/billing/upgrade": { scope: "organization" }, + people: { scope: "organization" }, + invite: { scope: "organization" }, +} satisfies Record; + /** * Project-scoped routes — console / workspace pages that live under * `/:org/:project/` and do **not** require a document id. @@ -134,10 +151,11 @@ const DOCUMENT_ROUTE_CONFIGS = { /** Stable document route keys — derived directly from the config above. */ type StableDocumentRouteType = keyof typeof DOCUMENT_ROUTE_CONFIGS; -/** Merged registry of all project + document routes. */ +/** Merged registry of all project + document + organization routes. */ const UNIVERSAL_ROUTE_CONFIGS = { ...PROJECT_ROUTE_CONFIGS, ...DOCUMENT_ROUTE_CONFIGS, + ...ORGANIZATION_ROUTE_CONFIGS, } satisfies Record; /** All registered universal route keys. */ @@ -284,14 +302,16 @@ export function matchUniversalRoute(path: string) { export type UniversalRouteContext = { org: string; - proj: string; + proj?: string; docId?: string | null; }; type UniversalRouteContextFor

= (typeof UNIVERSAL_ROUTE_CONFIGS)[P]["scope"] extends "document" ? { org: string; proj: string; docId: string } - : { org: string; proj: string }; + : (typeof UNIVERSAL_ROUTE_CONFIGS)[P]["scope"] extends "organization" + ? { org: string } + : { org: string; proj: string }; /** Strict overload: when the exact route literal is known at compile time. */ export function buildUniversalDestination

( @@ -308,14 +328,30 @@ export function buildUniversalDestination( context: UniversalRouteContext ) { const route = getUniversalRouteDefinition(page); - const base = `/${context.org}/${context.proj}`; const suffix = normalizeUniversalPath(route.path); + if (route.scope === "organization") { + const base = `/organizations/${context.org}`; + return suffix ? `${base}/${suffix}` : base; + } + + if (!context.proj) { + throw new Error( + `buildUniversalDestination(${page}): missing project context` + ); + } + const base = `/${context.org}/${context.proj}`; + if (route.scope === "project") { return suffix ? `${base}/${suffix}` : base; } - const docId = "docId" in context ? context.docId : ""; + if (!context.docId) { + throw new Error( + `buildUniversalDestination(${page}): missing document context` + ); + } + const docId = context.docId; return suffix ? `${base}/${docId}/${suffix}` : `${base}/${docId}`; } diff --git a/editor/k/labels.ts b/editor/k/labels.ts index 3108ad105a..8477b7bdbf 100644 --- a/editor/k/labels.ts +++ b/editor/k/labels.ts @@ -1,4 +1,4 @@ -import type { GDocumentType, PlatformPricingTier } from "@/types"; +import type { GDocumentType, PlanTier } from "@/types"; export namespace Labels { const doctype_labels = { @@ -10,19 +10,19 @@ export namespace Labels { v0_campaign_referral: "Campaign", } as const; - const price_tier_labels = { + const plan_labels = { free: "Free", - v0_pro: "Pro", - v0_team: "Team", - v0_enterprise: "Enterprise", - } as const; + pro: "Pro", + team: "Team", + } as const satisfies Record; export function doctype(dt: GDocumentType) { return doctype_labels[dt]; } - export function priceTier(tier: PlatformPricingTier) { - return price_tier_labels[tier]; + export function planTier(plan: PlanTier, is_enterprise = false): string { + if (is_enterprise) return "Enterprise"; + return plan_labels[plan]; } /** diff --git a/editor/lib/billing/__tests__/e2e/README.md b/editor/lib/billing/__tests__/e2e/README.md new file mode 100644 index 0000000000..c2020d3d15 --- /dev/null +++ b/editor/lib/billing/__tests__/e2e/README.md @@ -0,0 +1,75 @@ +# Billing E2E suite + +Three integration tests that hit real Stripe (test mode) and the real +webhook receiver. They cover what pgTAP can't: the HTTP path, signature +verification, and tolerance for actual Stripe object shapes. + +The full surface — projector branches, seat-sync triggers, RLS, org-delete +guard, dispute branches — is covered at the DB layer in +[`supabase/tests/test_grida_billing_test.sql`](../../../../../supabase/tests/test_grida_billing_test.sql). + +## What's covered + +- **`scenarios/lifecycle.test.ts`** — customer.created → subscription.created + → subscription.deleted, all signed and routed through the live receiver. +- **`scenarios/idempotency.test.ts`** — same event id replayed 3× yields + handled + replayed + replayed. +- **`scenarios/tampered-signature.test.ts`** — bad signature → 400, no + projection. + +## Setup (one-time, per developer) + +The suite picks up env from `editor/.env.test` (committed defaults) and +`editor/.env.test.local` (your secrets — gitignored). No shell ceremony. + +```bash +# 1. Local Supabase +supabase start && supabase db reset + +# 2. Drop your Supabase + Stripe credentials into editor/.env.test.local +# (see editor/.env.test for the exact keys it expects) +supabase status -o env | grep SUPABASE_SECRET_KEY >> editor/.env.test.local +echo 'STRIPE_SECRET_KEY=sk_test_...' >> editor/.env.test.local +echo 'STRIPE_WEBHOOK_SECRET=whsec_...' >> editor/.env.test.local + +# 3. Provision Stripe test-mode products + portal config (idempotent) +pnpm tsx editor/scripts/billing/setup-stripe-test.ts + +# 4. Run the dev server +pnpm dev --filter=editor +``` + +We don't share Stripe credentials. Contributors create their own free +Stripe test account (no payment info required) and use those keys here. +The setup script is idempotent against any test account. + +## Run + +```bash +pnpm --filter editor vitest run lib/billing/__tests__/e2e +``` + +The suite refuses to start unless every channel is demonstrably test mode: +`BILLING_E2E=1`, `NODE_ENV != production`, `STRIPE_SECRET_KEY` starts with +`sk_test_`, the Supabase URL host is `localhost`/`127.0.0.1`, and `APP_URL` +is also local. + +Each test creates its own ephemeral org owned by the seed user +`insider@grida.co`; teardown deletes the Stripe customer (cascades subs) +and the local org row (CASCADE wipes `grida_billing.*`). + +## Why these three + +The valuable coverage E2E adds over pgTAP: + +1. **HTTP receiver path** — signature verification, body parsing, + projector dispatch. pgTAP calls `fn_apply_stripe_event` directly; + only E2E exercises the route handler. +2. **Real Stripe object shapes** — pgTAP feeds hand-built jsonb. If + Stripe changes a field shape, only E2E catches it. +3. **Idempotency through the live HTTP path** — pgTAP tests the PK + dedupe at the SQL layer; E2E proves the HTTP+TS dispatch layer + honors it too. + +Lifecycle transitions, dispute branches, seat counts — the projector +logic — stay in pgTAP territory. diff --git a/editor/lib/billing/__tests__/e2e/fixtures/db.ts b/editor/lib/billing/__tests__/e2e/fixtures/db.ts new file mode 100644 index 0000000000..11e2b1e9b1 --- /dev/null +++ b/editor/lib/billing/__tests__/e2e/fixtures/db.ts @@ -0,0 +1,69 @@ +// `grida_billing` is not exposed to PostgREST; reach state via public +// wrapper RPCs. Webhook idempotency is checked through the receiver's HTTP +// response, not the DB, since `stripe_event` has no public read. + +import { service_role } from "@/lib/supabase/server"; +import { getCatalogueStripeIds, getCustomerId } from "../../.."; + +export interface ActiveSubscription { + stripe_subscription_id: string | null; + status: string; + quantity: number; + plan: string; + cancel_at_period_end: boolean; + current_period_start: string | null; + current_period_end: string | null; +} + +export async function readActiveSubscription( + org_id: number +): Promise { + const { data, error } = await service_role.workspace.rpc( + "fn_billing_get_active_subscription", + { p_org_id: org_id } + ); + if (error) throw new Error(`readActiveSubscription: ${error.message}`); + const row = ( + Array.isArray(data) ? data[0] : null + ) as ActiveSubscription | null; + if (!row) return null; + return { + stripe_subscription_id: row.stripe_subscription_id ?? null, + status: row.status, + quantity: row.quantity, + plan: row.plan, + cancel_at_period_end: row.cancel_at_period_end, + current_period_start: row.current_period_start ?? null, + current_period_end: row.current_period_end ?? null, + }; +} + +export const readCustomerId = getCustomerId; + +export async function getProPriceId(): Promise { + const cat = await getCatalogueStripeIds("plan.pro"); + if (!cat) { + throw new Error( + "plan.pro price not wired. Run: pnpm tsx editor/scripts/billing/setup-stripe-test.ts" + ); + } + return cat.stripe_price_id; +} + +export async function awaitDb( + read: () => Promise, + predicate: (value: T) => boolean, + options: { tries?: number; intervalMs?: number; label?: string } = {} +): Promise { + const tries = options.tries ?? 15; + const intervalMs = options.intervalMs ?? 200; + let last: T | undefined; + for (let i = 0; i < tries; i++) { + last = await read(); + if (predicate(last)) return last; + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error( + `awaitDb${options.label ? ` (${options.label})` : ""}: predicate false after ${tries} tries (${tries * intervalMs}ms). last=${JSON.stringify(last)}` + ); +} diff --git a/editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts b/editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts new file mode 100644 index 0000000000..362bece3ee --- /dev/null +++ b/editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts @@ -0,0 +1,87 @@ +import type Stripe from "stripe"; +import { stripe } from "../../.."; + +export type DeliverResult = { + status: number; + body: + | { received: true; type: string; handler: string | null } + | { received: true; replayed: true } + | { error: string; detail?: string }; +}; + +function appUrl(): string { + const url = process.env.APP_URL; + if (!url) throw new Error("APP_URL is required"); + return url.replace(/\/$/, ""); +} + +function webhookSecret(): string { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!secret) throw new Error("STRIPE_WEBHOOK_SECRET is required"); + return secret; +} + +// The receiver only reads `id`, `type`, and `data.object`, so we don't fill +// the giant `Stripe.Event` discriminated union. +function buildEnvelope( + type: string, + object: T, + eventId?: string +): Stripe.Event { + const env = { + id: + eventId ?? + `evt_test_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, + object: "event", + api_version: "2026-04-22.dahlia", + created: Math.floor(Date.now() / 1000), + data: { object }, + livemode: false, + pending_webhooks: 0, + request: { id: null, idempotency_key: null }, + type, + }; + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- envelope shape; Stripe.Event is a giant union + return env as any; +} + +// Generic over the input shape so callers can pass real Stripe SDK +// responses (Customer, Subscription, etc.) or hand-rolled object literals +// without an extra-property check. Anything beyond `id` rides through as +// opaque JSON. +export async function deliverEvent( + type: string, + object: T, + options: { eventId?: string; tamperSignature?: boolean } = {} +): Promise { + const event = buildEnvelope(type, object, options.eventId); + const payload = JSON.stringify(event); + const sig = stripe.webhooks.generateTestHeaderString({ + payload, + secret: webhookSecret(), + }); + const finalSig = options.tamperSignature + ? sig.replace(/v1=[a-f0-9]+/, "v1=" + "0".repeat(64)) + : sig; + + const res = await fetch(`${appUrl()}/private/webhooks/stripe`, { + method: "POST", + headers: { + "content-type": "application/json", + "stripe-signature": finalSig, + }, + body: payload, + }); + // Read once as text, then try to parse as JSON. If we called `res.json()` + // first and it threw, the stream would already be drained — `res.text()` in + // the catch would yield "" and we'd lose the actual error body Stripe (or + // the route) returned, which is the most useful diagnostic. + const raw = await res.text(); + let body: DeliverResult["body"]; + try { + body = JSON.parse(raw) as DeliverResult["body"]; + } catch { + body = { error: `non-json: ${raw}` }; + } + return { status: res.status, body }; +} diff --git a/editor/lib/billing/__tests__/e2e/fixtures/org.ts b/editor/lib/billing/__tests__/e2e/fixtures/org.ts new file mode 100644 index 0000000000..77c59902f6 --- /dev/null +++ b/editor/lib/billing/__tests__/e2e/fixtures/org.ts @@ -0,0 +1,109 @@ +import { randomUUID } from "node:crypto"; +import { service_role } from "@/lib/supabase/server"; +import { getCustomerId, stripe } from "../../.."; +import { deliverEvent } from "./deliver-event"; + +export interface EphemeralOrg { + org_id: number; + name: string; + owner_user_id: string; +} + +const SEED_OWNER_EMAIL = "insider@grida.co"; + +// `organization.owner_id` FKs to `auth.users.id`, so we can't mint a random +// uuid. Use the seed user — its uuid is regenerated on every `db reset`, +// so look it up at runtime. Sharing one owner across ephemeral orgs is fine +// (the FK is not unique). +async function getSeedOwnerId(): Promise { + const { data, error } = await service_role.workspace.auth.admin.listUsers({ + perPage: 1000, + }); + if (error) throw new Error(`getSeedOwnerId: ${error.message}`); + const user = data.users.find((u) => u.email === SEED_OWNER_EMAIL); + if (!user) { + throw new Error( + `getSeedOwnerId: seed user ${SEED_OWNER_EMAIL} not found. Run \`supabase db reset\` to re-seed.` + ); + } + return user.id; +} + +export async function provisionEphemeralOrg(): Promise { + const slug = `e2e-${randomUUID().replace(/-/g, "").slice(0, 24)}`; + const owner_user_id = await getSeedOwnerId(); + + const { data, error } = await service_role.workspace + .from("organization") + .insert({ + name: slug, + display_name: `E2E ${slug}`, + owner_id: owner_user_id, + }) + .select("id") + .single(); + + if (error) throw new Error(`provisionEphemeralOrg: ${error.message}`); + if (!data?.id) throw new Error("provisionEphemeralOrg: no id returned"); + return { org_id: data.id as number, name: slug, owner_user_id }; +} + +// The org-delete guard refuses while a Stripe-backed subscription is active, +// so we drive the cancellation through the projector ourselves: cancel at +// Stripe, deliver `customer.subscription.deleted` to flip the local row to +// `canceled`. We can't rely on Stripe's own webhook — `stripe listen` isn't +// running in the test process. Then delete the customer and the org row; +// CASCADE wipes `grida_billing.*`. +export async function teardownOrg(org: EphemeralOrg): Promise { + const customerId = await getCustomerId(org.org_id); + + if (customerId) { + try { + const subs = await stripe.subscriptions.list({ + customer: customerId, + status: "all", + limit: 100, + }); + for (const sub of subs.data) { + if (sub.status === "canceled") continue; + const canceled = await stripe.subscriptions + .cancel(sub.id) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + if (/No such subscription|resource_missing/i.test(msg)) return null; + throw err; + }); + if (canceled) { + await deliverEvent("customer.subscription.deleted", canceled); + } + } + } catch (err) { + // Surface the failure: a swallowed cancel leaves orphaned Stripe state + // that pollutes the test sandbox over time. Only "already gone" errors + // are tolerated and they're handled inside the inner cancel catch above. + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`[e2e/org] subscription cleanup failed: ${msg}`); + } + + try { + await stripe.customers.del(customerId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // "Already deleted" is fine — fall through to the local org delete. + // Anything else is a real teardown failure and must not be silently + // logged — orphan customers compound across runs and eventually trip + // the test sandbox limits. + if (!/No such customer|resource_missing/i.test(msg)) { + throw new Error(`[e2e/org] customer delete failed: ${msg}`); + } + } + } + + const del = await service_role.workspace + .from("organization") + .delete() + .eq("id", org.org_id); + if (del.error) { + throw new Error(`teardownOrg(${org.org_id}): ${del.error.message}`); + } +} diff --git a/editor/lib/billing/__tests__/e2e/fixtures/safety.ts b/editor/lib/billing/__tests__/e2e/fixtures/safety.ts new file mode 100644 index 0000000000..4f38beed7c --- /dev/null +++ b/editor/lib/billing/__tests__/e2e/fixtures/safety.ts @@ -0,0 +1,81 @@ +// Refuse to load unless we are demonstrably in a test environment: +// - Stripe key is a test key (`sk_test_…`) — never run against live Stripe. +// - Supabase URL is a local instance (localhost / 127.0.0.1) — never run +// against a hosted project (staging or prod). +// - NODE_ENV is not "production". +// - The opt-in `BILLING_E2E=1` gate is set. +// Failing any check throws on call, which vitest reports as a test error. + +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); + +function isLocalSupabaseUrl(raw: string | undefined): boolean { + if (!raw) return false; + try { + return LOCAL_HOSTS.has(new URL(raw).hostname); + } catch { + return false; + } +} + +function isLocalAppUrl(raw: string | undefined): boolean { + if (!raw) return false; + try { + return LOCAL_HOSTS.has(new URL(raw).hostname); + } catch { + return false; + } +} + +export function assertSuiteSafety(): void { + const issues: string[] = []; + + if (process.env.BILLING_E2E !== "1") { + issues.push("BILLING_E2E must be set to '1'"); + } + if (process.env.NODE_ENV === "production") { + issues.push("NODE_ENV must not be 'production'"); + } + + // Stripe must be sandbox/test mode. + const sk = process.env.STRIPE_SECRET_KEY ?? ""; + if (!sk.startsWith("sk_test_")) { + issues.push( + "STRIPE_SECRET_KEY must start with 'sk_test_' (refusing to run against live Stripe)" + ); + } + if (!process.env.STRIPE_WEBHOOK_SECRET) { + issues.push("STRIPE_WEBHOOK_SECRET is required"); + } + + // Supabase must be a local instance. + const supabaseUrl = + process.env.NEXT_PUBLIC_SUPABASE_URL ?? process.env.SUPABASE_URL; + if (!supabaseUrl) { + issues.push("NEXT_PUBLIC_SUPABASE_URL (or SUPABASE_URL) is required"); + } else if (!isLocalSupabaseUrl(supabaseUrl)) { + issues.push( + `NEXT_PUBLIC_SUPABASE_URL must point at a local instance (got '${supabaseUrl}'). ` + + `Hosted Supabase projects are forbidden — run \`supabase start\` and use the local URL.` + ); + } + if (!process.env.SUPABASE_SECRET_KEY) { + issues.push("SUPABASE_SECRET_KEY is required (service-role)"); + } + + // App URL must also be local — the test signs and POSTs to APP_URL/private/webhooks/stripe. + const appUrl = process.env.APP_URL; + if (!appUrl) { + issues.push("APP_URL is required (e.g. http://localhost:3000)"); + } else if (!isLocalAppUrl(appUrl)) { + issues.push( + `APP_URL must point at a local dev server (got '${appUrl}'). ` + + `Refusing to deliver synthetic webhooks to a non-local host.` + ); + } + + if (issues.length > 0) { + throw new Error( + `[billing-e2e] safety check failed:\n - ${issues.join("\n - ")}` + ); + } +} diff --git a/editor/lib/billing/__tests__/e2e/scenarios/idempotency.test.ts b/editor/lib/billing/__tests__/e2e/scenarios/idempotency.test.ts new file mode 100644 index 0000000000..1afea7c9bf --- /dev/null +++ b/editor/lib/billing/__tests__/e2e/scenarios/idempotency.test.ts @@ -0,0 +1,75 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { assertSuiteSafety } from "../fixtures/safety"; +import { + provisionEphemeralOrg, + teardownOrg, + type EphemeralOrg, +} from "../fixtures/org"; +import { + awaitDb, + getProPriceId, + readActiveSubscription, + readCustomerId, +} from "../fixtures/db"; +import { deliverEvent } from "../fixtures/deliver-event"; +import { stripe } from "../../.."; + +describe("E2E — webhook idempotency", () => { + let org: EphemeralOrg; + + beforeAll(() => assertSuiteSafety()); + afterAll(async () => { + if (org) await teardownOrg(org); + }); + + it("replaying the same event 3× yields handled+replayed+replayed", async () => { + org = await provisionEphemeralOrg(); + const priceId = await getProPriceId(); + + const customer = await stripe.customers.create({ + metadata: { grida_organization_id: String(org.org_id) }, + }); + await deliverEvent("customer.created", customer); + await awaitDb( + () => readCustomerId(org.org_id), + (id) => id === customer.id + ); + + const pm = await stripe.paymentMethods.attach("pm_card_visa", { + customer: customer.id, + }); + await stripe.customers.update(customer.id, { + invoice_settings: { default_payment_method: pm.id }, + }); + const sub = await stripe.subscriptions.create({ + customer: customer.id, + items: [{ price: priceId, quantity: 1 }], + }); + + // Stable event id across 3 deliveries forces the dedupe path. + const eventId = `evt_test_idem_${Date.now()}`; + + const r1 = await deliverEvent("customer.subscription.created", sub, { + eventId, + }); + const r2 = await deliverEvent("customer.subscription.created", sub, { + eventId, + }); + const r3 = await deliverEvent("customer.subscription.created", sub, { + eventId, + }); + + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + expect(r3.status).toBe(200); + expect("handler" in r1.body).toBe(true); + expect("replayed" in r2.body && r2.body.replayed).toBe(true); + expect("replayed" in r3.body && r3.body.replayed).toBe(true); + + const row = await awaitDb( + () => readActiveSubscription(org.org_id), + (r) => r?.stripe_subscription_id === sub.id + ); + expect(row!.quantity).toBe(1); + }, 60_000); +}); diff --git a/editor/lib/billing/__tests__/e2e/scenarios/lifecycle.test.ts b/editor/lib/billing/__tests__/e2e/scenarios/lifecycle.test.ts new file mode 100644 index 0000000000..cdfb91af94 --- /dev/null +++ b/editor/lib/billing/__tests__/e2e/scenarios/lifecycle.test.ts @@ -0,0 +1,77 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { assertSuiteSafety } from "../fixtures/safety"; +import { + provisionEphemeralOrg, + teardownOrg, + type EphemeralOrg, +} from "../fixtures/org"; +import { + awaitDb, + getProPriceId, + readActiveSubscription, + readCustomerId, +} from "../fixtures/db"; +import { deliverEvent } from "../fixtures/deliver-event"; +import { stripe } from "../../.."; + +describe("E2E — subscription lifecycle via real Stripe webhooks", () => { + let org: EphemeralOrg; + + beforeAll(() => assertSuiteSafety()); + afterAll(async () => { + if (org) await teardownOrg(org); + }); + + it("customer.created → subscription.created → subscription.deleted mirrors locally", async () => { + org = await provisionEphemeralOrg(); + const priceId = await getProPriceId(); + + const customer = await stripe.customers.create({ + metadata: { grida_organization_id: String(org.org_id) }, + }); + const r1 = await deliverEvent("customer.created", customer); + expect(r1.status).toBe(200); + + await awaitDb( + () => readCustomerId(org.org_id), + (id) => id === customer.id, + { label: "account.stripe_customer_id mirrors" } + ); + + // `pm_card_visa` is a token Stripe expands at attach time into a real + // pm_… id; the returned id is what `default_payment_method` accepts. + const pm = await stripe.paymentMethods.attach("pm_card_visa", { + customer: customer.id, + }); + await stripe.customers.update(customer.id, { + invoice_settings: { default_payment_method: pm.id }, + }); + const sub = await stripe.subscriptions.create({ + customer: customer.id, + items: [{ price: priceId, quantity: 1 }], + }); + const r2 = await deliverEvent("customer.subscription.created", sub); + expect(r2.status).toBe(200); + + const subRow = await awaitDb( + () => readActiveSubscription(org.org_id), + (row) => row?.stripe_subscription_id === sub.id, + { label: "active subscription mirrors" } + ); + expect(subRow!.plan).toBe("pro"); + expect(subRow!.quantity).toBe(1); + expect(["active", "trialing", "incomplete", "past_due"]).toContain( + subRow!.status + ); + + const canceled = await stripe.subscriptions.cancel(sub.id); + const r3 = await deliverEvent("customer.subscription.deleted", canceled); + expect(r3.status).toBe(200); + + await awaitDb( + () => readActiveSubscription(org.org_id), + (row) => row === null, + { label: "no active subscription after cancel" } + ); + }, 60_000); +}); diff --git a/editor/lib/billing/__tests__/e2e/scenarios/tampered-signature.test.ts b/editor/lib/billing/__tests__/e2e/scenarios/tampered-signature.test.ts new file mode 100644 index 0000000000..8545334ee3 --- /dev/null +++ b/editor/lib/billing/__tests__/e2e/scenarios/tampered-signature.test.ts @@ -0,0 +1,30 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { assertSuiteSafety } from "../fixtures/safety"; +import { deliverEvent } from "../fixtures/deliver-event"; + +describe("E2E — tampered webhook signature", () => { + beforeAll(() => assertSuiteSafety()); + + // Structural guarantee: the receiver verifies signature BEFORE calling + // `dispatchStripeEvent`, which is the only path that inserts into + // `grida_billing.stripe_event`. A 400 here proves no projection occurred. + // Direct DB-side assertion isn't worth adding because `grida_billing` is + // intentionally not on PostgREST's allow-list (locked-down schema). + it("returns 400 and does not project state", async () => { + const result = await deliverEvent( + "customer.subscription.created", + { + id: "sub_tamper_test", + status: "active", + items: { data: [] }, + }, + { tamperSignature: true } + ); + + expect(result.status).toBe(400); + expect(result.body).toHaveProperty("error"); + expect((result.body as { error: string }).error).toMatch( + /invalid|signature/i + ); + }); +}); diff --git a/editor/lib/billing/index.ts b/editor/lib/billing/index.ts new file mode 100644 index 0000000000..50fe99e190 --- /dev/null +++ b/editor/lib/billing/index.ts @@ -0,0 +1,422 @@ +// Stripe billing for Grida orgs: clients, auth, redirect validation, data +// helpers, and webhook projector dispatch. All projection logic lives in +// `public.fn_billing_apply_stripe_event`; TS only signs/parses/dispatches. + +import Stripe from "stripe"; +// Relative (not `@/lib/...`) so tsx-driven scripts that import this module +// don't need a tsconfig-paths shim to resolve the alias. +import { service_role } from "../supabase/server"; + +// --------------------------------------------------------------------------- +// Clients +// --------------------------------------------------------------------------- + +const STRIPE_API_VERSION = "2026-04-22.dahlia" as const; + +// Lazy Stripe client — instantiated on first property access, not at module +// load. This matters because Next.js evaluates route handlers during `next +// build` to collect page data, and Vercel build envs typically don't have +// runtime secrets like STRIPE_SECRET_KEY available. Throwing at module load +// breaks the build; throwing at first call only fails actual requests +// (where the env var IS available at runtime). +let _stripe: Stripe | null = null; +function getStripe(): Stripe { + if (_stripe) return _stripe; + const stripeKey = process.env.STRIPE_SECRET_KEY; + if (!stripeKey) { + throw new Error("STRIPE_SECRET_KEY is required."); + } + if ( + process.env.BILLING_TEST_MODE === "true" && + !stripeKey.startsWith("sk_test_") + ) { + throw new Error( + "BILLING_TEST_MODE=true but STRIPE_SECRET_KEY is not a test key." + ); + } + _stripe = new Stripe(stripeKey, { + apiVersion: STRIPE_API_VERSION, + httpClient: Stripe.createFetchHttpClient(), + typescript: true, + }); + return _stripe; +} + +// Proxy preserves the `stripe.subscriptions.create(...)` API at all call +// sites while deferring the env check. Each property access goes through +// `getStripe()` which short-circuits after first init. +export const stripe = new Proxy({} as Stripe, { + get(_target, prop, receiver) { + return Reflect.get(getStripe(), prop, receiver); + }, +}); +export type { Stripe }; + +// `grida_billing` is locked down — all access goes through `fn_billing_*` +// RPCs and `v_billing_*` views on `public`. We piggy-back on the project's +// shared `service_role.workspace` (also "public"-scoped) at each call site. + +// supabase-js returns `RETURNS TABLE` RPCs as arrays; unwrap to the first row. +function firstRow(data: T | T[] | null | undefined): T | null { + if (Array.isArray(data)) return (data[0] as T) ?? null; + return (data as T | null) ?? null; +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +// One error class for every billing-action failure mode. `code` is the stable +// machine-readable tag callers branch on; `status` is the HTTP code; `redirect` +// is an optional path the UI should send the user to (e.g. "billing/upgrade"). +export class BillingError extends Error { + constructor( + message: string, + readonly code: string, + readonly status: number = 400, + readonly redirect?: string + ) { + super(message); + this.name = "BillingError"; + } +} + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +export async function assertOrgMember( + user_id: string, + org_id: string | number +): Promise { + if (!user_id) throw new BillingError("unauthorized", "unauthorized", 401); + const orgId = typeof org_id === "string" ? Number(org_id) : org_id; + if (!Number.isFinite(orgId)) { + throw new BillingError(`invalid org_id: ${org_id}`, "invalid_org", 403); + } + + const { data, error } = await service_role.workspace + .from("organization_member") + .select("id") + .eq("organization_id", orgId) + .eq("user_id", user_id) + .limit(1) + .maybeSingle(); + + if (error) { + throw new BillingError( + `membership check failed: ${error.message}`, + "membership_check_failed", + 403 + ); + } + if (!data) { + throw new BillingError( + "not a member of this organization", + "not_member", + 403 + ); + } +} + +export async function assertOrgOwner( + user_id: string, + org_id: string | number +): Promise { + if (!user_id) throw new BillingError("unauthorized", "unauthorized", 401); + const orgId = typeof org_id === "string" ? Number(org_id) : org_id; + if (!Number.isFinite(orgId)) { + throw new BillingError(`invalid org_id: ${org_id}`, "invalid_org", 403); + } + + const { data, error } = await service_role.workspace + .from("organization") + .select("owner_id") + .eq("id", orgId) + .maybeSingle(); + + if (error) { + throw new BillingError( + `owner check failed: ${error.message}`, + "owner_check_failed", + 403 + ); + } + if (!data || data.owner_id !== user_id) { + throw new BillingError( + "not the owner of this organization", + "not_owner", + 403 + ); + } +} + +// --------------------------------------------------------------------------- +// Redirect validation +// --------------------------------------------------------------------------- + +// URL must be absolute, http(s), and origin-equal to the request origin or +// `NEXT_PUBLIC_BASE_URL`. Stripe Checkout accepts any URL we hand it, so this +// is the place we draw the line. +export function assertAllowedRedirect( + url: string | undefined, + request_origin: string +): string { + if (!url) { + throw new BillingError( + "invalid redirect", + "invalid_redirect", + 400, + "billing" + ); + } + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new BillingError( + "invalid redirect", + "invalid_redirect", + 400, + "billing" + ); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new BillingError( + "invalid redirect", + "invalid_redirect", + 400, + "billing" + ); + } + const allowedOrigin = new URL(request_origin).origin; + const baseEnv = process.env.NEXT_PUBLIC_BASE_URL; + let baseOrigin: string | null = null; + if (baseEnv) { + try { + baseOrigin = new URL(baseEnv).origin; + } catch { + // ignore malformed env + } + } + if (parsed.origin !== allowedOrigin && parsed.origin !== baseOrigin) { + throw new BillingError( + "invalid redirect", + "invalid_redirect", + 400, + "billing" + ); + } + return parsed.toString(); +} + +// --------------------------------------------------------------------------- +// Data helpers +// --------------------------------------------------------------------------- + +export async function getCustomerId(org_id: number): Promise { + const { data, error } = await service_role.workspace.rpc( + "fn_billing_get_customer_id", + { + p_org_id: org_id, + } + ); + if (error) throw new Error(`getCustomerId: ${error.message}`); + return typeof data === "string" && data ? data : null; +} + +export async function getCatalogueStripeIds( + grida_billing_id: string +): Promise<{ stripe_product_id: string; stripe_price_id: string } | null> { + const { data, error } = await service_role.workspace.rpc( + "fn_billing_get_catalogue", + { + p_id: grida_billing_id, + } + ); + if (error) throw new Error(`getCatalogueStripeIds: ${error.message}`); + const row = firstRow<{ + stripe_product_id?: string | null; + stripe_price_id?: string | null; + }>(data); + if (!row?.stripe_product_id || !row.stripe_price_id) return null; + return { + stripe_product_id: row.stripe_product_id, + stripe_price_id: row.stripe_price_id, + }; +} + +export async function getActivePaidSubscription(org_id: number): Promise<{ + stripe_subscription_id: string; + status: string; +} | null> { + const { data, error } = await service_role.workspace.rpc( + "fn_billing_get_active_subscription", + { + p_org_id: org_id, + } + ); + if (error) throw new Error(`getActivePaidSubscription: ${error.message}`); + const row = firstRow<{ + stripe_subscription_id?: string | null; + status?: string | null; + }>(data); + if (!row?.stripe_subscription_id) return null; + return { + stripe_subscription_id: row.stripe_subscription_id, + status: row.status ?? "active", + }; +} + +// Resolve `account.stripe_customer_id` for an org; mint + persist if absent. +// Race-safe via two layers: +// 1. Stripe `idempotencyKey: "customer:"` collapses concurrent +// `customers.create` calls into a single Stripe customer. +// 2. After-create recheck of `getCustomerId` catches the case where another +// request finished its attach between our cached read and the Stripe call. +// 3. `fn_billing_attach_stripe_customer` is row-locked, so concurrent +// attaches converge on the first-written id. +export async function resolveOrCreateStripeCustomer( + org_id: number +): Promise { + const cached = await getCustomerId(org_id); + if (cached) return cached; + + const orgRow = await service_role.workspace + .from("organization") + .select("name, display_name, email") + .eq("id", org_id) + .maybeSingle(); + const orgName = + orgRow.data?.display_name ?? orgRow.data?.name ?? `org-${org_id}`; + const ownerEmail = orgRow.data?.email ?? undefined; + + const created = await stripe.customers.create( + { + name: orgName, + email: ownerEmail, + metadata: { grida_organization_id: String(org_id) }, + }, + { idempotencyKey: `customer:${org_id}` } + ); + + // Another request may have attached its (idempotent-twin) customer between + // our cached read and now. Re-read before attaching ours; if a winner is + // already in the DB, return it and let the duplicate Stripe customer fall + // away (it is the same id under Stripe's idempotency key, so this is safe). + const concurrent = await getCustomerId(org_id); + if (concurrent) return concurrent; + + const attachRes = await service_role.workspace.rpc( + "fn_billing_attach_stripe_customer", + { + p_org_id: org_id, + p_stripe_customer_id: created.id, + } + ); + if (attachRes.error) { + throw new Error( + `resolveOrCreateStripeCustomer attach: ${attachRes.error.message}` + ); + } + const row = firstRow<{ stripe_customer_id?: string | null }>(attachRes.data); + return row?.stripe_customer_id ?? created.id; +} + +// --------------------------------------------------------------------------- +// Webhook projector +// --------------------------------------------------------------------------- + +type StripeEventDispatchResult = { + result: "handled" | "replayed"; + handler: string | null; +}; + +// `charge.dispute.*` events lack the subscription id; the projector requires +// `metadata.grida_subscription_id`. Walk dispute → charge → invoice → sub +// before dispatching. If we can't resolve, leave it — the projector raises +// a clear error and Stripe retries. +async function enrichDispute( + payload: Record +): Promise> { + const md = payload.metadata as Record | null | undefined; + if (md && typeof md.grida_subscription_id === "string") return payload; + + const chargeRef = payload.charge; + const chargeId = + typeof chargeRef === "string" + ? chargeRef + : (chargeRef as { id?: string } | null)?.id; + if (!chargeId) return payload; + + try { + const charge = (await stripe.charges.retrieve(chargeId, { + expand: ["invoice"], + })) as unknown as { invoice?: unknown }; + const invoice = charge.invoice as + | { subscription?: string | { id: string } | null } + | string + | null + | undefined; + if (!invoice || typeof invoice === "string") return payload; + const sub = invoice.subscription; + const subId = typeof sub === "string" ? sub : sub?.id; + if (!subId) return payload; + return { + ...payload, + metadata: { ...md, grida_subscription_id: subId }, + }; + } catch (err) { + console.warn("[billing] dispute pre-resolve failed:", err); + return payload; + } +} + +export async function dispatchStripeEvent( + event: Stripe.Event +): Promise { + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- Stripe object shape varies per event type; jsonb passthrough + let payload: any = event.data.object; + if (event.type.startsWith("charge.dispute.")) { + payload = await enrichDispute(payload as Record); + } + + const { data, error } = await service_role.workspace.rpc( + "fn_billing_apply_stripe_event", + { + p_event_id: event.id, + p_event_type: event.type, + p_payload: payload, + } + ); + if (error) throw new Error(`apply_stripe_event: ${error.message}`); + + const row = firstRow(data) as { + result?: "handled" | "replayed"; + handler?: string | null; + } | null; + return { + result: row?.result ?? "handled", + handler: row?.handler ?? null, + }; +} + +// Failure stamps go through a separate RPC so they survive the projector's +// RAISE-driven rollback. Passes `eventType` so the RPC can UPSERT — the +// projector's `INSERT INTO stripe_event` rolls back with the failure, and an +// UPDATE-only stamp would silently match nothing on first failure. +export async function stampStripeEventFailure( + eventId: string, + eventType: string, + reason: string +): Promise { + const { error } = await service_role.workspace.rpc( + "fn_billing_stamp_failure", + { + p_event_id: eventId, + p_event_type: eventType, + p_reason: reason, + } + ); + if (error) console.warn("[billing] stamp_failure:", error.message); +} diff --git a/editor/lib/billing/marketing-plans.ts b/editor/lib/billing/marketing-plans.ts new file mode 100644 index 0000000000..25645abcab --- /dev/null +++ b/editor/lib/billing/marketing-plans.ts @@ -0,0 +1,146 @@ +// Marketing-shaped plan data for the public pricing page (`/pricing`). +// Lives next to `plans.ts` so that any change to numbers (`plans.ts`) +// surfaces to a developer also touching the marketing surface — the two +// representations of the same plan should never silently drift. +// +// Numbers come from `plans.ts`. Copy (feature bullets, CTAs, badges, +// Free/Enterprise tiers that don't have a runtime billing row) lives +// here because the marketing surface naturally needs more than the +// runtime billing surface does. + +import { + PAID_PLANS, + price_monthly_equivalent_dollars, + price_dollars, +} from "./plans"; + +export interface PricingInformation { + id: string; + name: string; + nameBadge?: string; + costUnit?: string; + href: string; + priceLabel?: string; + priceMonthly: number | string; + /** Small note under price (e.g. "Starts from $599/mo" for Enterprise) */ + priceNote?: string; + warning?: string; + warningTooltip?: string; + description: string; + highlight?: boolean; + features: { + name: string; + trail?: string; + }[]; + cta: string; +} + +const proMonthlyDollars = price_dollars(PAID_PLANS.pro.id, "month"); +const teamMonthlyDollars = price_dollars(PAID_PLANS.team.id, "month"); +const proAnnualMonthlyEquivDollars = price_monthly_equivalent_dollars( + PAID_PLANS.pro.id, + "year" +); +const teamAnnualMonthlyEquivDollars = price_monthly_equivalent_dollars( + PAID_PLANS.team.id, + "year" +); + +export const plans: PricingInformation[] = [ + { + id: "tier_free", + name: "Free", + nameBadge: "", + href: "/dashboard/new?plan=free", + priceLabel: "", + priceMonthly: "$0", + description: "Perfect for hobby projects.", + features: [ + { name: "1,000 monthly active users" }, + { name: "Projects & Sites", trail: "3" }, + { name: "AI Credits", trail: "500" }, + { name: "Designs", trail: "♾️" }, + { name: "Forms", trail: "♾️" }, + { name: "Seats", trail: "1" }, + { name: "1GB Storage" }, + ], + cta: "Start for free", + }, + { + id: "tier_pro", + name: "Pro", + highlight: true, + nameBadge: "Most Popular", + costUnit: "per month", + href: "/dashboard/new?plan=pro", + priceLabel: "From", + warning: "$10 in compute credits included", + priceMonthly: `$${proMonthlyDollars}`, + description: "For teams with creative workflows.", + features: [ + { name: "10,000 monthly active users" }, + { name: "Unlimited Projects & Sites" }, + { name: "AI Credits", trail: "10,000" }, + { name: "Designs", trail: "♾️" }, + { name: "Forms", trail: "♾️" }, + { name: "Seats", trail: "1" }, + { name: "30GB Storage" }, + { name: "Email support" }, + ], + cta: "Get Started", + }, + { + id: "tier_team", + name: "Team", + nameBadge: "", + costUnit: "per month", + href: "/dashboard/new?plan=team", + priceLabel: "From", + warning: "$10 in compute credits included", + priceMonthly: `$${teamMonthlyDollars}`, + description: "Pro, plus more automated process", + features: [ + { name: "50,000 monthly active users" }, + { name: "Unlimited Projects & Sites" }, + { name: "AI Credits", trail: "35,000" }, + { name: "Designs", trail: "♾️" }, + { name: "Forms", trail: "♾️" }, + { name: "Seats", trail: "1" }, + { name: "500GB Storage" }, + { name: "Chat support" }, + ], + cta: "Get Started", + }, + { + id: "tier_enterprise", + name: "Enterprise", + href: "https://grida.co/d/e/c3cf8937-f4f3-4c69-81f3-8d3b9e109013", + priceLabel: "", + priceMonthly: "Custom", + priceNote: "Starts from $599/mo", + description: + "Dedicated support and managed experience. We run it, you ship.", + features: [ + { name: "Direct Slack access to engineers" }, + { name: "Managed platform—no fork needed" }, + { name: "Cloud or On-premises deployment" }, + { name: "Custom features tailored to you" }, + ], + cta: "Contact Sales", + }, +]; + +export const save_plans: PricingInformation[] = [ + plans[0], + { + ...plans[1], + priceMonthly: `$${proAnnualMonthlyEquivDollars}`, + href: "/dashboard/new?plan=pro&period=yearly", + }, + { + ...plans[2], + priceMonthly: `$${teamAnnualMonthlyEquivDollars}`, + href: "/dashboard/new?plan=team&period=yearly", + }, + plans[3], +]; diff --git a/editor/lib/billing/plans.ts b/editor/lib/billing/plans.ts new file mode 100644 index 0000000000..1d6bd91b4f --- /dev/null +++ b/editor/lib/billing/plans.ts @@ -0,0 +1,102 @@ +// Single source of truth for paid-plan definitions in the editor billing +// surface. Used by: +// • the upgrade UI (plan cards) +// • the billing settings UI (current price line) +// • the Stripe setup script (to provision products + prices) +// +// Stripe is the runtime authority for what gets charged — these constants +// are what the setup script *writes* to Stripe. The www marketing pricing +// page reads from `./marketing-plans` (sibling file) so price numbers +// can't drift between surfaces; only copy (descriptions, features, CTAs) +// lives there separately. + +export type PaidPlanId = "pro" | "team"; +export type PlanId = "free" | PaidPlanId; +export type Interval = "month" | "year"; + +/** + * `grida_billing.product_catalogue.id` keys. Monthly is `plan.`; + * annual is `plan..annual`. The webhook projector reads the plan + * out of this id, so the wire format matters. + */ +export type CatalogueId = + | "plan.pro" + | "plan.pro.annual" + | "plan.team" + | "plan.team.annual"; + +export type PaidPlanDefinition = { + id: PaidPlanId; + name: string; + description: string; + /** Sticker monthly price in cents. */ + monthly_cents: number; + /** Annual price in cents. By design = monthly_cents × 12 × 0.8 (20% off). */ + annual_cents: number; + features: ReadonlyArray; +}; + +export const PAID_PLANS: Readonly> = { + pro: { + id: "pro", + name: "Pro", + description: "For solo builders shipping production work.", + monthly_cents: 2000, + annual_cents: 19200, + features: [ + "Stripe-managed billing & invoices", + "Higher monthly AI allowance", + "Cancel or switch plans anytime via the Customer Portal", + ], + }, + team: { + id: "team", + name: "Team", + description: "More headroom for heavier workflows.", + monthly_cents: 6000, + annual_cents: 57600, + features: [ + "Everything in Pro", + "More storage & monthly active users", + "Chat support", + ], + }, +}; + +export const PAID_PLAN_LIST: ReadonlyArray = [ + PAID_PLANS.pro, + PAID_PLANS.team, +]; + +export const PLAN_RANK: Readonly> = { + free: 0, + pro: 1, + team: 2, +}; + +export function price_catalogue_id( + plan: PaidPlanId, + interval: Interval +): CatalogueId { + return interval === "year" ? `plan.${plan}.annual` : `plan.${plan}`; +} + +export function price_cents(plan: PaidPlanId, interval: Interval): number { + return interval === "year" + ? PAID_PLANS[plan].annual_cents + : PAID_PLANS[plan].monthly_cents; +} + +export function price_dollars(plan: PaidPlanId, interval: Interval): number { + return price_cents(plan, interval) / 100; +} + +/** Effective monthly equivalent — useful for "$/mo" labels under annual prices. */ +export function price_monthly_equivalent_dollars( + plan: PaidPlanId, + interval: Interval +): number { + return interval === "year" + ? PAID_PLANS[plan].annual_cents / 12 / 100 + : PAID_PLANS[plan].monthly_cents / 100; +} diff --git a/editor/lib/supabase/server.ts b/editor/lib/supabase/server.ts index 050a3ce830..64c51af190 100644 --- a/editor/lib/supabase/server.ts +++ b/editor/lib/supabase/server.ts @@ -94,6 +94,19 @@ const __create_service_role_client = < }; /** + * Service-role Supabase clients (RLS-bypassing). Per-schema namespace — + * pick the one matching the table you're querying. + * + * Usage rule: **always reference `service_role.` inline at every + * call site.** Do not alias it to a local variable (`const db = service_role.workspace`) + * or re-export it. The point of the long, explicit name is that any reviewer + * can grep `service_role` and find every privileged DB touch — aliasing + * defeats that. + * + * @example + * await service_role.workspace.from("organization").select("id"); // ✅ + * const db = service_role.workspace; await db.from(...); // ❌ defeats grep + * * @deprecated - deprecation warning for extra security (not actually deprecated) */ export namespace service_role { diff --git a/editor/package.json b/editor/package.json index daf3adea97..1245536d74 100644 --- a/editor/package.json +++ b/editor/package.json @@ -189,6 +189,7 @@ "signature_pad": "^4.2.0", "sonner": "^2.0.7", "streamdown": "^1.6.11", + "stripe": "^22.1.0", "stylis": "^4.3.2", "svg-pathdata": "^7.2.0", "swr": "^2.2.5", diff --git a/editor/scaffolds/workspace/sidebar.tsx b/editor/scaffolds/workspace/sidebar.tsx index e26450a84b..f3d343ef1f 100644 --- a/editor/scaffolds/workspace/sidebar.tsx +++ b/editor/scaffolds/workspace/sidebar.tsx @@ -64,7 +64,7 @@ import type { GDocument, OrganizationWithAvatar, OrganizationWithMembers, - PlatformPricingTier, + PlanTier, } from "@/types"; import Link from "next/link"; import "core-js/features/object/group-by"; @@ -190,23 +190,22 @@ function PricingTierCard({ }: { organization: OrganizationWithAvatar & OrganizationWithMembers; }) { - const { display_plan: tier, members } = organization; + const { plan, is_enterprise, members } = organization; const ENTERPRISE_DEFAULT_SEATS = 5; - const label = Labels.priceTier(tier); - const isEnterprise = tier === "v0_enterprise"; - const isTeam = tier === "v0_team"; + const label = Labels.planTier(plan, is_enterprise); + const isEnterprise = is_enterprise; + const isTeam = plan === "team"; const canUpgrade = !isEnterprise && !isTeam; - const tierMessages: Record = { + const tierMessages: Record = { free: "You're currently on the Free Plan. Upgrade to unlock premium features!", - v0_pro: - "Thanks for being a Pro user! You're accessing advanced capabilities.", - v0_team: "You're on our Team plan, optimized for collaboration.", - v0_enterprise: - "You're on Enterprise Plan—thank you for your continued partnership", + pro: "Thanks for being a Pro user! You're accessing advanced capabilities.", + team: "You're on our Team plan, optimized for collaboration.", }; - const message = tierMessages[tier] ?? `Thanks for subscribing to ${label}!`; + const message = isEnterprise + ? "You're on Enterprise Plan—thank you for your continued partnership" + : (tierMessages[plan] ?? `Thanks for subscribing to ${label}!`); return (

@@ -219,7 +218,9 @@ function PricingTierCard({

{message}

{canUpgrade && ( - + @@ -519,7 +520,7 @@ function OrganizationSwitcher({ variant="outline" className="ms-auto text-xs px-1.5 py-0.5 font-normal text-muted-foreground" > - {Labels.priceTier(org.display_plan)} + {Labels.planTier(org.plan, org.is_enterprise)} diff --git a/editor/scaffolds/workspace/workspace.tsx b/editor/scaffolds/workspace/workspace.tsx index dfa826f31b..3400829c88 100644 --- a/editor/scaffolds/workspace/workspace.tsx +++ b/editor/scaffolds/workspace/workspace.tsx @@ -65,6 +65,17 @@ const workspaceReducer = (state: __WorkspaceState, action: WorkspaceAction) => switch (action.type) { case "init/organizations": draft.organizations = action.organizations; + // The reducer's `organization` field came from a server-render prop + // and can be stale (e.g. derived fields like `plan`). Refresh it from + // the SWR-fetched list when the active org appears there. + { + const fresh = action.organizations.find( + (o) => o.id === draft.organization.id + ); + if (fresh) { + draft.organization = { ...draft.organization, ...fresh }; + } + } break; case "init/projects": draft.projects = action.projects; @@ -138,6 +149,10 @@ export function Workspace({ avatar_url: organization.avatar_path ? PublicUrls.organization_avatar_url(supabase)(organization.avatar_path) : null, + // `plan` is server-resolved by the workspace API (from + // `v_billing_subscription`). Default to "free" until the SWR fetch lands + // and the reducer refreshes `state.organization` with the real value. + plan: "free" as const, }, organizations: [], projects: [], diff --git a/editor/scripts/billing/setup-stripe-test.ts b/editor/scripts/billing/setup-stripe-test.ts new file mode 100644 index 0000000000..7afff3d7f7 --- /dev/null +++ b/editor/scripts/billing/setup-stripe-test.ts @@ -0,0 +1,325 @@ +#!/usr/bin/env -S pnpm tsx +// Idempotent Stripe test-mode setup: products, prices, and Customer +// Portal config. Match-or-create by `metadata.grida_billing_id`. Writes the +// resulting Stripe ids into `grida_billing.product_catalogue`. +// +// Run from the repo root: +// pnpm tsx editor/scripts/billing/setup-stripe-test.ts +// +// Refuses to run unless STRIPE_SECRET_KEY starts with `sk_test_` and +// BILLING_TEST_MODE=true. Reads env from (in precedence order): +// process.env > .env.test.local > .env.test > .env.local + +import * as fs from "node:fs"; +import * as path from "node:path"; +// Type-only — erased at compile time, so no runtime touch of lib/billing. +import type { Stripe } from "../../lib/billing"; + +// Env must be loaded before any import touches `lib/billing`, which throws on +// missing STRIPE_SECRET_KEY / SUPABASE_* at module load. +function loadEnvFile(filePath: string): void { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (!(key in process.env)) process.env[key] = value; + } +} +const editorDir = path.resolve(__dirname, "..", ".."); +loadEnvFile(path.join(editorDir, ".env.test.local")); +loadEnvFile(path.join(editorDir, ".env.test")); +loadEnvFile(path.join(editorDir, ".env.local")); + +async function main(): Promise { + const sk = process.env.STRIPE_SECRET_KEY ?? ""; + if (!sk.startsWith("sk_test_")) { + throw new Error("Refusing: STRIPE_SECRET_KEY must start with 'sk_test_'."); + } + if (process.env.BILLING_TEST_MODE !== "true") { + throw new Error("Refusing: BILLING_TEST_MODE must be 'true'."); + } + + const { stripe } = await import("../../lib/billing"); + const { service_role } = await import("../../lib/supabase/server"); + + // ----------------------------------------------------------------------- + // Plan products + prices (monthly + annual) + // ----------------------------------------------------------------------- + // + // One Stripe product per plan ("Grida Pro", "Grida Team"); two prices per + // product (monthly + annual). Annual prices encode the 20% discount in + // `unit_amount` directly (no separate coupon line). The catalogue gets + // one row per (plan, interval) pair, keyed `plan.` for monthly + // and `plan..annual` for annual. + // + // Numeric prices come from `lib/billing/plans.ts` (single source of + // truth) — never hardcode them here. + + const { PAID_PLAN_LIST, price_catalogue_id } = + await import("../../lib/billing/plans"); + type Interval = "month" | "year"; + + type PlanProduct = { + name: string; + description: string; + product_grida_id: `plan.${"pro" | "team"}`; + }; + + type PriceSpec = { + catalogue_id: + | "plan.pro" + | "plan.team" + | "plan.pro.annual" + | "plan.team.annual"; + interval: Interval; + unit_amount_cents: number; + nickname: string; + }; + + const PRODUCTS = Object.fromEntries( + PAID_PLAN_LIST.map((p): [typeof p.id, PlanProduct] => [ + p.id, + { + name: `Grida ${p.name}`, + description: `Grida ${p.name}: $${p.monthly_cents / 100}/mo or $${ + p.annual_cents / 100 + }/yr (20% off).`, + product_grida_id: `plan.${p.id}`, + }, + ]) + ) as Record<"pro" | "team", PlanProduct>; + + const PRICES: { product: PlanProduct; price: PriceSpec }[] = + PAID_PLAN_LIST.flatMap((p) => [ + { + product: PRODUCTS[p.id], + price: { + catalogue_id: price_catalogue_id(p.id, "month"), + interval: "month" as const, + unit_amount_cents: p.monthly_cents, + nickname: `${p.name} monthly`, + }, + }, + { + product: PRODUCTS[p.id], + price: { + catalogue_id: price_catalogue_id(p.id, "year"), + interval: "year" as const, + unit_amount_cents: p.annual_cents, + nickname: `${p.name} annual`, + }, + }, + ]); + + // We list+filter instead of products.search because search is eventually + // consistent and can miss a product we created seconds ago, breaking + // idempotency on the second run. + async function ensureProduct(p: PlanProduct): Promise { + const list = await stripe.products.list({ active: true, limit: 100 }); + const existing = list.data.find( + (x) => x.metadata?.grida_billing_id === p.product_grida_id + ); + if (existing) { + console.log( + `[stripe-setup] reusing product ${existing.id} (${p.product_grida_id})` + ); + return existing.id; + } + const created = await stripe.products.create({ + name: p.name, + description: p.description, + metadata: { grida_billing_id: p.product_grida_id }, + }); + console.log( + `[stripe-setup] created product ${created.id} (${p.product_grida_id})` + ); + return created.id; + } + + async function ensurePrice( + product_id: string, + spec: PriceSpec + ): Promise { + const list = await stripe.prices.list({ + product: product_id, + active: true, + limit: 100, + }); + const existing = list.data.find( + (p) => + p.unit_amount === spec.unit_amount_cents && + p.currency === "usd" && + p.recurring?.interval === spec.interval && + p.recurring?.usage_type === "licensed" + ); + if (existing) { + console.log( + `[stripe-setup] reusing price ${existing.id} (${spec.catalogue_id} $${spec.unit_amount_cents / 100}/${spec.interval})` + ); + return existing.id; + } + const created = await stripe.prices.create({ + product: product_id, + currency: "usd", + unit_amount: spec.unit_amount_cents, + recurring: { interval: spec.interval, usage_type: "licensed" }, + nickname: spec.nickname, + metadata: { grida_billing_id: spec.catalogue_id }, + }); + console.log( + `[stripe-setup] created price ${created.id} (${spec.catalogue_id} $${spec.unit_amount_cents / 100}/${spec.interval})` + ); + return created.id; + } + + async function writeCatalogue( + catalogue_id: PriceSpec["catalogue_id"], + product_id: string, + price_id: string + ): Promise { + const { error } = await service_role.workspace.rpc( + "fn_billing_setup_product", + { + p_grida_billing_id: catalogue_id, + p_stripe_product_id: product_id, + p_stripe_price_id: price_id, + } + ); + if (error) { + throw new Error(`writeCatalogue ${catalogue_id}: ${error.message}`); + } + } + + // ----------------------------------------------------------------------- + // Customer Portal config + // ----------------------------------------------------------------------- + + // `proration_behavior=always_invoice` immediately invoices the prorated + // difference on a price change (including monthly ↔ annual on the same + // plan) rather than deferring to the next invoice. Downgrades still + // prorate (Stripe credits the unused portion to the Customer Balance). + type ProductWired = { + product_id: string; + monthly_price_id: string; + annual_price_id: string; + }; + + async function setupPortal(wired: { + pro: ProductWired; + team: ProductWired; + }): Promise { + type ConfigParams = Stripe.BillingPortal.ConfigurationCreateParams; + const config: ConfigParams = { + business_profile: { headline: "Grida billing" }, + features: { + // We never expose the generic portal dashboard. Every portal session + // we open is a deep-link `flow_data` session scoped to one intent. + // The features below have to be `enabled` for their corresponding + // flow_data types to work, but with no generic portal entry point + // the user never reaches the dashboard anyway. + // + // Disabled: customer_update (no flow_data type for email/profile + // edits; we don't want a section the user can't control from our UI). + subscription_cancel: { + enabled: true, + mode: "at_period_end", + proration_behavior: "none", + }, + customer_update: { enabled: false }, + payment_method_update: { enabled: true }, + invoice_history: { enabled: true }, + subscription_update: { + enabled: true, + default_allowed_updates: ["price"], + proration_behavior: "always_invoice", + products: [ + { + product: wired.pro.product_id, + prices: [wired.pro.monthly_price_id, wired.pro.annual_price_id], + }, + { + product: wired.team.product_id, + prices: [wired.team.monthly_price_id, wired.team.annual_price_id], + }, + ], + }, + }, + metadata: { grida_billing_id: "portal.v1" }, + }; + + const list = await stripe.billingPortal.configurations.list({ limit: 100 }); + const existing = list.data.find( + (c) => c.metadata?.grida_billing_id === "portal.v1" + ); + + if (existing) { + const updated = await stripe.billingPortal.configurations.update( + existing.id, + config as Stripe.BillingPortal.ConfigurationUpdateParams + ); + console.log(`[stripe-setup] updated portal config ${updated.id}`); + return updated.id; + } + + const created = await stripe.billingPortal.configurations.create(config); + console.log(`[stripe-setup] created portal config ${created.id}`); + return created.id; + } + + console.log("[stripe-setup] starting"); + + // Provision both products in parallel, then their 4 prices in parallel. + const [pro_product_id, team_product_id] = await Promise.all([ + ensureProduct(PRODUCTS.pro), + ensureProduct(PRODUCTS.team), + ]); + + const productIdFor = (p: PlanProduct): string => + p === PRODUCTS.pro ? pro_product_id : team_product_id; + + const priceIds = await Promise.all( + PRICES.map(async ({ product, price }) => { + const id = await ensurePrice(productIdFor(product), price); + await writeCatalogue(price.catalogue_id, productIdFor(product), id); + return { catalogue_id: price.catalogue_id, price_id: id }; + }) + ); + + const idByCatalogue = (id: PriceSpec["catalogue_id"]): string => { + const found = priceIds.find((p) => p.catalogue_id === id); + if (!found) throw new Error(`missing price for ${id}`); + return found.price_id; + }; + + const wired = { + pro: { + product_id: pro_product_id, + monthly_price_id: idByCatalogue("plan.pro"), + annual_price_id: idByCatalogue("plan.pro.annual"), + }, + team: { + product_id: team_product_id, + monthly_price_id: idByCatalogue("plan.team"), + annual_price_id: idByCatalogue("plan.team.annual"), + }, + }; + + const portal_config_id = await setupPortal(wired); + console.log("[stripe-setup] done"); + console.log(JSON.stringify({ ...wired, portal_config_id }, null, 2)); +} + +main().catch((err) => { + console.error("[stripe-setup] failed:", err?.message ?? err); + process.exit(1); +}); diff --git a/editor/tsconfig.json b/editor/tsconfig.json index b49fc9d70b..e77eb1d7fd 100644 --- a/editor/tsconfig.json +++ b/editor/tsconfig.json @@ -31,5 +31,5 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "scripts"] } diff --git a/editor/types/types.ts b/editor/types/types.ts index df1aa644a3..15c252ac10 100644 --- a/editor/types/types.ts +++ b/editor/types/types.ts @@ -1,10 +1,12 @@ import grida from "@grida/schema"; -export type PlatformPricingTier = - | "free" - | "v0_pro" - | "v0_team" - | "v0_enterprise"; +/** + * Plan tier as it appears in the Grida UI. Sourced from + * `grida_billing.subscription.plan` (Stripe-backed) — `'free' | 'pro' | 'team'`. + * Enterprise is a separate ops flag (`organization.is_enterprise`); when true, + * the UI renders Enterprise regardless of the underlying plan. + */ +export type PlanTier = "free" | "pro" | "team"; export type JSONValue = | string @@ -102,7 +104,8 @@ export interface Organization { blog: string | null; description: string | null; display_name: string; - display_plan: PlatformPricingTier; + /** Manual ops flag — overrides `plan` for UI rendering when true. */ + is_enterprise: boolean; id: number; name: string; owner_id: string; @@ -114,6 +117,12 @@ export type OrganizationWithMembers = Organization & { export type OrganizationWithAvatar = Organization & { avatar_url: string | null; + /** + * Stripe-driven plan from `v_billing_subscription`, resolved server-side + * by the workspace API. Not a DB column — only present on responses that + * went through that API path. + */ + plan: PlanTier; }; export interface OrganizationMember { diff --git a/editor/vitest.config.ts b/editor/vitest.config.ts index 0b9489bdcb..05178740aa 100644 --- a/editor/vitest.config.ts +++ b/editor/vitest.config.ts @@ -1,5 +1,43 @@ import { configDefaults, defineConfig } from "vitest/config"; import { fileURLToPath, URL } from "node:url"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +// Auto-load `.env.test` and `.env.test.local` so `pnpm vitest run` works +// without `set -a; . ./.env.test.local` ceremony. +// Precedence (highest wins): shell > .env.test.local > .env.test. +// `loadEnvFile()` only sets a key when not already in process.env, so an +// existing shell-exported value is never overridden by a file. +function loadEnvFile(filePath: string): void { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (!(key in process.env)) process.env[key] = value; + } +} +const dir = path.dirname(fileURLToPath(import.meta.url)); +loadEnvFile(path.join(dir, ".env.test.local")); +loadEnvFile(path.join(dir, ".env.test")); + +// The billing E2E suite under `lib/billing/__tests__/e2e/` hits real Stripe +// (test mode) and the local webhook receiver. Slow, rate-limited, requires +// credentials. Excluded from default runs; gate with BILLING_E2E=1. +// +// Note: this is opt-in only. Do NOT set `BILLING_E2E=1` in committed env +// files — CI runs default `pnpm test` and has no Stripe credentials, +// so flipping the default would break CI. +const BILLING_E2E_GATE = process.env.BILLING_E2E === "1"; export default defineConfig({ root: ".", @@ -14,7 +52,19 @@ export default defineConfig({ ...configDefaults.exclude, "**/.next/**", "**/dist/**", - "**/e2e/**", + // Always exclude top-level `editor/e2e/` (Playwright suite — runs + // via `pnpm test:e2e`, never under vitest). + "e2e/**", + // Billing E2E only runs when the gate is on. + ...(BILLING_E2E_GATE ? [] : ["lib/billing/__tests__/e2e/**"]), ], + // Billing E2E runs sequentially: shared Stripe rate limits and the + // single local webhook receiver can't safely interleave. + ...(BILLING_E2E_GATE + ? { + fileParallelism: false, + sequence: { concurrent: false }, + } + : {}), }, }); diff --git a/editor/www/data/plans.ts b/editor/www/data/plans.ts deleted file mode 100644 index 4188d9686a..0000000000 --- a/editor/www/data/plans.ts +++ /dev/null @@ -1,178 +0,0 @@ -export interface PricingInformation { - id: string; - name: string; - nameBadge?: string; - costUnit?: string; - href: string; - priceLabel?: string; - priceMonthly: number | string; - /** Small note under price (e.g. "Starts from $599/mo" for Enterprise) */ - priceNote?: string; - warning?: string; - warningTooltip?: string; - description: string; - highlight?: boolean; - features: { - name: string; - trail?: string; - }[]; - cta: string; -} - -export const plans: PricingInformation[] = [ - { - id: "tier_free", - name: "Free", - nameBadge: "", - href: "/dashboard/new?plan=free", - priceLabel: "", - priceMonthly: "$0", - description: "Perfect for hobby projects.", - features: [ - { - name: "1,000 monthly active users", - }, - { - name: "Projects & Sites", - trail: "3", - }, - { - name: "AI Credits", - trail: "500", - }, - { - name: "Designs", - trail: "♾️", - }, - { - name: "Forms", - trail: "♾️", - }, - { - name: "Seats", - trail: "1", - }, - { - name: "1GB Storage", - }, - ], - cta: "Start for free", - }, - { - id: "tier_pro", - name: "Pro", - highlight: true, - nameBadge: "Most Popular", - costUnit: "per seat/month", - href: "/dashboard/new?plan=pro", - priceLabel: "From", - warning: "$10 in compute credits included", - priceMonthly: `$20`, - description: "For teams with creative workflows.", - features: [ - { - name: "10,000 monthly active users", - }, - { - name: "Unlimited Projects & Sites", - }, - { - name: "AI Credits", - trail: "10,000", - }, - { - name: "Designs", - trail: "♾️", - }, - { - name: "Forms", - trail: "♾️", - }, - { - name: "Seats", - trail: "♾️", - }, - { - name: "30GB Storage", - }, - { - name: "Email support", - }, - ], - cta: "Get Started", - }, - { - id: "tier_team", - name: "Team", - nameBadge: "", - costUnit: "per seat/month", - href: "/dashboard/new?plan=team", - priceLabel: "From", - warning: "$10 in compute credits included", - priceMonthly: `$60`, - description: "Pro, plus more automated process", - features: [ - { - name: "50,000 monthly active users", - }, - { - name: "Unlimited Projects & Sites", - }, - { - name: "AI Credits", - trail: "35,000", - }, - { - name: "Designs", - trail: "♾️", - }, - { - name: "Forms", - trail: "♾️", - }, - { - name: "Seats", - trail: "♾️", - }, - { - name: "500GB Storage", - }, - { - name: "Chat support", - }, - ], - cta: "Get Started", - }, - { - id: "tier_enterprise", - name: "Enterprise", - href: "https://grida.co/d/e/c3cf8937-f4f3-4c69-81f3-8d3b9e109013", - priceLabel: "", - priceMonthly: "Custom", - priceNote: "Starts from $599/mo", - description: - "Dedicated support and managed experience. We run it, you ship.", - features: [ - { name: "Direct Slack access to engineers" }, - { name: "Managed platform—no fork needed" }, - { name: "Cloud or On-premises deployment" }, - { name: "Custom features tailored to you" }, - ], - cta: "Contact Sales", - }, -]; - -export const save_plans: PricingInformation[] = [ - plans[0], - { - ...plans[1], - priceMonthly: `$16`, - href: "/dashboard/new?plan=pro&period=yearly", - }, - { - ...plans[2], - priceMonthly: `$48`, - href: "/dashboard/new?plan=team&period=yearly", - }, - plans[3], -]; diff --git a/editor/www/data/pricing.ts b/editor/www/data/pricing.ts index fdb34f2679..e2fb85b263 100644 --- a/editor/www/data/pricing.ts +++ b/editor/www/data/pricing.ts @@ -3,10 +3,9 @@ type Pricing = { highlight: PricingCategory; integrations: PricingCategory; storage: PricingCategory; - support: PricingCategory; - ticketing: PricingCategory; commerce: PricingCategory; channels: PricingCategory; + support: PricingCategory; commingsoon: PricingCategory; }; @@ -47,7 +46,7 @@ export const pricing: Pricing = { usage_based: false, }, { - title: "Generated Image License", + title: "Generated Media License", plans: { free: "Public (CC0)", pro: "Full ownership", @@ -67,22 +66,15 @@ export const pricing: Pricing = { usage_based: false, }, { - title: "Credits Rollover", - plans: { - free: false, - pro: true, - team: true, - enterprise: true, - }, - usage_based: false, - }, - { + // Top-up flow is not yet shipped — keep the row visible (so the + // comparison stays informational) but advertise it as unavailable + // across all tiers until the feature lands. title: "Buy extra credits", plans: { free: false, pro: false, - team: true, - enterprise: true, + team: false, + enterprise: false, }, usage_based: false, }, @@ -182,14 +174,14 @@ export const pricing: Pricing = { usage_based: false, }, { - title: "Advanced Analytics", + title: "Simulator", plans: { free: false, pro: false, - team: true, + team: false, enterprise: true, }, - usage_based: false, + usage_based: true, }, { title: "Remove branding from Sites", @@ -203,31 +195,6 @@ export const pricing: Pricing = { }, ], }, - support: { - title: "Support", - features: [ - { - title: "Community Support", - plans: { - free: true, - pro: true, - team: true, - enterprise: true, - }, - usage_based: false, - }, - { - title: "Live chat support", - plans: { - free: false, - pro: false, - team: true, - enterprise: true, - }, - usage_based: false, - }, - ], - }, storage: { title: "Storage", features: [ @@ -256,26 +223,6 @@ export const pricing: Pricing = { integrations: { title: "Integrations", features: [ - { - title: "Connect to Google sheets", - plans: { - free: false, - pro: false, - team: false, - enterprise: true, - }, - usage_based: false, - }, - { - title: "Connect to Notion Database", - plans: { - free: false, - pro: false, - team: false, - enterprise: true, - }, - usage_based: false, - }, { title: "Custom Domain", plans: { @@ -298,43 +245,6 @@ export const pricing: Pricing = { }, ], }, - ticketing: { - title: "Ticketing", - features: [ - { - title: "Concurrent Users", - plans: { - free: "Up to 50 concurrencies", - pro: "Up to 100 concurrencies", - team: "Up to 250 concurrencies", - enterprise: - "250 concurrencies included. then $100 / 500 concurrencies", - }, - usage_based: true, - }, - { - title: "Dedicated Servers for High demand", - plans: { - free: false, - pro: false, - team: false, - enterprise: - "$3,000 initially. then $2,500 per 10,000 concurrencies (may vary)", - }, - usage_based: true, - }, - { - title: "Simulator", - plans: { - free: false, - pro: false, - team: false, - enterprise: true, - }, - usage_based: true, - }, - ], - }, commerce: { title: "Commerce", features: [ @@ -348,26 +258,6 @@ export const pricing: Pricing = { }, usage_based: false, }, - { - title: "Payments with Toss (for 🇰🇷)", - plans: { - free: false, - pro: false, - team: "Contact Sales", - enterprise: "Contact Sales", - }, - usage_based: false, - }, - { - title: "Ticketing for Events", - plans: { - free: false, - pro: false, - team: true, - enterprise: true, - }, - usage_based: false, - }, { title: "Inventory Management", plans: { @@ -403,10 +293,15 @@ export const pricing: Pricing = { }, usage_based: false, }, + ], + }, + support: { + title: "Support", + features: [ { - title: "WhatsApp Notifications", + title: "Community Support", plans: { - free: false, + free: true, pro: true, team: true, enterprise: true, @@ -414,12 +309,12 @@ export const pricing: Pricing = { usage_based: false, }, { - title: "KakaoTalk Notifications (for 🇰🇷)", + title: "Live chat support", plans: { free: false, pro: false, - team: false, - enterprise: "Contact Sales", + team: true, + enterprise: true, }, usage_based: false, }, diff --git a/editor/www/pricing/pricing-comparison-table.tsx b/editor/www/pricing/pricing-comparison-table.tsx index c8cc7df09a..a6134795db 100644 --- a/editor/www/pricing/pricing-comparison-table.tsx +++ b/editor/www/pricing/pricing-comparison-table.tsx @@ -3,7 +3,7 @@ import React, { useState } from "react"; import { Component1Icon } from "@radix-ui/react-icons"; import { pricing } from "../data/pricing"; -import { PricingInformation } from "../data/plans"; +import { PricingInformation } from "@/lib/billing/marketing-plans"; import { PricingTableRowDesktop, PricingTableRowMobile, @@ -17,7 +17,6 @@ import { PlugZapIcon, ShoppingBagIcon, SparklesIcon, - TicketIcon, } from "lucide-react"; function PricingMobileHeader({ @@ -67,7 +66,7 @@ const PricingComparisonTable = ({ plans }: { plans: PricingInformation[] }) => { return (
{/* */}
@@ -127,11 +126,6 @@ const PricingComparisonTable = ({ plans }: { plans: PricingInformation[] }) => { plan={"free"} icon={} /> - } - /> { plan={"pro"} icon={} /> - } - /> { plan={"team"} icon={} /> - } - /> { plan={"enterprise"} icon={} /> - } - /> { sectionId="commerce" /> } - sectionId="ticketing" + category={pricing.channels} + icon={} + sectionId="channels" /> } sectionId="support" /> - } - sectionId="channels" - />
diff --git a/editor/www/pricing/pricing.tsx b/editor/www/pricing/pricing.tsx index 798d0fc1b9..c25d821e0e 100644 --- a/editor/www/pricing/pricing.tsx +++ b/editor/www/pricing/pricing.tsx @@ -2,13 +2,18 @@ import React, { useState } from "react"; import { PricingCard, PricingCardButton } from "@/www/pricing/pricing-card"; -import { plans as nosave_plans, save_plans } from "@/www/data/plans"; +import { + plans as nosave_plans, + save_plans, +} from "@/lib/billing/marketing-plans"; import PricingComparisonTable from "./pricing-comparison-table"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import Link from "next/link"; export function Pricing() { - const [save, setSave] = useState(true); + // Default to monthly so visitors first see the real default price. + // Annual is opt-in. + const [save, setSave] = useState(false); const plans = save ? save_plans : nosave_plans; diff --git a/lefthook.yml b/lefthook.yml index 3a0499b64a..49bd59aa51 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,7 +2,7 @@ pre-commit: parallel: true jobs: - name: oxfmt - glob: "*.{ts,tsx,js,jsx,json,css,md,html}" + glob: "*.{ts,tsx,js,jsx,json,css,md,mdx,html}" run: pnpm oxfmt {staged_files} stage_fixed: true diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e3488bd8c..d4b0d4c84f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,7 +58,7 @@ importers: dependencies: '@next/third-parties': specifier: 16.2.4 - version: 16.2.4(next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 16.2.4(next@16.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) '@react-three/drei': specifier: ^10.0.7 version: 10.7.7(@react-three/fiber@9.1.2(@types/react@19.1.3)(immer@9.0.21)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(three@0.170.0))(@types/react@19.1.3)(@types/three@0.170.0)(immer@9.0.21)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(three@0.170.0) @@ -432,7 +432,7 @@ importers: version: 16.2.4(@mdx-js/loader@3.1.1(acorn@8.16.0)(webpack@5.98.0))(@mdx-js/react@3.1.1(@types/react@19.1.3)(react@19.2.5)) '@next/third-parties': specifier: 16.2.4 - version: 16.2.4(next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 16.2.4(next@16.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) '@number-flow/react': specifier: ^0.5.7 version: 0.5.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -832,6 +832,9 @@ importers: streamdown: specifier: ^1.6.11 version: 1.6.11(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.5) + stripe: + specifier: ^22.1.0 + version: 22.1.0(@types/node@24.12.2) stylis: specifier: ^4.3.2 version: 4.3.6 @@ -5037,30 +5040,35 @@ packages: '@react-email/body@0.0.11': resolution: {integrity: sha512-ZSD2SxVSgUjHGrB0Wi+4tu3MEpB4fYSbezsFNEJk2xCWDBkFiOeEsjTmR5dvi+CxTK691hQTQlHv0XWuP7ENTg==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/button@0.0.19': resolution: {integrity: sha512-HYHrhyVGt7rdM/ls6FuuD6XE7fa7bjZTJqB2byn6/oGsfiEZaogY77OtoLL/mrQHjHjZiJadtAMSik9XLcm7+A==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/code-block@0.0.13': resolution: {integrity: sha512-4DE4yPSgKEOnZMzcrDvRuD6mxsNxOex0hCYEG9F9q23geYgb2WCCeGBvIUXVzK69l703Dg4Vzrd5qUjl+JfcwA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/code-inline@0.0.5': resolution: {integrity: sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/column@0.0.13': resolution: {integrity: sha512-Lqq17l7ShzJG/d3b1w/+lVO+gp2FM05ZUo/nW0rjxB8xBICXOVv6PqjDnn3FXKssvhO5qAV20lHM6S+spRhEwQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 @@ -5074,59 +5082,69 @@ packages: '@react-email/container@0.0.15': resolution: {integrity: sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/font@0.0.9': resolution: {integrity: sha512-4zjq23oT9APXkerqeslPH3OZWuh5X4crHK6nx82mVHV2SrLba8+8dPEnWbaACWTNjOCbcLIzaC9unk7Wq2MIXw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/head@0.0.12': resolution: {integrity: sha512-X2Ii6dDFMF+D4niNwMAHbTkeCjlYYnMsd7edXOsi0JByxt9wNyZ9EnhFiBoQdqkE+SMDcu8TlNNttMrf5sJeMA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/heading@0.0.15': resolution: {integrity: sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/hr@0.0.11': resolution: {integrity: sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/html@0.0.11': resolution: {integrity: sha512-qJhbOQy5VW5qzU74AimjAR9FRFQfrMa7dn4gkEXKMB/S9xZN8e1yC1uA9C15jkXI/PzmJ0muDIWmFwatm5/+VA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/img@0.0.11': resolution: {integrity: sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/link@0.0.12': resolution: {integrity: sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/markdown@0.0.15': resolution: {integrity: sha512-UQA9pVm5sbflgtg3EX3FquUP4aMBzmLReLbGJ6DZQZnAskBF36aI56cRykDq1o+1jT+CKIK1CducPYziaXliag==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/preview@0.0.12': resolution: {integrity: sha512-g/H5fa9PQPDK6WUEG7iTlC19sAktI23qyoiJtMLqQiXFCfWeQMhqjLGKeLSKkfzszqmfJCjZtpSiKtBoOdxp3Q==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 @@ -5147,24 +5165,28 @@ packages: '@react-email/row@0.0.12': resolution: {integrity: sha512-HkCdnEjvK3o+n0y0tZKXYhIXUNPDx+2vq1dJTmqappVHXS5tXS6W5JOPZr5j+eoZ8gY3PShI2LWj5rWF7ZEtIQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/section@0.0.16': resolution: {integrity: sha512-FjqF9xQ8FoeUZYKSdt8sMIKvoT9XF8BrzhT3xiFKdEMwYNbsDflcjfErJe3jb7Wj/es/lKTbV5QR1dnLzGpL3w==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/tailwind@1.0.5': resolution: {integrity: sha512-BH00cZSeFfP9HiDASl+sPHi7Hh77W5nzDgdnxtsVr/m3uQD9g180UwxcE3PhOfx0vRdLzQUU8PtmvvDfbztKQg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 '@react-email/text@0.1.3': resolution: {integrity: sha512-H22KR54MXUg29a+1/lTfg9oCQA65V8+TL4v19OzV7RsOxnEnzGOc287XKh8vc+v7ENewrMV97BzUPOnKz3bqkA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: 19.2.5 @@ -13181,6 +13203,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@22.1.0: + resolution: {integrity: sha512-w/xHyJGxXWnLPbNHG13sz/fae0MrFGC80Oz7YbICQymbfpqfEcsoG+6yG+9BWb81PWc4rrkeSO4wmTcmefmbLw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + strnum@2.1.1: resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} @@ -13875,10 +13906,12 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validate-npm-package-name@7.0.2: @@ -17673,7 +17706,7 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.4': optional: true - '@next/third-parties@16.2.4(next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': + '@next/third-parties@16.2.4(next@16.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': dependencies: next: 16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 @@ -28651,6 +28684,10 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@22.1.0(@types/node@24.12.2): + optionalDependencies: + '@types/node': 24.12.2 + strnum@2.1.1: {} style-to-js@1.1.21: diff --git a/supabase/migrations/20260506132900_grida_billing.sql b/supabase/migrations/20260506132900_grida_billing.sql new file mode 100644 index 0000000000..85dde312fd --- /dev/null +++ b/supabase/migrations/20260506132900_grida_billing.sql @@ -0,0 +1,1035 @@ +-- grida_billing schema +-- +-- Core billing primitives. Mirrors Stripe lifecycle objects (Customer, +-- Subscription, Invoice events, Disputes) into our DB so the rest of +-- the product can react to billing changes without ever touching Stripe +-- directly. +-- +-- The schema is locked down: only postgres owner and service_role can +-- reach grida_billing.* tables. PostgREST surface lives in public.*. +-- +-- Stripe-sourced amounts are in CENTS (Stripe's smallest currency unit) +-- and stored as `*_cents` columns to keep the boundary explicit. +-- +-- No jsonb storage; all forensic / state fields are typed columns. +-- Test/live mode isolation is operational (separate Supabase projects +-- + env keys), not encoded in the schema. + +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions; + +CREATE SCHEMA IF NOT EXISTS grida_billing; +ALTER SCHEMA grida_billing OWNER TO postgres; + +-- Schema lockdown. No USAGE for anon/authenticated. +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA grida_billing GRANT ALL ON TABLES TO service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA grida_billing GRANT ALL ON ROUTINES TO service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA grida_billing GRANT ALL ON SEQUENCES TO service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA grida_billing REVOKE ALL ON TABLES FROM authenticated, anon; +ALTER DEFAULT PRIVILEGES IN SCHEMA grida_billing REVOKE ALL ON ROUTINES FROM authenticated, anon; +ALTER DEFAULT PRIVILEGES IN SCHEMA grida_billing REVOKE ALL ON SEQUENCES FROM authenticated, anon; + + +--------------------------------------------------------------------- +-- [grida_billing.account] +-- One row per organization. Bridge to Stripe Customer. +--------------------------------------------------------------------- + +CREATE TABLE grida_billing.account ( + organization_id bigint PRIMARY KEY REFERENCES public.organization(id) ON DELETE CASCADE, + stripe_customer_id text UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE grida_billing.account ENABLE ROW LEVEL SECURITY; +CREATE POLICY default_deny_authenticated ON grida_billing.account AS RESTRICTIVE FOR ALL TO authenticated USING (false) WITH CHECK (false); +CREATE POLICY default_deny_anon ON grida_billing.account AS RESTRICTIVE FOR ALL TO anon USING (false) WITH CHECK (false); +REVOKE ALL ON TABLE grida_billing.account FROM anon, authenticated; +GRANT ALL ON TABLE grida_billing.account TO service_role; + + +--------------------------------------------------------------------- +-- [grida_billing.subscription] +-- Stripe Subscription mirror. plan ∈ ('free','pro','team'). +-- +-- `is_free` distinguishes our local-only free row (no Stripe sub) +-- from a paid Stripe-backed row. For free rows: status='active', +-- stripe_subscription_id IS NULL. For paid rows: status mirrors +-- Stripe directly. Enforced by CHECK below. +--------------------------------------------------------------------- + +CREATE TABLE grida_billing.subscription ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id bigint NOT NULL REFERENCES public.organization(id) ON DELETE CASCADE, + plan text NOT NULL CHECK (plan IN ('free','pro','team')), + is_free boolean NOT NULL DEFAULT false, + status text NOT NULL CHECK (status IN ( + 'active','trialing','past_due','canceled', + 'unpaid','paused','incomplete','incomplete_expired' + )), + quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1), + cancel_at_period_end boolean NOT NULL DEFAULT false, + current_period_start timestamptz, + current_period_end timestamptz, + stripe_subscription_id text UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + -- is_free ↔ no Stripe subscription. Always. + CONSTRAINT subscription_is_free_iff_no_stripe CHECK ( + is_free = (stripe_subscription_id IS NULL) + ), + -- Free rows are plan='free'; paid rows are plan IN ('pro','team'). + CONSTRAINT subscription_free_plan_consistency CHECK ( + (is_free AND plan = 'free') OR (NOT is_free AND plan IN ('pro','team')) + ) +); + +CREATE UNIQUE INDEX subscription_one_active_per_org_idx + ON grida_billing.subscription (organization_id) + WHERE status <> 'canceled'; +CREATE INDEX subscription_organization_id_idx + ON grida_billing.subscription (organization_id); + +ALTER TABLE grida_billing.subscription ENABLE ROW LEVEL SECURITY; +CREATE POLICY default_deny_authenticated ON grida_billing.subscription AS RESTRICTIVE FOR ALL TO authenticated USING (false) WITH CHECK (false); +CREATE POLICY default_deny_anon ON grida_billing.subscription AS RESTRICTIVE FOR ALL TO anon USING (false) WITH CHECK (false); +REVOKE ALL ON TABLE grida_billing.subscription FROM anon, authenticated; +GRANT ALL ON TABLE grida_billing.subscription TO service_role; + + +--------------------------------------------------------------------- +-- [grida_billing.product_catalogue] +-- Generic Stripe product/price linkage. +--------------------------------------------------------------------- + +CREATE TABLE grida_billing.product_catalogue ( + id text PRIMARY KEY, + kind text NOT NULL CHECK (kind IN ('plan','addon','metered','seat')), + surface text NOT NULL DEFAULT '*' CHECK (surface IN ('editor','cors','*')), + stripe_product_id text, + stripe_price_id text, + -- Unit price in cents for the billing period implied by the catalogue id + -- (monthly for `plan.`, yearly for `plan..annual`). Stripe is the + -- runtime authority for what gets charged — this column is informational. + unit_amount_cents integer CHECK (unit_amount_cents IS NULL OR unit_amount_cents >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT product_catalogue_plan_has_price CHECK ( + kind <> 'plan' OR unit_amount_cents IS NOT NULL + ) +); + +CREATE INDEX product_catalogue_stripe_price_id_idx + ON grida_billing.product_catalogue (stripe_price_id) + WHERE stripe_price_id IS NOT NULL; + +ALTER TABLE grida_billing.product_catalogue ENABLE ROW LEVEL SECURITY; +CREATE POLICY default_deny_authenticated ON grida_billing.product_catalogue AS RESTRICTIVE FOR ALL TO authenticated USING (false) WITH CHECK (false); +CREATE POLICY default_deny_anon ON grida_billing.product_catalogue AS RESTRICTIVE FOR ALL TO anon USING (false) WITH CHECK (false); +REVOKE ALL ON TABLE grida_billing.product_catalogue FROM anon, authenticated; +GRANT ALL ON TABLE grida_billing.product_catalogue TO service_role; + +INSERT INTO grida_billing.product_catalogue (id, kind, unit_amount_cents) VALUES + ('plan.free', 'plan', 0), + ('plan.pro', 'plan', 2000), -- $20 / mo + ('plan.team', 'plan', 6000), -- $60 / mo + ('plan.pro.annual', 'plan', 19200), -- $192 / yr (20% off $240) + ('plan.team.annual', 'plan', 57600) -- $576 / yr (20% off $720) +ON CONFLICT (id) DO NOTHING; + + +--------------------------------------------------------------------- +-- [grida_billing.stripe_event] +-- Webhook idempotency / dedup. +--------------------------------------------------------------------- + +CREATE TABLE grida_billing.stripe_event ( + id text PRIMARY KEY, + type text NOT NULL, + received_at timestamptz NOT NULL DEFAULT now(), + processed_at timestamptz, + failed_at timestamptz, + failure_reason text, + handler text +); + +CREATE INDEX stripe_event_handler_idx + ON grida_billing.stripe_event (handler) + WHERE handler IS NOT NULL; + +ALTER TABLE grida_billing.stripe_event ENABLE ROW LEVEL SECURITY; +CREATE POLICY default_deny_authenticated ON grida_billing.stripe_event AS RESTRICTIVE FOR ALL TO authenticated USING (false) WITH CHECK (false); +CREATE POLICY default_deny_anon ON grida_billing.stripe_event AS RESTRICTIVE FOR ALL TO anon USING (false) WITH CHECK (false); +REVOKE ALL ON TABLE grida_billing.stripe_event FROM anon, authenticated; +GRANT ALL ON TABLE grida_billing.stripe_event TO service_role; + + +--------------------------------------------------------------------- +-- [grida_billing.audit] +-- Billing-scoped operations log. Typed columns; no jsonb. +-- +-- user_id = the actor (auth.uid() at call time, when known; +-- NULL for system / cron / webhook). +-- member_user_id = the seat target (for seat_add / seat_remove). +-- +-- Stripe-sourced amounts (e.g. invoice.amount_paid) are in CENTS +-- and stored in amount_cents. +--------------------------------------------------------------------- + +CREATE TABLE grida_billing.audit ( + id bigserial PRIMARY KEY, + organization_id bigint NOT NULL REFERENCES public.organization(id) ON DELETE CASCADE, + user_id uuid, + operation text NOT NULL CHECK (operation IN ( + 'subscribe','cancel','seat_add','seat_remove', + 'customer_attach','webhook.received' + )), + + -- Stripe references. + stripe_event_id text, + stripe_subscription_id text, + stripe_invoice_id text, + stripe_customer_id text, + + -- Seat operations. + member_user_id uuid, + prev_quantity integer, + new_quantity integer, + + -- State transitions. + plan text, + status text, + + -- Webhook context. + event_type text, + billing_reason text, + attempt_count integer, + + -- Stripe-sourced amount (cents). + amount_cents bigint, + + -- Free-form operator note. + note text, + + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX audit_org_created_idx + ON grida_billing.audit (organization_id, created_at DESC); +CREATE INDEX audit_stripe_event_id_idx + ON grida_billing.audit (stripe_event_id) + WHERE stripe_event_id IS NOT NULL; +CREATE INDEX audit_stripe_invoice_id_idx + ON grida_billing.audit (stripe_invoice_id) + WHERE stripe_invoice_id IS NOT NULL; + +ALTER TABLE grida_billing.audit ENABLE ROW LEVEL SECURITY; +CREATE POLICY default_deny_authenticated ON grida_billing.audit AS RESTRICTIVE FOR ALL TO authenticated USING (false) WITH CHECK (false); +CREATE POLICY default_deny_anon ON grida_billing.audit AS RESTRICTIVE FOR ALL TO anon USING (false) WITH CHECK (false); +REVOKE ALL ON TABLE grida_billing.audit FROM anon, authenticated; +GRANT ALL ON TABLE grida_billing.audit TO service_role; + + +-- ============================================================================ +-- Functions +-- ============================================================================ + +--------------------------------------------------------------------- +-- [grida_billing.fn_provision_account] +-- Idempotent. Creates account + free subscription rows. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.fn_provision_account(p_org_id bigint) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +BEGIN + INSERT INTO grida_billing.account (organization_id) + VALUES (p_org_id) + ON CONFLICT (organization_id) DO NOTHING; + + INSERT INTO grida_billing.subscription ( + organization_id, plan, is_free, status, quantity + ) VALUES ( + p_org_id, 'free', true, 'active', 1 + ) + ON CONFLICT DO NOTHING; +END; +$$; + + +--------------------------------------------------------------------- +-- [grida_billing.tg_provision_on_org_insert] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.tg_provision_on_org_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +BEGIN + PERFORM grida_billing.fn_provision_account(NEW.id); + RETURN NEW; +END; +$$; + +CREATE TRIGGER tg_billing_provision_on_org_insert + AFTER INSERT ON public.organization + FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_provision_on_org_insert(); + + +--------------------------------------------------------------------- +-- [grida_billing.tg_organization_before_delete] +-- Refuse to delete an organization with an active Stripe-backed +-- subscription. CASCADE removes our local rows but does NOT cancel +-- the Stripe subscription — the customer would keep getting billed +-- forever for a service we can't deliver. (TC-BILLING-SUB-017) +-- +-- TS deletion path must: +-- 1. Cancel the Stripe subscription (Customer Portal or admin SDK). +-- 2. Wait for customer.subscription.deleted webhook → status='canceled'. +-- 3. THEN delete the organization row. +-- +-- For free orgs with no Stripe sub, deletion proceeds normally. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.tg_organization_before_delete() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + v_active_sub_id text; +BEGIN + SELECT stripe_subscription_id INTO v_active_sub_id + FROM grida_billing.subscription + WHERE organization_id = OLD.id + AND is_free = false + AND status NOT IN ('canceled') + LIMIT 1; + + IF v_active_sub_id IS NOT NULL THEN + RAISE EXCEPTION + 'cannot delete organization %: active Stripe subscription % must be canceled first', + OLD.id, v_active_sub_id + USING ERRCODE = 'foreign_key_violation', + HINT = 'Cancel via Stripe Customer Portal, await customer.subscription.deleted webhook, then retry.'; + END IF; + + RETURN OLD; +END; +$$; + +CREATE TRIGGER tg_billing_organization_before_delete + BEFORE DELETE ON public.organization + FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_organization_before_delete(); + + +--------------------------------------------------------------------- +-- [grida_billing.fn_attach_stripe_customer] +-- Idempotent, race-safe attach. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.fn_attach_stripe_customer( + p_org_id bigint, + p_stripe_customer_id text +) +RETURNS TABLE ( + attached boolean, + stripe_customer_id text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + v_existing text; + v_attached boolean := false; +BEGIN + SELECT acc.stripe_customer_id INTO v_existing + FROM grida_billing.account acc + WHERE acc.organization_id = p_org_id + FOR UPDATE; + + -- The account row is provisioned by `tg_billing_provision_on_org_insert`. + -- Its absence means either the trigger didn't fire (data inconsistency) or + -- p_org_id is bogus — either way, refuse to silently report "attached". + IF NOT FOUND THEN + RAISE EXCEPTION + 'billing account not provisioned for organization %', p_org_id; + END IF; + + IF v_existing IS NULL THEN + UPDATE grida_billing.account + SET stripe_customer_id = p_stripe_customer_id, + updated_at = now() + WHERE organization_id = p_org_id; + v_existing := p_stripe_customer_id; + v_attached := true; + + INSERT INTO grida_billing.audit (organization_id, operation, stripe_customer_id) + VALUES (p_org_id, 'customer_attach', p_stripe_customer_id); + ELSIF v_existing <> p_stripe_customer_id THEN + -- Fail closed on attach-time drift. Mismatched ids = ops contamination + -- (manual SQL, leaked fixtures, double-create races). Silent skip would + -- return the existing id and the caller's freshly-created Stripe + -- customer would orphan on Stripe's side. Surface the conflict so + -- whoever is calling can investigate. + RAISE EXCEPTION + 'organization % is already attached to Stripe customer %, refusing %', + p_org_id, v_existing, p_stripe_customer_id; + END IF; + + RETURN QUERY SELECT v_attached, v_existing; +END; +$$; + + +-- Seat-sync triggers intentionally absent in v1. +-- Multi-seat billing is deferred: paid subs are billed at the quantity +-- chosen at Checkout (default 1) and changed only via the webhook. +-- Membership changes do not mutate subscription.quantity. When seat +-- management lands, it'll go through a server action that calls Stripe +-- with an idempotency key — never a local-mirror trigger. + + +--------------------------------------------------------------------- +-- [grida_billing.fn_apply_stripe_event] +-- Webhook projector for SaaS billing events: +-- • customer.created / customer.updated → upsert account.stripe_customer_id +-- • customer.subscription.{created,updated,deleted} → upsert subscription +-- (cancels the free sentinel row first if present) +-- • invoice.payment_failed → set status='past_due' +-- • invoice.payment_succeeded → restore from past_due +-- • charge.dispute.{created,updated,closed} → suspend / restore / +-- cancel the subscription based on dispute lifecycle. +-- +-- Idempotency: stripe_event.id PK + ON CONFLICT DO NOTHING. Replays +-- return result='replayed'; first-fail/retry path falls through. +-- On exception the function RAISEs; the receiver stamps failed_at +-- via fn_stamp_failure in a separate transaction. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.fn_apply_stripe_event( + p_event_id text, + p_event_type text, + p_payload jsonb +) +RETURNS TABLE ( + result text, + handler text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + v_existing grida_billing.stripe_event%ROWTYPE; + v_handler text; + v_org_id bigint; + v_customer_id text; + v_sub_id text; + v_invoice_id text; + v_status text; + v_quantity integer; + v_cancel_at_pe boolean; + v_cancel_at_unix bigint; + v_period_start timestamptz; + v_period_end timestamptz; + v_first_item jsonb; + v_price_id text; + v_plan text; + v_billing_reason text; + v_amount_paid bigint; + v_attempt_count integer; +BEGIN + IF p_event_id IS NULL OR p_event_type IS NULL THEN + RAISE EXCEPTION 'fn_apply_stripe_event: missing required argument'; + END IF; + + -- Idempotency. Insert-or-noop, then SELECT FOR UPDATE so concurrent + -- deliveries of the same event id serialise here: the second one waits for + -- the first to commit, then sees `processed_at` set and short-circuits. + -- Without the row lock both could read processed_at IS NULL and re-project. + INSERT INTO grida_billing.stripe_event (id, type) + VALUES (p_event_id, p_event_type) + ON CONFLICT (id) DO NOTHING; + + SELECT * INTO v_existing + FROM grida_billing.stripe_event + WHERE id = p_event_id + FOR UPDATE; + IF v_existing.processed_at IS NOT NULL THEN + RETURN QUERY SELECT 'replayed'::text, v_existing.handler; + RETURN; + END IF; + + IF p_event_type IN ('customer.created','customer.updated') THEN + v_customer_id := p_payload->>'id'; + v_org_id := nullif(p_payload->'metadata'->>'grida_organization_id', '')::bigint; + + IF v_org_id IS NOT NULL THEN + -- Fail closed on stripe_customer_id drift: if the org is already + -- attached to a *different* customer, refuse rather than silently + -- no-oping. Mismatched ids almost always mean ops contamination + -- (manual SQL, leaked test data) and silent skip turns later + -- subscription events into ghosts. Stripe will retry the webhook + -- while ops fixes the binding. + DECLARE + v_existing_cust text; + BEGIN + SELECT stripe_customer_id INTO v_existing_cust + FROM grida_billing.account + WHERE organization_id = v_org_id; + IF FOUND + AND v_existing_cust IS NOT NULL + AND v_existing_cust <> v_customer_id THEN + RAISE EXCEPTION + 'organization % already attached to Stripe customer %, refusing webhook for customer %', + v_org_id, v_existing_cust, v_customer_id; + END IF; + END; + + UPDATE grida_billing.account + SET stripe_customer_id = v_customer_id, + updated_at = now() + WHERE organization_id = v_org_id + AND (stripe_customer_id IS NULL OR stripe_customer_id = v_customer_id); + + INSERT INTO grida_billing.audit ( + organization_id, operation, stripe_event_id, stripe_customer_id, event_type + ) VALUES ( + v_org_id, 'webhook.received', p_event_id, v_customer_id, p_event_type + ); + END IF; + v_handler := 'customer_upsert'; + + ELSIF p_event_type IN ( + 'customer.subscription.created', + 'customer.subscription.updated', + 'customer.subscription.deleted' + ) THEN + v_sub_id := p_payload->>'id'; + v_customer_id := CASE + WHEN jsonb_typeof(p_payload->'customer') = 'string' + THEN trim(both '"' from (p_payload->'customer')::text) + ELSE p_payload->'customer'->>'id' + END; + + SELECT organization_id INTO v_org_id + FROM grida_billing.account + WHERE stripe_customer_id = v_customer_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'subscription event % cannot be projected: no account for customer %', v_sub_id, v_customer_id; + END IF; + + v_status := p_payload->>'status'; + v_first_item := p_payload->'items'->'data'->0; + v_quantity := coalesce((v_first_item->>'quantity')::integer, 1); + + v_cancel_at_pe := coalesce((p_payload->>'cancel_at_period_end')::boolean, false); + v_cancel_at_unix := nullif(p_payload->>'cancel_at', '')::bigint; + IF NOT v_cancel_at_pe AND v_cancel_at_unix IS NOT NULL + AND to_timestamp(v_cancel_at_unix) > now() THEN + v_cancel_at_pe := true; + END IF; + + v_period_start := to_timestamp(nullif(v_first_item->>'current_period_start','')::bigint); + v_period_end := to_timestamp(nullif(v_first_item->>'current_period_end', '')::bigint); + v_price_id := v_first_item->'price'->>'id'; + + -- Map Stripe price → plan via the catalogue. Fail closed: an unknown or + -- newly-provisioned price must not silently project as 'pro' (would + -- mis-entitle the org). RAISE so Stripe retries while ops fixes the + -- catalogue mapping. + v_plan := NULL; + IF v_price_id IS NOT NULL THEN + SELECT CASE + WHEN id IN ('plan.team', 'plan.team.annual') THEN 'team' + WHEN id IN ('plan.pro', 'plan.pro.annual') THEN 'pro' + ELSE NULL + END + INTO v_plan + FROM grida_billing.product_catalogue + WHERE stripe_price_id = v_price_id; + END IF; + IF v_plan IS NULL THEN + RAISE EXCEPTION + 'subscription % has unknown stripe price % — add it to grida_billing.product_catalogue', + v_sub_id, v_price_id; + END IF; + + -- Cancel the free sentinel row before the upsert (resolves + -- "one active per org" partial unique). + UPDATE grida_billing.subscription + SET status = 'canceled', updated_at = now() + WHERE organization_id = v_org_id + AND is_free = true + AND status <> 'canceled'; + + INSERT INTO grida_billing.subscription ( + organization_id, plan, is_free, status, quantity, + cancel_at_period_end, current_period_start, current_period_end, + stripe_subscription_id + ) VALUES ( + v_org_id, v_plan, false, v_status, v_quantity, + v_cancel_at_pe, v_period_start, v_period_end, v_sub_id + ) + ON CONFLICT (stripe_subscription_id) DO UPDATE SET + organization_id = EXCLUDED.organization_id, + plan = EXCLUDED.plan, + status = EXCLUDED.status, + quantity = EXCLUDED.quantity, + cancel_at_period_end = EXCLUDED.cancel_at_period_end, + current_period_start = EXCLUDED.current_period_start, + current_period_end = EXCLUDED.current_period_end, + updated_at = now(); + + INSERT INTO grida_billing.audit ( + organization_id, operation, stripe_event_id, stripe_subscription_id, + event_type, plan, status, new_quantity + ) VALUES ( + v_org_id, 'webhook.received', p_event_id, v_sub_id, + p_event_type, v_plan, v_status, v_quantity + ); + v_handler := 'subscription_upsert'; + + ELSIF p_event_type IN ('charge.dispute.created','charge.dispute.closed','charge.dispute.updated') THEN + -- Dispute payloads don't carry subscription_id natively. The + -- TS receiver pre-resolves dispute.charge → invoice.subscription + -- and stitches it into metadata.grida_subscription_id before + -- invoking this RPC. Without it we cannot project; raise. + v_sub_id := nullif(p_payload->'metadata'->>'grida_subscription_id', ''); + DECLARE + v_dispute_status text := p_payload->>'status'; + v_dispute_id text := p_payload->>'id'; + BEGIN + IF v_sub_id IS NULL THEN + RAISE EXCEPTION 'charge.dispute event % missing metadata.grida_subscription_id (TS must pre-resolve)', v_dispute_id; + END IF; + + SELECT organization_id INTO v_org_id + FROM grida_billing.subscription + WHERE stripe_subscription_id = v_sub_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'charge.dispute: no subscription row for %', v_sub_id; + END IF; + + -- TC-BILLING-PAY-011: created → suspend AI immediately. + -- TC-BILLING-PAY-012: closed.won → restore. + -- TC-BILLING-PAY-013: closed.lost → cancel; AI hard-blocked. + IF p_event_type = 'charge.dispute.created' + OR (p_event_type IN ('charge.dispute.updated') + AND v_dispute_status IN ('warning_needs_response','warning_under_review', + 'needs_response','under_review')) THEN + UPDATE grida_billing.subscription + SET status = 'paused', updated_at = now() + WHERE stripe_subscription_id = v_sub_id + AND status NOT IN ('canceled'); + ELSIF p_event_type = 'charge.dispute.closed' + AND v_dispute_status IN ('won','warning_closed') THEN + UPDATE grida_billing.subscription + SET status = 'active', updated_at = now() + WHERE stripe_subscription_id = v_sub_id + AND status = 'paused'; + ELSIF p_event_type = 'charge.dispute.closed' + AND v_dispute_status = 'lost' THEN + UPDATE grida_billing.subscription + SET status = 'canceled', updated_at = now() + WHERE stripe_subscription_id = v_sub_id; + END IF; + + INSERT INTO grida_billing.audit ( + organization_id, operation, stripe_event_id, stripe_subscription_id, + event_type, status, note + ) VALUES ( + v_org_id, 'webhook.received', p_event_id, v_sub_id, + p_event_type, + CASE + WHEN p_event_type = 'charge.dispute.closed' AND v_dispute_status = 'won' THEN 'active' + WHEN p_event_type = 'charge.dispute.closed' AND v_dispute_status = 'lost' THEN 'canceled' + ELSE 'paused' + END, + format('dispute_id=%s status=%s', v_dispute_id, v_dispute_status) + ); + v_handler := 'dispute_' || coalesce(v_dispute_status, 'unknown'); + END; + + ELSIF p_event_type = 'invoice.payment_failed' THEN + v_invoice_id := p_payload->>'id'; + v_attempt_count := nullif(p_payload->>'attempt_count','')::integer; + v_sub_id := nullif(p_payload->>'subscription', ''); + IF v_sub_id IS NULL THEN + v_sub_id := p_payload->'parent'->'subscription_details'->>'subscription'; + END IF; + + IF v_sub_id IS NULL THEN + v_handler := 'invoice_payment_failed_skipped'; + ELSE + SELECT organization_id INTO v_org_id + FROM grida_billing.subscription + WHERE stripe_subscription_id = v_sub_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'invoice.payment_failed: no subscription row for %', v_sub_id; + END IF; + + -- First-invoice failures arrive while Stripe holds the sub in + -- `incomplete` (then `incomplete_expired` after ~23h). Don't collapse + -- those into `past_due` — that's the renewal-failure status, and the UI + -- branches on the distinction. + UPDATE grida_billing.subscription + SET status = CASE + WHEN status IN ('incomplete', 'incomplete_expired') THEN status + ELSE 'past_due' + END, + updated_at = now() + WHERE stripe_subscription_id = v_sub_id + RETURNING status INTO v_status; + + INSERT INTO grida_billing.audit ( + organization_id, operation, stripe_event_id, stripe_subscription_id, + stripe_invoice_id, event_type, status, attempt_count + ) VALUES ( + v_org_id, 'webhook.received', p_event_id, v_sub_id, + v_invoice_id, p_event_type, v_status, v_attempt_count + ); + v_handler := 'invoice_payment_failed'; + END IF; + + ELSIF p_event_type = 'invoice.payment_succeeded' THEN + v_invoice_id := p_payload->>'id'; + v_billing_reason := p_payload->>'billing_reason'; + v_amount_paid := nullif(p_payload->>'amount_paid', '')::bigint; + v_sub_id := nullif(p_payload->>'subscription', ''); + IF v_sub_id IS NULL THEN + v_sub_id := p_payload->'parent'->'subscription_details'->>'subscription'; + END IF; + + IF v_sub_id IS NULL THEN + v_handler := 'invoice_payment_succeeded_skipped'; + ELSE + SELECT organization_id, plan INTO v_org_id, v_plan + FROM grida_billing.subscription + WHERE stripe_subscription_id = v_sub_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'invoice.payment_succeeded: no subscription row for %', v_sub_id; + END IF; + + UPDATE grida_billing.subscription + SET status = 'active', updated_at = now() + WHERE stripe_subscription_id = v_sub_id + AND status = 'past_due'; + + INSERT INTO grida_billing.audit ( + organization_id, operation, stripe_event_id, stripe_subscription_id, + stripe_invoice_id, event_type, billing_reason, amount_cents, plan + ) VALUES ( + v_org_id, 'webhook.received', p_event_id, v_sub_id, + v_invoice_id, p_event_type, v_billing_reason, v_amount_paid, v_plan + ); + v_handler := 'invoice_payment_succeeded'; + END IF; + + ELSE + v_handler := 'unhandled'; + END IF; + + UPDATE grida_billing.stripe_event + SET processed_at = now(), + handler = v_handler, + failed_at = NULL, + failure_reason = NULL + WHERE id = p_event_id; + + RETURN QUERY SELECT 'handled'::text, v_handler; +END; +$$; + + +--------------------------------------------------------------------- +-- [grida_billing.fn_stamp_failure] +-- Forensic stamp called from the receiver's catch path AFTER +-- fn_apply_stripe_event has rolled back. Separate transaction. +-- +-- UPSERT, not UPDATE: when the projector RAISEs, the entire transaction +-- (including the INSERT INTO stripe_event at the top of fn_apply_stripe_event) +-- rolls back. So on a first-time failure, no row exists yet and an +-- UPDATE-only stamp would silently match nothing. INSERT … ON CONFLICT +-- handles both cases — first failure inserts the forensic row; later +-- failures update the existing one. +--------------------------------------------------------------------- + +DROP FUNCTION IF EXISTS grida_billing.fn_stamp_failure(text, text); + +CREATE OR REPLACE FUNCTION grida_billing.fn_stamp_failure( + p_event_id text, + p_event_type text, + p_reason text +) +RETURNS void +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + INSERT INTO grida_billing.stripe_event (id, type, failed_at, failure_reason) + VALUES (p_event_id, p_event_type, now(), left(p_reason, 2000)) + ON CONFLICT (id) DO UPDATE SET + failed_at = EXCLUDED.failed_at, + failure_reason = EXCLUDED.failure_reason; +$$; + + +-- ============================================================================ +-- public.* wrapper surface (PostgREST) +-- ============================================================================ + +--------------------------------------------------------------------- +-- [public.v_billing_subscription] +--------------------------------------------------------------------- + +CREATE OR REPLACE VIEW public.v_billing_subscription +WITH (security_invoker = false) +AS +SELECT + s.organization_id, + s.plan, + s.is_free, + s.status, + s.quantity, + s.cancel_at_period_end, + s.current_period_start, + s.current_period_end, + s.stripe_subscription_id, + acc.stripe_customer_id +FROM grida_billing.subscription s +LEFT JOIN grida_billing.account acc ON acc.organization_id = s.organization_id +WHERE s.status <> 'canceled' + AND s.organization_id IN ( + SELECT om.organization_id + FROM public.organization_member om + WHERE om.user_id = (SELECT auth.uid()) + ); + +GRANT SELECT ON public.v_billing_subscription TO authenticated, service_role; + + +--------------------------------------------------------------------- +-- [public.v_billing_audit] +-- Owner-only billing audit feed (TC-BILLING-OPS-009/010). +-- Members can NOT read this — only the org owner. +--------------------------------------------------------------------- + +CREATE OR REPLACE VIEW public.v_billing_audit +WITH (security_invoker = false) +AS +SELECT + a.id, + a.organization_id, + a.user_id, + a.operation, + a.stripe_event_id, + a.stripe_subscription_id, + a.stripe_invoice_id, + a.stripe_customer_id, + a.member_user_id, + a.prev_quantity, + a.new_quantity, + a.plan, + a.status, + a.event_type, + a.billing_reason, + a.attempt_count, + a.amount_cents, + a.note, + a.created_at +FROM grida_billing.audit a +WHERE a.organization_id IN ( + SELECT o.id FROM public.organization o + WHERE o.owner_id = (SELECT auth.uid()) +); + +GRANT SELECT ON public.v_billing_audit TO authenticated, service_role; + + +--------------------------------------------------------------------- +-- [public.fn_billing_apply_stripe_event] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.fn_billing_apply_stripe_event( + p_event_id text, + p_event_type text, + p_payload jsonb +) +RETURNS TABLE (result text, handler text) +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT * FROM grida_billing.fn_apply_stripe_event(p_event_id, p_event_type, p_payload); +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_apply_stripe_event(text, text, jsonb) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_apply_stripe_event(text, text, jsonb) TO service_role; + + +--------------------------------------------------------------------- +-- [public.fn_billing_stamp_failure] +--------------------------------------------------------------------- + +DROP FUNCTION IF EXISTS public.fn_billing_stamp_failure(text, text); + +CREATE OR REPLACE FUNCTION public.fn_billing_stamp_failure( + p_event_id text, + p_event_type text, + p_reason text +) +RETURNS void +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT grida_billing.fn_stamp_failure(p_event_id, p_event_type, p_reason); +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_stamp_failure(text, text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_stamp_failure(text, text, text) TO service_role; + + +--------------------------------------------------------------------- +-- [public.fn_billing_attach_stripe_customer] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.fn_billing_attach_stripe_customer( + p_org_id bigint, + p_stripe_customer_id text +) +RETURNS TABLE (attached boolean, stripe_customer_id text) +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT * FROM grida_billing.fn_attach_stripe_customer(p_org_id, p_stripe_customer_id); +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_attach_stripe_customer(bigint, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_attach_stripe_customer(bigint, text) TO service_role; + + +--------------------------------------------------------------------- +-- [public.fn_billing_get_customer_id] +-- Service-role read of account.stripe_customer_id. Used by TS checkout +-- helpers (lib/billing/checkout.ts) to look up the cached customer id. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.fn_billing_get_customer_id(p_org_id bigint) +RETURNS text +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT stripe_customer_id FROM grida_billing.account WHERE organization_id = p_org_id; +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_get_customer_id(bigint) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_get_customer_id(bigint) TO service_role; + + +--------------------------------------------------------------------- +-- [public.fn_billing_get_active_subscription] +-- Service-role read of the org's active Stripe subscription. Returns +-- empty when the org is on the free sentinel row. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.fn_billing_get_active_subscription(p_org_id bigint) +RETURNS TABLE ( + stripe_subscription_id text, + status text, + quantity integer, + plan text, + cancel_at_period_end boolean, + current_period_start timestamptz, + current_period_end timestamptz +) +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT s.stripe_subscription_id, s.status, s.quantity, s.plan, + s.cancel_at_period_end, s.current_period_start, s.current_period_end + FROM grida_billing.subscription s + WHERE s.organization_id = p_org_id + AND s.is_free = false + AND s.status <> 'canceled' + ORDER BY s.updated_at DESC + LIMIT 1; +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_get_active_subscription(bigint) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_get_active_subscription(bigint) TO service_role; + + +--------------------------------------------------------------------- +-- [public.fn_billing_get_catalogue] +-- Service-role read of product_catalogue.stripe_product_id + +-- stripe_price_id. Returns NULL row when the catalogue id is unknown +-- or has no Stripe wiring yet. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.fn_billing_get_catalogue(p_id text) +RETURNS TABLE ( + stripe_product_id text, + stripe_price_id text +) +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT stripe_product_id, stripe_price_id + FROM grida_billing.product_catalogue + WHERE id = p_id; +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_get_catalogue(text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_get_catalogue(text) TO service_role; + + +--------------------------------------------------------------------- +-- [public.fn_billing_setup_product] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.fn_billing_setup_product( + p_grida_billing_id text, + p_stripe_product_id text, + p_stripe_price_id text +) +RETURNS TABLE (id text, stripe_product_id text, stripe_price_id text) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +BEGIN + IF p_grida_billing_id NOT IN ( + 'plan.pro', 'plan.team', 'plan.pro.annual', 'plan.team.annual' + ) THEN + RAISE EXCEPTION 'fn_billing_setup_product: unknown catalogue id %', p_grida_billing_id; + END IF; + + UPDATE grida_billing.product_catalogue + SET stripe_product_id = p_stripe_product_id, + stripe_price_id = p_stripe_price_id + WHERE product_catalogue.id = p_grida_billing_id; + + RETURN QUERY SELECT p_grida_billing_id, p_stripe_product_id, p_stripe_price_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_setup_product(text, text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_setup_product(text, text, text) TO service_role; diff --git a/supabase/migrations/20260506132901_drop_legacy_pricing_tier.sql b/supabase/migrations/20260506132901_drop_legacy_pricing_tier.sql new file mode 100644 index 0000000000..2536ab9b25 --- /dev/null +++ b/supabase/migrations/20260506132901_drop_legacy_pricing_tier.sql @@ -0,0 +1,20 @@ +-- Drop the legacy `display_plan` column and `pricing_tier` enum from +-- `public.organization`. Plan state now lives in `grida_billing.subscription` +-- (Stripe-driven, accessible via `public.v_billing_subscription` / +-- `fn_billing_get_active_subscription`). The Enterprise flag — the only +-- ops-side use of `display_plan` — moves to a dedicated boolean column. +-- +-- `display_plan` was never written to by application code or by the Stripe +-- webhook projector; it's been a manual-SQL channel since the legacy +-- 20250316 schema. Dropping it ends the dual-source-of-truth on plan state. + +ALTER TABLE public.organization + ADD COLUMN is_enterprise boolean NOT NULL DEFAULT false; + +UPDATE public.organization + SET is_enterprise = true + WHERE display_plan = 'v0_enterprise'; + +ALTER TABLE public.organization DROP COLUMN display_plan; + +DROP TYPE public.pricing_tier; diff --git a/supabase/schemas/grida_billing.sql b/supabase/schemas/grida_billing.sql new file mode 100644 index 0000000000..85dde312fd --- /dev/null +++ b/supabase/schemas/grida_billing.sql @@ -0,0 +1,1035 @@ +-- grida_billing schema +-- +-- Core billing primitives. Mirrors Stripe lifecycle objects (Customer, +-- Subscription, Invoice events, Disputes) into our DB so the rest of +-- the product can react to billing changes without ever touching Stripe +-- directly. +-- +-- The schema is locked down: only postgres owner and service_role can +-- reach grida_billing.* tables. PostgREST surface lives in public.*. +-- +-- Stripe-sourced amounts are in CENTS (Stripe's smallest currency unit) +-- and stored as `*_cents` columns to keep the boundary explicit. +-- +-- No jsonb storage; all forensic / state fields are typed columns. +-- Test/live mode isolation is operational (separate Supabase projects +-- + env keys), not encoded in the schema. + +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions; + +CREATE SCHEMA IF NOT EXISTS grida_billing; +ALTER SCHEMA grida_billing OWNER TO postgres; + +-- Schema lockdown. No USAGE for anon/authenticated. +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA grida_billing GRANT ALL ON TABLES TO service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA grida_billing GRANT ALL ON ROUTINES TO service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA grida_billing GRANT ALL ON SEQUENCES TO service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA grida_billing REVOKE ALL ON TABLES FROM authenticated, anon; +ALTER DEFAULT PRIVILEGES IN SCHEMA grida_billing REVOKE ALL ON ROUTINES FROM authenticated, anon; +ALTER DEFAULT PRIVILEGES IN SCHEMA grida_billing REVOKE ALL ON SEQUENCES FROM authenticated, anon; + + +--------------------------------------------------------------------- +-- [grida_billing.account] +-- One row per organization. Bridge to Stripe Customer. +--------------------------------------------------------------------- + +CREATE TABLE grida_billing.account ( + organization_id bigint PRIMARY KEY REFERENCES public.organization(id) ON DELETE CASCADE, + stripe_customer_id text UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE grida_billing.account ENABLE ROW LEVEL SECURITY; +CREATE POLICY default_deny_authenticated ON grida_billing.account AS RESTRICTIVE FOR ALL TO authenticated USING (false) WITH CHECK (false); +CREATE POLICY default_deny_anon ON grida_billing.account AS RESTRICTIVE FOR ALL TO anon USING (false) WITH CHECK (false); +REVOKE ALL ON TABLE grida_billing.account FROM anon, authenticated; +GRANT ALL ON TABLE grida_billing.account TO service_role; + + +--------------------------------------------------------------------- +-- [grida_billing.subscription] +-- Stripe Subscription mirror. plan ∈ ('free','pro','team'). +-- +-- `is_free` distinguishes our local-only free row (no Stripe sub) +-- from a paid Stripe-backed row. For free rows: status='active', +-- stripe_subscription_id IS NULL. For paid rows: status mirrors +-- Stripe directly. Enforced by CHECK below. +--------------------------------------------------------------------- + +CREATE TABLE grida_billing.subscription ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id bigint NOT NULL REFERENCES public.organization(id) ON DELETE CASCADE, + plan text NOT NULL CHECK (plan IN ('free','pro','team')), + is_free boolean NOT NULL DEFAULT false, + status text NOT NULL CHECK (status IN ( + 'active','trialing','past_due','canceled', + 'unpaid','paused','incomplete','incomplete_expired' + )), + quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1), + cancel_at_period_end boolean NOT NULL DEFAULT false, + current_period_start timestamptz, + current_period_end timestamptz, + stripe_subscription_id text UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + -- is_free ↔ no Stripe subscription. Always. + CONSTRAINT subscription_is_free_iff_no_stripe CHECK ( + is_free = (stripe_subscription_id IS NULL) + ), + -- Free rows are plan='free'; paid rows are plan IN ('pro','team'). + CONSTRAINT subscription_free_plan_consistency CHECK ( + (is_free AND plan = 'free') OR (NOT is_free AND plan IN ('pro','team')) + ) +); + +CREATE UNIQUE INDEX subscription_one_active_per_org_idx + ON grida_billing.subscription (organization_id) + WHERE status <> 'canceled'; +CREATE INDEX subscription_organization_id_idx + ON grida_billing.subscription (organization_id); + +ALTER TABLE grida_billing.subscription ENABLE ROW LEVEL SECURITY; +CREATE POLICY default_deny_authenticated ON grida_billing.subscription AS RESTRICTIVE FOR ALL TO authenticated USING (false) WITH CHECK (false); +CREATE POLICY default_deny_anon ON grida_billing.subscription AS RESTRICTIVE FOR ALL TO anon USING (false) WITH CHECK (false); +REVOKE ALL ON TABLE grida_billing.subscription FROM anon, authenticated; +GRANT ALL ON TABLE grida_billing.subscription TO service_role; + + +--------------------------------------------------------------------- +-- [grida_billing.product_catalogue] +-- Generic Stripe product/price linkage. +--------------------------------------------------------------------- + +CREATE TABLE grida_billing.product_catalogue ( + id text PRIMARY KEY, + kind text NOT NULL CHECK (kind IN ('plan','addon','metered','seat')), + surface text NOT NULL DEFAULT '*' CHECK (surface IN ('editor','cors','*')), + stripe_product_id text, + stripe_price_id text, + -- Unit price in cents for the billing period implied by the catalogue id + -- (monthly for `plan.`, yearly for `plan..annual`). Stripe is the + -- runtime authority for what gets charged — this column is informational. + unit_amount_cents integer CHECK (unit_amount_cents IS NULL OR unit_amount_cents >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT product_catalogue_plan_has_price CHECK ( + kind <> 'plan' OR unit_amount_cents IS NOT NULL + ) +); + +CREATE INDEX product_catalogue_stripe_price_id_idx + ON grida_billing.product_catalogue (stripe_price_id) + WHERE stripe_price_id IS NOT NULL; + +ALTER TABLE grida_billing.product_catalogue ENABLE ROW LEVEL SECURITY; +CREATE POLICY default_deny_authenticated ON grida_billing.product_catalogue AS RESTRICTIVE FOR ALL TO authenticated USING (false) WITH CHECK (false); +CREATE POLICY default_deny_anon ON grida_billing.product_catalogue AS RESTRICTIVE FOR ALL TO anon USING (false) WITH CHECK (false); +REVOKE ALL ON TABLE grida_billing.product_catalogue FROM anon, authenticated; +GRANT ALL ON TABLE grida_billing.product_catalogue TO service_role; + +INSERT INTO grida_billing.product_catalogue (id, kind, unit_amount_cents) VALUES + ('plan.free', 'plan', 0), + ('plan.pro', 'plan', 2000), -- $20 / mo + ('plan.team', 'plan', 6000), -- $60 / mo + ('plan.pro.annual', 'plan', 19200), -- $192 / yr (20% off $240) + ('plan.team.annual', 'plan', 57600) -- $576 / yr (20% off $720) +ON CONFLICT (id) DO NOTHING; + + +--------------------------------------------------------------------- +-- [grida_billing.stripe_event] +-- Webhook idempotency / dedup. +--------------------------------------------------------------------- + +CREATE TABLE grida_billing.stripe_event ( + id text PRIMARY KEY, + type text NOT NULL, + received_at timestamptz NOT NULL DEFAULT now(), + processed_at timestamptz, + failed_at timestamptz, + failure_reason text, + handler text +); + +CREATE INDEX stripe_event_handler_idx + ON grida_billing.stripe_event (handler) + WHERE handler IS NOT NULL; + +ALTER TABLE grida_billing.stripe_event ENABLE ROW LEVEL SECURITY; +CREATE POLICY default_deny_authenticated ON grida_billing.stripe_event AS RESTRICTIVE FOR ALL TO authenticated USING (false) WITH CHECK (false); +CREATE POLICY default_deny_anon ON grida_billing.stripe_event AS RESTRICTIVE FOR ALL TO anon USING (false) WITH CHECK (false); +REVOKE ALL ON TABLE grida_billing.stripe_event FROM anon, authenticated; +GRANT ALL ON TABLE grida_billing.stripe_event TO service_role; + + +--------------------------------------------------------------------- +-- [grida_billing.audit] +-- Billing-scoped operations log. Typed columns; no jsonb. +-- +-- user_id = the actor (auth.uid() at call time, when known; +-- NULL for system / cron / webhook). +-- member_user_id = the seat target (for seat_add / seat_remove). +-- +-- Stripe-sourced amounts (e.g. invoice.amount_paid) are in CENTS +-- and stored in amount_cents. +--------------------------------------------------------------------- + +CREATE TABLE grida_billing.audit ( + id bigserial PRIMARY KEY, + organization_id bigint NOT NULL REFERENCES public.organization(id) ON DELETE CASCADE, + user_id uuid, + operation text NOT NULL CHECK (operation IN ( + 'subscribe','cancel','seat_add','seat_remove', + 'customer_attach','webhook.received' + )), + + -- Stripe references. + stripe_event_id text, + stripe_subscription_id text, + stripe_invoice_id text, + stripe_customer_id text, + + -- Seat operations. + member_user_id uuid, + prev_quantity integer, + new_quantity integer, + + -- State transitions. + plan text, + status text, + + -- Webhook context. + event_type text, + billing_reason text, + attempt_count integer, + + -- Stripe-sourced amount (cents). + amount_cents bigint, + + -- Free-form operator note. + note text, + + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX audit_org_created_idx + ON grida_billing.audit (organization_id, created_at DESC); +CREATE INDEX audit_stripe_event_id_idx + ON grida_billing.audit (stripe_event_id) + WHERE stripe_event_id IS NOT NULL; +CREATE INDEX audit_stripe_invoice_id_idx + ON grida_billing.audit (stripe_invoice_id) + WHERE stripe_invoice_id IS NOT NULL; + +ALTER TABLE grida_billing.audit ENABLE ROW LEVEL SECURITY; +CREATE POLICY default_deny_authenticated ON grida_billing.audit AS RESTRICTIVE FOR ALL TO authenticated USING (false) WITH CHECK (false); +CREATE POLICY default_deny_anon ON grida_billing.audit AS RESTRICTIVE FOR ALL TO anon USING (false) WITH CHECK (false); +REVOKE ALL ON TABLE grida_billing.audit FROM anon, authenticated; +GRANT ALL ON TABLE grida_billing.audit TO service_role; + + +-- ============================================================================ +-- Functions +-- ============================================================================ + +--------------------------------------------------------------------- +-- [grida_billing.fn_provision_account] +-- Idempotent. Creates account + free subscription rows. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.fn_provision_account(p_org_id bigint) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +BEGIN + INSERT INTO grida_billing.account (organization_id) + VALUES (p_org_id) + ON CONFLICT (organization_id) DO NOTHING; + + INSERT INTO grida_billing.subscription ( + organization_id, plan, is_free, status, quantity + ) VALUES ( + p_org_id, 'free', true, 'active', 1 + ) + ON CONFLICT DO NOTHING; +END; +$$; + + +--------------------------------------------------------------------- +-- [grida_billing.tg_provision_on_org_insert] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.tg_provision_on_org_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +BEGIN + PERFORM grida_billing.fn_provision_account(NEW.id); + RETURN NEW; +END; +$$; + +CREATE TRIGGER tg_billing_provision_on_org_insert + AFTER INSERT ON public.organization + FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_provision_on_org_insert(); + + +--------------------------------------------------------------------- +-- [grida_billing.tg_organization_before_delete] +-- Refuse to delete an organization with an active Stripe-backed +-- subscription. CASCADE removes our local rows but does NOT cancel +-- the Stripe subscription — the customer would keep getting billed +-- forever for a service we can't deliver. (TC-BILLING-SUB-017) +-- +-- TS deletion path must: +-- 1. Cancel the Stripe subscription (Customer Portal or admin SDK). +-- 2. Wait for customer.subscription.deleted webhook → status='canceled'. +-- 3. THEN delete the organization row. +-- +-- For free orgs with no Stripe sub, deletion proceeds normally. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.tg_organization_before_delete() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + v_active_sub_id text; +BEGIN + SELECT stripe_subscription_id INTO v_active_sub_id + FROM grida_billing.subscription + WHERE organization_id = OLD.id + AND is_free = false + AND status NOT IN ('canceled') + LIMIT 1; + + IF v_active_sub_id IS NOT NULL THEN + RAISE EXCEPTION + 'cannot delete organization %: active Stripe subscription % must be canceled first', + OLD.id, v_active_sub_id + USING ERRCODE = 'foreign_key_violation', + HINT = 'Cancel via Stripe Customer Portal, await customer.subscription.deleted webhook, then retry.'; + END IF; + + RETURN OLD; +END; +$$; + +CREATE TRIGGER tg_billing_organization_before_delete + BEFORE DELETE ON public.organization + FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_organization_before_delete(); + + +--------------------------------------------------------------------- +-- [grida_billing.fn_attach_stripe_customer] +-- Idempotent, race-safe attach. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.fn_attach_stripe_customer( + p_org_id bigint, + p_stripe_customer_id text +) +RETURNS TABLE ( + attached boolean, + stripe_customer_id text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + v_existing text; + v_attached boolean := false; +BEGIN + SELECT acc.stripe_customer_id INTO v_existing + FROM grida_billing.account acc + WHERE acc.organization_id = p_org_id + FOR UPDATE; + + -- The account row is provisioned by `tg_billing_provision_on_org_insert`. + -- Its absence means either the trigger didn't fire (data inconsistency) or + -- p_org_id is bogus — either way, refuse to silently report "attached". + IF NOT FOUND THEN + RAISE EXCEPTION + 'billing account not provisioned for organization %', p_org_id; + END IF; + + IF v_existing IS NULL THEN + UPDATE grida_billing.account + SET stripe_customer_id = p_stripe_customer_id, + updated_at = now() + WHERE organization_id = p_org_id; + v_existing := p_stripe_customer_id; + v_attached := true; + + INSERT INTO grida_billing.audit (organization_id, operation, stripe_customer_id) + VALUES (p_org_id, 'customer_attach', p_stripe_customer_id); + ELSIF v_existing <> p_stripe_customer_id THEN + -- Fail closed on attach-time drift. Mismatched ids = ops contamination + -- (manual SQL, leaked fixtures, double-create races). Silent skip would + -- return the existing id and the caller's freshly-created Stripe + -- customer would orphan on Stripe's side. Surface the conflict so + -- whoever is calling can investigate. + RAISE EXCEPTION + 'organization % is already attached to Stripe customer %, refusing %', + p_org_id, v_existing, p_stripe_customer_id; + END IF; + + RETURN QUERY SELECT v_attached, v_existing; +END; +$$; + + +-- Seat-sync triggers intentionally absent in v1. +-- Multi-seat billing is deferred: paid subs are billed at the quantity +-- chosen at Checkout (default 1) and changed only via the webhook. +-- Membership changes do not mutate subscription.quantity. When seat +-- management lands, it'll go through a server action that calls Stripe +-- with an idempotency key — never a local-mirror trigger. + + +--------------------------------------------------------------------- +-- [grida_billing.fn_apply_stripe_event] +-- Webhook projector for SaaS billing events: +-- • customer.created / customer.updated → upsert account.stripe_customer_id +-- • customer.subscription.{created,updated,deleted} → upsert subscription +-- (cancels the free sentinel row first if present) +-- • invoice.payment_failed → set status='past_due' +-- • invoice.payment_succeeded → restore from past_due +-- • charge.dispute.{created,updated,closed} → suspend / restore / +-- cancel the subscription based on dispute lifecycle. +-- +-- Idempotency: stripe_event.id PK + ON CONFLICT DO NOTHING. Replays +-- return result='replayed'; first-fail/retry path falls through. +-- On exception the function RAISEs; the receiver stamps failed_at +-- via fn_stamp_failure in a separate transaction. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.fn_apply_stripe_event( + p_event_id text, + p_event_type text, + p_payload jsonb +) +RETURNS TABLE ( + result text, + handler text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + v_existing grida_billing.stripe_event%ROWTYPE; + v_handler text; + v_org_id bigint; + v_customer_id text; + v_sub_id text; + v_invoice_id text; + v_status text; + v_quantity integer; + v_cancel_at_pe boolean; + v_cancel_at_unix bigint; + v_period_start timestamptz; + v_period_end timestamptz; + v_first_item jsonb; + v_price_id text; + v_plan text; + v_billing_reason text; + v_amount_paid bigint; + v_attempt_count integer; +BEGIN + IF p_event_id IS NULL OR p_event_type IS NULL THEN + RAISE EXCEPTION 'fn_apply_stripe_event: missing required argument'; + END IF; + + -- Idempotency. Insert-or-noop, then SELECT FOR UPDATE so concurrent + -- deliveries of the same event id serialise here: the second one waits for + -- the first to commit, then sees `processed_at` set and short-circuits. + -- Without the row lock both could read processed_at IS NULL and re-project. + INSERT INTO grida_billing.stripe_event (id, type) + VALUES (p_event_id, p_event_type) + ON CONFLICT (id) DO NOTHING; + + SELECT * INTO v_existing + FROM grida_billing.stripe_event + WHERE id = p_event_id + FOR UPDATE; + IF v_existing.processed_at IS NOT NULL THEN + RETURN QUERY SELECT 'replayed'::text, v_existing.handler; + RETURN; + END IF; + + IF p_event_type IN ('customer.created','customer.updated') THEN + v_customer_id := p_payload->>'id'; + v_org_id := nullif(p_payload->'metadata'->>'grida_organization_id', '')::bigint; + + IF v_org_id IS NOT NULL THEN + -- Fail closed on stripe_customer_id drift: if the org is already + -- attached to a *different* customer, refuse rather than silently + -- no-oping. Mismatched ids almost always mean ops contamination + -- (manual SQL, leaked test data) and silent skip turns later + -- subscription events into ghosts. Stripe will retry the webhook + -- while ops fixes the binding. + DECLARE + v_existing_cust text; + BEGIN + SELECT stripe_customer_id INTO v_existing_cust + FROM grida_billing.account + WHERE organization_id = v_org_id; + IF FOUND + AND v_existing_cust IS NOT NULL + AND v_existing_cust <> v_customer_id THEN + RAISE EXCEPTION + 'organization % already attached to Stripe customer %, refusing webhook for customer %', + v_org_id, v_existing_cust, v_customer_id; + END IF; + END; + + UPDATE grida_billing.account + SET stripe_customer_id = v_customer_id, + updated_at = now() + WHERE organization_id = v_org_id + AND (stripe_customer_id IS NULL OR stripe_customer_id = v_customer_id); + + INSERT INTO grida_billing.audit ( + organization_id, operation, stripe_event_id, stripe_customer_id, event_type + ) VALUES ( + v_org_id, 'webhook.received', p_event_id, v_customer_id, p_event_type + ); + END IF; + v_handler := 'customer_upsert'; + + ELSIF p_event_type IN ( + 'customer.subscription.created', + 'customer.subscription.updated', + 'customer.subscription.deleted' + ) THEN + v_sub_id := p_payload->>'id'; + v_customer_id := CASE + WHEN jsonb_typeof(p_payload->'customer') = 'string' + THEN trim(both '"' from (p_payload->'customer')::text) + ELSE p_payload->'customer'->>'id' + END; + + SELECT organization_id INTO v_org_id + FROM grida_billing.account + WHERE stripe_customer_id = v_customer_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'subscription event % cannot be projected: no account for customer %', v_sub_id, v_customer_id; + END IF; + + v_status := p_payload->>'status'; + v_first_item := p_payload->'items'->'data'->0; + v_quantity := coalesce((v_first_item->>'quantity')::integer, 1); + + v_cancel_at_pe := coalesce((p_payload->>'cancel_at_period_end')::boolean, false); + v_cancel_at_unix := nullif(p_payload->>'cancel_at', '')::bigint; + IF NOT v_cancel_at_pe AND v_cancel_at_unix IS NOT NULL + AND to_timestamp(v_cancel_at_unix) > now() THEN + v_cancel_at_pe := true; + END IF; + + v_period_start := to_timestamp(nullif(v_first_item->>'current_period_start','')::bigint); + v_period_end := to_timestamp(nullif(v_first_item->>'current_period_end', '')::bigint); + v_price_id := v_first_item->'price'->>'id'; + + -- Map Stripe price → plan via the catalogue. Fail closed: an unknown or + -- newly-provisioned price must not silently project as 'pro' (would + -- mis-entitle the org). RAISE so Stripe retries while ops fixes the + -- catalogue mapping. + v_plan := NULL; + IF v_price_id IS NOT NULL THEN + SELECT CASE + WHEN id IN ('plan.team', 'plan.team.annual') THEN 'team' + WHEN id IN ('plan.pro', 'plan.pro.annual') THEN 'pro' + ELSE NULL + END + INTO v_plan + FROM grida_billing.product_catalogue + WHERE stripe_price_id = v_price_id; + END IF; + IF v_plan IS NULL THEN + RAISE EXCEPTION + 'subscription % has unknown stripe price % — add it to grida_billing.product_catalogue', + v_sub_id, v_price_id; + END IF; + + -- Cancel the free sentinel row before the upsert (resolves + -- "one active per org" partial unique). + UPDATE grida_billing.subscription + SET status = 'canceled', updated_at = now() + WHERE organization_id = v_org_id + AND is_free = true + AND status <> 'canceled'; + + INSERT INTO grida_billing.subscription ( + organization_id, plan, is_free, status, quantity, + cancel_at_period_end, current_period_start, current_period_end, + stripe_subscription_id + ) VALUES ( + v_org_id, v_plan, false, v_status, v_quantity, + v_cancel_at_pe, v_period_start, v_period_end, v_sub_id + ) + ON CONFLICT (stripe_subscription_id) DO UPDATE SET + organization_id = EXCLUDED.organization_id, + plan = EXCLUDED.plan, + status = EXCLUDED.status, + quantity = EXCLUDED.quantity, + cancel_at_period_end = EXCLUDED.cancel_at_period_end, + current_period_start = EXCLUDED.current_period_start, + current_period_end = EXCLUDED.current_period_end, + updated_at = now(); + + INSERT INTO grida_billing.audit ( + organization_id, operation, stripe_event_id, stripe_subscription_id, + event_type, plan, status, new_quantity + ) VALUES ( + v_org_id, 'webhook.received', p_event_id, v_sub_id, + p_event_type, v_plan, v_status, v_quantity + ); + v_handler := 'subscription_upsert'; + + ELSIF p_event_type IN ('charge.dispute.created','charge.dispute.closed','charge.dispute.updated') THEN + -- Dispute payloads don't carry subscription_id natively. The + -- TS receiver pre-resolves dispute.charge → invoice.subscription + -- and stitches it into metadata.grida_subscription_id before + -- invoking this RPC. Without it we cannot project; raise. + v_sub_id := nullif(p_payload->'metadata'->>'grida_subscription_id', ''); + DECLARE + v_dispute_status text := p_payload->>'status'; + v_dispute_id text := p_payload->>'id'; + BEGIN + IF v_sub_id IS NULL THEN + RAISE EXCEPTION 'charge.dispute event % missing metadata.grida_subscription_id (TS must pre-resolve)', v_dispute_id; + END IF; + + SELECT organization_id INTO v_org_id + FROM grida_billing.subscription + WHERE stripe_subscription_id = v_sub_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'charge.dispute: no subscription row for %', v_sub_id; + END IF; + + -- TC-BILLING-PAY-011: created → suspend AI immediately. + -- TC-BILLING-PAY-012: closed.won → restore. + -- TC-BILLING-PAY-013: closed.lost → cancel; AI hard-blocked. + IF p_event_type = 'charge.dispute.created' + OR (p_event_type IN ('charge.dispute.updated') + AND v_dispute_status IN ('warning_needs_response','warning_under_review', + 'needs_response','under_review')) THEN + UPDATE grida_billing.subscription + SET status = 'paused', updated_at = now() + WHERE stripe_subscription_id = v_sub_id + AND status NOT IN ('canceled'); + ELSIF p_event_type = 'charge.dispute.closed' + AND v_dispute_status IN ('won','warning_closed') THEN + UPDATE grida_billing.subscription + SET status = 'active', updated_at = now() + WHERE stripe_subscription_id = v_sub_id + AND status = 'paused'; + ELSIF p_event_type = 'charge.dispute.closed' + AND v_dispute_status = 'lost' THEN + UPDATE grida_billing.subscription + SET status = 'canceled', updated_at = now() + WHERE stripe_subscription_id = v_sub_id; + END IF; + + INSERT INTO grida_billing.audit ( + organization_id, operation, stripe_event_id, stripe_subscription_id, + event_type, status, note + ) VALUES ( + v_org_id, 'webhook.received', p_event_id, v_sub_id, + p_event_type, + CASE + WHEN p_event_type = 'charge.dispute.closed' AND v_dispute_status = 'won' THEN 'active' + WHEN p_event_type = 'charge.dispute.closed' AND v_dispute_status = 'lost' THEN 'canceled' + ELSE 'paused' + END, + format('dispute_id=%s status=%s', v_dispute_id, v_dispute_status) + ); + v_handler := 'dispute_' || coalesce(v_dispute_status, 'unknown'); + END; + + ELSIF p_event_type = 'invoice.payment_failed' THEN + v_invoice_id := p_payload->>'id'; + v_attempt_count := nullif(p_payload->>'attempt_count','')::integer; + v_sub_id := nullif(p_payload->>'subscription', ''); + IF v_sub_id IS NULL THEN + v_sub_id := p_payload->'parent'->'subscription_details'->>'subscription'; + END IF; + + IF v_sub_id IS NULL THEN + v_handler := 'invoice_payment_failed_skipped'; + ELSE + SELECT organization_id INTO v_org_id + FROM grida_billing.subscription + WHERE stripe_subscription_id = v_sub_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'invoice.payment_failed: no subscription row for %', v_sub_id; + END IF; + + -- First-invoice failures arrive while Stripe holds the sub in + -- `incomplete` (then `incomplete_expired` after ~23h). Don't collapse + -- those into `past_due` — that's the renewal-failure status, and the UI + -- branches on the distinction. + UPDATE grida_billing.subscription + SET status = CASE + WHEN status IN ('incomplete', 'incomplete_expired') THEN status + ELSE 'past_due' + END, + updated_at = now() + WHERE stripe_subscription_id = v_sub_id + RETURNING status INTO v_status; + + INSERT INTO grida_billing.audit ( + organization_id, operation, stripe_event_id, stripe_subscription_id, + stripe_invoice_id, event_type, status, attempt_count + ) VALUES ( + v_org_id, 'webhook.received', p_event_id, v_sub_id, + v_invoice_id, p_event_type, v_status, v_attempt_count + ); + v_handler := 'invoice_payment_failed'; + END IF; + + ELSIF p_event_type = 'invoice.payment_succeeded' THEN + v_invoice_id := p_payload->>'id'; + v_billing_reason := p_payload->>'billing_reason'; + v_amount_paid := nullif(p_payload->>'amount_paid', '')::bigint; + v_sub_id := nullif(p_payload->>'subscription', ''); + IF v_sub_id IS NULL THEN + v_sub_id := p_payload->'parent'->'subscription_details'->>'subscription'; + END IF; + + IF v_sub_id IS NULL THEN + v_handler := 'invoice_payment_succeeded_skipped'; + ELSE + SELECT organization_id, plan INTO v_org_id, v_plan + FROM grida_billing.subscription + WHERE stripe_subscription_id = v_sub_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'invoice.payment_succeeded: no subscription row for %', v_sub_id; + END IF; + + UPDATE grida_billing.subscription + SET status = 'active', updated_at = now() + WHERE stripe_subscription_id = v_sub_id + AND status = 'past_due'; + + INSERT INTO grida_billing.audit ( + organization_id, operation, stripe_event_id, stripe_subscription_id, + stripe_invoice_id, event_type, billing_reason, amount_cents, plan + ) VALUES ( + v_org_id, 'webhook.received', p_event_id, v_sub_id, + v_invoice_id, p_event_type, v_billing_reason, v_amount_paid, v_plan + ); + v_handler := 'invoice_payment_succeeded'; + END IF; + + ELSE + v_handler := 'unhandled'; + END IF; + + UPDATE grida_billing.stripe_event + SET processed_at = now(), + handler = v_handler, + failed_at = NULL, + failure_reason = NULL + WHERE id = p_event_id; + + RETURN QUERY SELECT 'handled'::text, v_handler; +END; +$$; + + +--------------------------------------------------------------------- +-- [grida_billing.fn_stamp_failure] +-- Forensic stamp called from the receiver's catch path AFTER +-- fn_apply_stripe_event has rolled back. Separate transaction. +-- +-- UPSERT, not UPDATE: when the projector RAISEs, the entire transaction +-- (including the INSERT INTO stripe_event at the top of fn_apply_stripe_event) +-- rolls back. So on a first-time failure, no row exists yet and an +-- UPDATE-only stamp would silently match nothing. INSERT … ON CONFLICT +-- handles both cases — first failure inserts the forensic row; later +-- failures update the existing one. +--------------------------------------------------------------------- + +DROP FUNCTION IF EXISTS grida_billing.fn_stamp_failure(text, text); + +CREATE OR REPLACE FUNCTION grida_billing.fn_stamp_failure( + p_event_id text, + p_event_type text, + p_reason text +) +RETURNS void +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + INSERT INTO grida_billing.stripe_event (id, type, failed_at, failure_reason) + VALUES (p_event_id, p_event_type, now(), left(p_reason, 2000)) + ON CONFLICT (id) DO UPDATE SET + failed_at = EXCLUDED.failed_at, + failure_reason = EXCLUDED.failure_reason; +$$; + + +-- ============================================================================ +-- public.* wrapper surface (PostgREST) +-- ============================================================================ + +--------------------------------------------------------------------- +-- [public.v_billing_subscription] +--------------------------------------------------------------------- + +CREATE OR REPLACE VIEW public.v_billing_subscription +WITH (security_invoker = false) +AS +SELECT + s.organization_id, + s.plan, + s.is_free, + s.status, + s.quantity, + s.cancel_at_period_end, + s.current_period_start, + s.current_period_end, + s.stripe_subscription_id, + acc.stripe_customer_id +FROM grida_billing.subscription s +LEFT JOIN grida_billing.account acc ON acc.organization_id = s.organization_id +WHERE s.status <> 'canceled' + AND s.organization_id IN ( + SELECT om.organization_id + FROM public.organization_member om + WHERE om.user_id = (SELECT auth.uid()) + ); + +GRANT SELECT ON public.v_billing_subscription TO authenticated, service_role; + + +--------------------------------------------------------------------- +-- [public.v_billing_audit] +-- Owner-only billing audit feed (TC-BILLING-OPS-009/010). +-- Members can NOT read this — only the org owner. +--------------------------------------------------------------------- + +CREATE OR REPLACE VIEW public.v_billing_audit +WITH (security_invoker = false) +AS +SELECT + a.id, + a.organization_id, + a.user_id, + a.operation, + a.stripe_event_id, + a.stripe_subscription_id, + a.stripe_invoice_id, + a.stripe_customer_id, + a.member_user_id, + a.prev_quantity, + a.new_quantity, + a.plan, + a.status, + a.event_type, + a.billing_reason, + a.attempt_count, + a.amount_cents, + a.note, + a.created_at +FROM grida_billing.audit a +WHERE a.organization_id IN ( + SELECT o.id FROM public.organization o + WHERE o.owner_id = (SELECT auth.uid()) +); + +GRANT SELECT ON public.v_billing_audit TO authenticated, service_role; + + +--------------------------------------------------------------------- +-- [public.fn_billing_apply_stripe_event] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.fn_billing_apply_stripe_event( + p_event_id text, + p_event_type text, + p_payload jsonb +) +RETURNS TABLE (result text, handler text) +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT * FROM grida_billing.fn_apply_stripe_event(p_event_id, p_event_type, p_payload); +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_apply_stripe_event(text, text, jsonb) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_apply_stripe_event(text, text, jsonb) TO service_role; + + +--------------------------------------------------------------------- +-- [public.fn_billing_stamp_failure] +--------------------------------------------------------------------- + +DROP FUNCTION IF EXISTS public.fn_billing_stamp_failure(text, text); + +CREATE OR REPLACE FUNCTION public.fn_billing_stamp_failure( + p_event_id text, + p_event_type text, + p_reason text +) +RETURNS void +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT grida_billing.fn_stamp_failure(p_event_id, p_event_type, p_reason); +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_stamp_failure(text, text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_stamp_failure(text, text, text) TO service_role; + + +--------------------------------------------------------------------- +-- [public.fn_billing_attach_stripe_customer] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.fn_billing_attach_stripe_customer( + p_org_id bigint, + p_stripe_customer_id text +) +RETURNS TABLE (attached boolean, stripe_customer_id text) +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT * FROM grida_billing.fn_attach_stripe_customer(p_org_id, p_stripe_customer_id); +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_attach_stripe_customer(bigint, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_attach_stripe_customer(bigint, text) TO service_role; + + +--------------------------------------------------------------------- +-- [public.fn_billing_get_customer_id] +-- Service-role read of account.stripe_customer_id. Used by TS checkout +-- helpers (lib/billing/checkout.ts) to look up the cached customer id. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.fn_billing_get_customer_id(p_org_id bigint) +RETURNS text +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT stripe_customer_id FROM grida_billing.account WHERE organization_id = p_org_id; +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_get_customer_id(bigint) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_get_customer_id(bigint) TO service_role; + + +--------------------------------------------------------------------- +-- [public.fn_billing_get_active_subscription] +-- Service-role read of the org's active Stripe subscription. Returns +-- empty when the org is on the free sentinel row. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.fn_billing_get_active_subscription(p_org_id bigint) +RETURNS TABLE ( + stripe_subscription_id text, + status text, + quantity integer, + plan text, + cancel_at_period_end boolean, + current_period_start timestamptz, + current_period_end timestamptz +) +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT s.stripe_subscription_id, s.status, s.quantity, s.plan, + s.cancel_at_period_end, s.current_period_start, s.current_period_end + FROM grida_billing.subscription s + WHERE s.organization_id = p_org_id + AND s.is_free = false + AND s.status <> 'canceled' + ORDER BY s.updated_at DESC + LIMIT 1; +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_get_active_subscription(bigint) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_get_active_subscription(bigint) TO service_role; + + +--------------------------------------------------------------------- +-- [public.fn_billing_get_catalogue] +-- Service-role read of product_catalogue.stripe_product_id + +-- stripe_price_id. Returns NULL row when the catalogue id is unknown +-- or has no Stripe wiring yet. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.fn_billing_get_catalogue(p_id text) +RETURNS TABLE ( + stripe_product_id text, + stripe_price_id text +) +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT stripe_product_id, stripe_price_id + FROM grida_billing.product_catalogue + WHERE id = p_id; +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_get_catalogue(text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_get_catalogue(text) TO service_role; + + +--------------------------------------------------------------------- +-- [public.fn_billing_setup_product] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.fn_billing_setup_product( + p_grida_billing_id text, + p_stripe_product_id text, + p_stripe_price_id text +) +RETURNS TABLE (id text, stripe_product_id text, stripe_price_id text) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +BEGIN + IF p_grida_billing_id NOT IN ( + 'plan.pro', 'plan.team', 'plan.pro.annual', 'plan.team.annual' + ) THEN + RAISE EXCEPTION 'fn_billing_setup_product: unknown catalogue id %', p_grida_billing_id; + END IF; + + UPDATE grida_billing.product_catalogue + SET stripe_product_id = p_stripe_product_id, + stripe_price_id = p_stripe_price_id + WHERE product_catalogue.id = p_grida_billing_id; + + RETURN QUERY SELECT p_grida_billing_id, p_stripe_product_id, p_stripe_price_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_setup_product(text, text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_setup_product(text, text, text) TO service_role; diff --git a/supabase/seed.sql b/supabase/seed.sql index 0ba8b3a979..45f8531a55 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -53,8 +53,7 @@ INSERT INTO public.organization ( email, description, blog, - display_name, - display_plan + display_name ) VALUES ( 'local', @@ -63,8 +62,7 @@ VALUES ( 'hello@grida.co', 'Local test organization for development purposes.', 'https://grida.co', - 'Local', - 'free' + 'Local' ); -- Organization: "acme" (owned by alice@acme.com) @@ -75,8 +73,7 @@ INSERT INTO public.organization ( email, description, blog, - display_name, - display_plan + display_name ) VALUES ( 'acme', @@ -85,8 +82,7 @@ VALUES ( 'hello@acme.com', 'ACME test organization for multi-tenant testing.', 'https://acme.com', - 'ACME', - 'free' + 'ACME' ); -- #endregion organization @@ -122,3 +118,7 @@ INSERT INTO grida_library.category (id, name) VALUES ('generated', 'Generated') ON CONFLICT (id) DO NOTHING; -- #endregion library categories + +-- Note: grida_billing.account + free grida_billing.subscription rows are +-- provisioned automatically by tg_billing_provision_on_org_insert when the +-- seed inserts the local/acme orgs above. No seed-time backfill needed. diff --git a/supabase/tests/test_grida_billing_test.sql b/supabase/tests/test_grida_billing_test.sql new file mode 100644 index 0000000000..da7e63ba8e --- /dev/null +++ b/supabase/tests/test_grida_billing_test.sql @@ -0,0 +1,496 @@ +-- pgTAP suite: grida_billing schema +-- +-- Covers: provisioning trigger, Stripe projector (subscription, invoice, +-- dispute, idempotency), org-delete guard, RLS on +-- public.v_billing_subscription / v_billing_audit. + +BEGIN; + +SELECT plan(57); + +-- Stash seed UUIDs (regenerated on every `supabase db reset`). +DO $$ +BEGIN + PERFORM set_config('test.insider_uid', + (SELECT id::text FROM auth.users WHERE email='insider@grida.co'), false); + PERFORM set_config('test.alice_uid', + (SELECT id::text FROM auth.users WHERE email='alice@acme.com'), false); + PERFORM set_config('test.random_uid', + (SELECT id::text FROM auth.users WHERE email='random@example.com'), false); +END $$; + +-- --------------------------------------------------------------------- +-- 1. Schema lockdown. +-- --------------------------------------------------------------------- + +SELECT has_schema('grida_billing', 'grida_billing schema exists'); +SELECT has_table('grida_billing', 'account', 'account table exists'); +SELECT has_table('grida_billing', 'subscription', 'subscription table exists'); +SELECT has_table('grida_billing', 'product_catalogue', 'product_catalogue exists'); +SELECT has_table('grida_billing', 'stripe_event', 'stripe_event exists'); +SELECT has_table('grida_billing', 'audit', 'audit exists'); + +-- --------------------------------------------------------------------- +-- 2. Auto-provision on org insert. +-- --------------------------------------------------------------------- + +SELECT ok( + exists(SELECT 1 FROM grida_billing.account WHERE organization_id = 1), + 'org 1 has billing.account row from trigger' +); +SELECT ok( + exists(SELECT 1 FROM grida_billing.subscription + WHERE organization_id = 1 AND is_free AND plan='free' AND status='active'), + 'org 1 has free/active subscription from trigger' +); + +-- --------------------------------------------------------------------- +-- 3. Stripe projector: customer attach + subscription.created. +-- --------------------------------------------------------------------- + +DO $$ BEGIN PERFORM public.fn_billing_attach_stripe_customer(1, 'cus_test_org1'); END $$; +-- Wire the test price id onto plan.pro so the fail-closed projector can map +-- it. Without this the subscription.created event below RAISEs with "unknown +-- stripe price" — see fn_apply_stripe_event. +DO $$ BEGIN PERFORM public.fn_billing_setup_product('plan.pro', 'prod_test_pro', 'price_pro_test'); END $$; + +SELECT is( + (SELECT stripe_customer_id FROM grida_billing.account WHERE organization_id=1), + 'cus_test_org1', + 'customer attach stamps account' +); + +SELECT lives_ok($$ + SELECT public.fn_billing_apply_stripe_event( + 'evt_test_sub_created', + 'customer.subscription.created', + jsonb_build_object( + 'id','sub_test1', 'status','active', + 'cancel_at_period_end', false, + 'customer','cus_test_org1', + 'items', jsonb_build_object('data', jsonb_build_array( + jsonb_build_object( + 'quantity', 3, + 'current_period_start', extract(epoch from now())::bigint, + 'current_period_end', extract(epoch from now() + interval '30 days')::bigint, + 'price', jsonb_build_object('id', 'price_pro_test') + ) + )) + ) + ) +$$, 'subscription.created applies cleanly'); + +SELECT ok( + exists(SELECT 1 FROM grida_billing.subscription + WHERE stripe_subscription_id='sub_test1' + AND organization_id=1 AND is_free=false + AND quantity=3 AND status='active'), + 'paid subscription row inserted with qty=3' +); + +SELECT ok( + not exists(SELECT 1 FROM grida_billing.subscription + WHERE organization_id=1 AND is_free=true AND status<>'canceled'), + 'free sentinel row was canceled by upgrade' +); + +-- --------------------------------------------------------------------- +-- 4. Idempotency. +-- --------------------------------------------------------------------- + +SELECT is( + (SELECT result FROM public.fn_billing_apply_stripe_event( + 'evt_test_sub_created', 'customer.subscription.created', + '{"id":"sub_test1","status":"active","customer":"cus_test_org1","items":{"data":[{"quantity":3,"price":{"id":"price_pro_test"}}]}}'::jsonb)), + 'replayed', + 'duplicate event_id returns replayed' +); + +-- --------------------------------------------------------------------- +-- 5. invoice.payment_failed → past_due → payment_succeeded → active. +-- --------------------------------------------------------------------- + +SELECT lives_ok($$ + SELECT public.fn_billing_apply_stripe_event( + 'evt_invoice_failed_1', + 'invoice.payment_failed', + jsonb_build_object('id','in_test1','subscription','sub_test1','attempt_count',1) + ) +$$, 'invoice.payment_failed applies'); + +SELECT is( + (SELECT status FROM grida_billing.subscription WHERE stripe_subscription_id='sub_test1'), + 'past_due', + 'subscription is past_due after payment_failed' +); + +SELECT lives_ok($$ + SELECT public.fn_billing_apply_stripe_event( + 'evt_invoice_succeeded_1', + 'invoice.payment_succeeded', + jsonb_build_object('id','in_test2','subscription','sub_test1','billing_reason','subscription_cycle','amount_paid', 6000) + ) +$$, 'invoice.payment_succeeded applies'); + +SELECT is( + (SELECT status FROM grida_billing.subscription WHERE stripe_subscription_id='sub_test1'), + 'active', + 'subscription restored to active after payment_succeeded' +); + +-- --------------------------------------------------------------------- +-- 5a. subscription.updated mirrors quantity changes. +-- --------------------------------------------------------------------- + +SELECT lives_ok($$ + SELECT public.fn_billing_apply_stripe_event( + 'evt_test_sub_qty_change', + 'customer.subscription.updated', + jsonb_build_object( + 'id','sub_test1', 'status','active', + 'cancel_at_period_end', false, + 'customer','cus_test_org1', + 'items', jsonb_build_object('data', jsonb_build_array( + jsonb_build_object( + 'quantity', 5, + 'current_period_start', extract(epoch from now())::bigint, + 'current_period_end', extract(epoch from now() + interval '30 days')::bigint, + 'price', jsonb_build_object('id', 'price_pro_test') + ) + )) + ) + ) +$$, 'subscription.updated (qty change) applies'); + +SELECT is( + (SELECT quantity FROM grida_billing.subscription WHERE stripe_subscription_id='sub_test1'), + 5, + 'subscription.updated mirrors new quantity (3 → 5)' +); + +-- --------------------------------------------------------------------- +-- 5b. subscription.updated mirrors cancel_at_period_end toggle. +-- --------------------------------------------------------------------- + +SELECT lives_ok($$ + SELECT public.fn_billing_apply_stripe_event( + 'evt_test_sub_cape', + 'customer.subscription.updated', + jsonb_build_object( + 'id','sub_test1', 'status','active', + 'cancel_at_period_end', true, + 'customer','cus_test_org1', + 'items', jsonb_build_object('data', jsonb_build_array( + jsonb_build_object( + 'quantity', 5, + 'current_period_start', extract(epoch from now())::bigint, + 'current_period_end', extract(epoch from now() + interval '30 days')::bigint, + 'price', jsonb_build_object('id', 'price_pro_test') + ) + )) + ) + ) +$$, 'subscription.updated (cancel_at_period_end) applies'); + +SELECT is( + (SELECT cancel_at_period_end FROM grida_billing.subscription WHERE stripe_subscription_id='sub_test1'), + true, + 'subscription.updated flips cancel_at_period_end → true' +); + +-- Reset cape back to false so section 7's org-delete-guard test sees the +-- expected "active Stripe sub" state. +DO $$ BEGIN + PERFORM public.fn_billing_apply_stripe_event( + 'evt_test_sub_cape_reset', + 'customer.subscription.updated', + jsonb_build_object( + 'id','sub_test1', 'status','active', + 'cancel_at_period_end', false, + 'customer','cus_test_org1', + 'items', jsonb_build_object('data', jsonb_build_array( + jsonb_build_object( + 'quantity', 5, + 'price', jsonb_build_object('id', 'price_pro_test') + ) + )) + ) + ); +END $$; + +-- --------------------------------------------------------------------- +-- 6. Dispute lifecycle. +-- --------------------------------------------------------------------- + +SELECT lives_ok($$ + SELECT public.fn_billing_apply_stripe_event( + 'evt_dispute_created_1', + 'charge.dispute.created', + jsonb_build_object( + 'id','dp_1','status','warning_under_review', + 'metadata', jsonb_build_object('grida_subscription_id','sub_test1') + ) + ) +$$, 'dispute.created applies'); + +SELECT is( + (SELECT status FROM grida_billing.subscription WHERE stripe_subscription_id='sub_test1'), + 'paused', + 'dispute.created → paused' +); + +SELECT lives_ok($$ + SELECT public.fn_billing_apply_stripe_event( + 'evt_dispute_won_1', + 'charge.dispute.closed', + jsonb_build_object( + 'id','dp_1','status','won', + 'metadata', jsonb_build_object('grida_subscription_id','sub_test1') + ) + ) +$$, 'dispute.closed.won applies'); + +SELECT is( + (SELECT status FROM grida_billing.subscription WHERE stripe_subscription_id='sub_test1'), + 'active', + 'dispute.closed.won → active' +); + +SELECT throws_ok($$ + SELECT public.fn_billing_apply_stripe_event( + 'evt_dispute_unbound', + 'charge.dispute.created', + '{"id":"dp_x","status":"under_review"}'::jsonb + ) +$$, NULL, NULL, + 'dispute event without grida_subscription_id raises' +); + +-- --------------------------------------------------------------------- +-- 7. Org-delete guard. +-- --------------------------------------------------------------------- + +SELECT throws_ok($$ + DELETE FROM public.organization WHERE id = 1 +$$, NULL, NULL, + 'cannot delete org with active Stripe subscription'); + +UPDATE grida_billing.subscription + SET status='canceled', updated_at=now() + WHERE organization_id=1 AND is_free=false; + +SELECT lives_ok($$ + DELETE FROM public.organization WHERE id = 1 +$$, 'org deletes after Stripe sub canceled'); + +-- --------------------------------------------------------------------- +-- 9. RLS on v_billing_subscription. (Local org was deleted above, use acme.) +-- --------------------------------------------------------------------- + +SET LOCAL ROLE authenticated; + +SELECT set_config('request.jwt.claim.sub', current_setting('test.insider_uid'), true); +SELECT is( + (SELECT count(*) FROM public.v_billing_subscription WHERE organization_id=2), + 0::bigint, + 'insider does NOT see acme subscription' +); + +SELECT set_config('request.jwt.claim.sub', current_setting('test.alice_uid'), true); +SELECT ok( + exists(SELECT 1 FROM public.v_billing_subscription WHERE organization_id=2), + 'alice sees own acme subscription' +); + +SELECT set_config('request.jwt.claim.sub', current_setting('test.random_uid'), true); +SELECT is( + (SELECT count(*) FROM public.v_billing_subscription), + 0::bigint, + 'random user with no org sees no subscriptions' +); +RESET ROLE; + +-- --------------------------------------------------------------------- +-- 10. v_billing_audit owner-only (member-but-not-owner blocked). +-- --------------------------------------------------------------------- + +INSERT INTO grida_billing.audit (organization_id, operation, note) +VALUES (2, 'customer_attach', 'pgtap fixture'); + +INSERT INTO public.organization_member (organization_id, user_id) +VALUES (2, current_setting('test.insider_uid')::uuid); + +SET LOCAL ROLE authenticated; + +SELECT set_config('request.jwt.claim.sub', current_setting('test.insider_uid'), true); +SELECT is( + (SELECT count(*) FROM public.v_billing_audit WHERE organization_id=2), + 0::bigint, + 'member who is NOT owner cannot see acme audit' +); + +SELECT set_config('request.jwt.claim.sub', current_setting('test.alice_uid'), true); +SELECT ok( + (SELECT count(*) FROM public.v_billing_audit WHERE organization_id=2) > 0, + 'owner alice sees her acme audit rows' +); + +RESET ROLE; + +-- --------------------------------------------------------------------- +-- 11. Annual catalogue id resolves to the underlying plan. +-- `plan.pro.annual` → 'pro', `plan.team.annual` → 'team'. +-- Wire dummy stripe price ids onto the catalogue rows, deliver a +-- subscription.created event referencing each, and assert the projected +-- `subscription.plan` is correct (not the silent fallback). +-- --------------------------------------------------------------------- + +DO $$ BEGIN PERFORM public.fn_billing_attach_stripe_customer(2, 'cus_test_org2_annual'); END $$; +DO $$ BEGIN PERFORM public.fn_billing_setup_product('plan.pro.annual', 'prod_test_pro_annual', 'price_test_pro_annual'); END $$; + +SELECT lives_ok($$ + SELECT public.fn_billing_apply_stripe_event( + 'evt_test_sub_pro_annual', + 'customer.subscription.created', + jsonb_build_object( + 'id','sub_test_pro_annual', 'status','active', + 'cancel_at_period_end', false, + 'customer','cus_test_org2_annual', + 'items', jsonb_build_object('data', jsonb_build_array( + jsonb_build_object( + 'quantity', 1, + 'current_period_start', extract(epoch from now())::bigint, + 'current_period_end', extract(epoch from now() + interval '365 days')::bigint, + 'price', jsonb_build_object('id', 'price_test_pro_annual') + ) + )) + ) + ) +$$, 'subscription.created with annual price applies'); + +SELECT is( + (SELECT plan FROM grida_billing.subscription WHERE stripe_subscription_id='sub_test_pro_annual'), + 'pro', + 'plan.pro.annual catalogue id resolves to plan=pro (no silent fallback)' +); + +-- --------------------------------------------------------------------- +-- 12. is_enterprise column: default is false; service_role can flip it. +-- --------------------------------------------------------------------- + +SELECT is( + (SELECT is_enterprise FROM public.organization WHERE id = 2), + false, + 'is_enterprise defaults to false on org insert' +); + +UPDATE public.organization SET is_enterprise = true WHERE id = 2; + +SELECT is( + (SELECT is_enterprise FROM public.organization WHERE id = 2), + true, + 'service_role can set is_enterprise = true' +); + +-- --------------------------------------------------------------------- +-- 13. Direct RLS / GRANT denial on grida_billing internal tables. +-- The schema is locked-down: REVOKE ALL FROM anon, authenticated + +-- RESTRICTIVE deny policies. Verify both `anon` and `authenticated` +-- get permission_denied (SQLSTATE 42501) on read AND write paths so +-- a future stray GRANT or policy loosen-up gets caught here. +-- Tables: account, subscription, product_catalogue, stripe_event, audit. +-- --------------------------------------------------------------------- + +-- authenticated role. +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', current_setting('test.insider_uid'), true); + +SELECT throws_ok( + $$ SELECT 1 FROM grida_billing.account LIMIT 1 $$, '42501', NULL, + 'authenticated cannot SELECT grida_billing.account' +); +SELECT throws_ok( + $$ INSERT INTO grida_billing.account (organization_id) VALUES (1) $$, '42501', NULL, + 'authenticated cannot INSERT grida_billing.account' +); +SELECT throws_ok( + $$ SELECT 1 FROM grida_billing.subscription LIMIT 1 $$, '42501', NULL, + 'authenticated cannot SELECT grida_billing.subscription' +); +SELECT throws_ok( + $$ INSERT INTO grida_billing.subscription (organization_id, plan, is_free, status) VALUES (1, 'free', true, 'active') $$, '42501', NULL, + 'authenticated cannot INSERT grida_billing.subscription' +); +SELECT throws_ok( + $$ SELECT 1 FROM grida_billing.product_catalogue LIMIT 1 $$, '42501', NULL, + 'authenticated cannot SELECT grida_billing.product_catalogue' +); +SELECT throws_ok( + $$ INSERT INTO grida_billing.product_catalogue (id, kind) VALUES ('plan.x', 'plan') $$, '42501', NULL, + 'authenticated cannot INSERT grida_billing.product_catalogue' +); +SELECT throws_ok( + $$ SELECT 1 FROM grida_billing.stripe_event LIMIT 1 $$, '42501', NULL, + 'authenticated cannot SELECT grida_billing.stripe_event' +); +SELECT throws_ok( + $$ INSERT INTO grida_billing.stripe_event (id, type) VALUES ('evt_x', 'customer.created') $$, '42501', NULL, + 'authenticated cannot INSERT grida_billing.stripe_event' +); +SELECT throws_ok( + $$ SELECT 1 FROM grida_billing.audit LIMIT 1 $$, '42501', NULL, + 'authenticated cannot SELECT grida_billing.audit' +); +SELECT throws_ok( + $$ INSERT INTO grida_billing.audit (organization_id, operation) VALUES (1, 'customer_attach') $$, '42501', NULL, + 'authenticated cannot INSERT grida_billing.audit' +); + +-- anon role (no JWT, no auth.uid()). +SET LOCAL ROLE anon; + +SELECT throws_ok( + $$ SELECT 1 FROM grida_billing.account LIMIT 1 $$, '42501', NULL, + 'anon cannot SELECT grida_billing.account' +); +SELECT throws_ok( + $$ INSERT INTO grida_billing.account (organization_id) VALUES (1) $$, '42501', NULL, + 'anon cannot INSERT grida_billing.account' +); +SELECT throws_ok( + $$ SELECT 1 FROM grida_billing.subscription LIMIT 1 $$, '42501', NULL, + 'anon cannot SELECT grida_billing.subscription' +); +SELECT throws_ok( + $$ INSERT INTO grida_billing.subscription (organization_id, plan, is_free, status) VALUES (1, 'free', true, 'active') $$, '42501', NULL, + 'anon cannot INSERT grida_billing.subscription' +); +SELECT throws_ok( + $$ SELECT 1 FROM grida_billing.product_catalogue LIMIT 1 $$, '42501', NULL, + 'anon cannot SELECT grida_billing.product_catalogue' +); +SELECT throws_ok( + $$ INSERT INTO grida_billing.product_catalogue (id, kind) VALUES ('plan.x', 'plan') $$, '42501', NULL, + 'anon cannot INSERT grida_billing.product_catalogue' +); +SELECT throws_ok( + $$ SELECT 1 FROM grida_billing.stripe_event LIMIT 1 $$, '42501', NULL, + 'anon cannot SELECT grida_billing.stripe_event' +); +SELECT throws_ok( + $$ INSERT INTO grida_billing.stripe_event (id, type) VALUES ('evt_x', 'customer.created') $$, '42501', NULL, + 'anon cannot INSERT grida_billing.stripe_event' +); +SELECT throws_ok( + $$ SELECT 1 FROM grida_billing.audit LIMIT 1 $$, '42501', NULL, + 'anon cannot SELECT grida_billing.audit' +); +SELECT throws_ok( + $$ INSERT INTO grida_billing.audit (organization_id, operation) VALUES (1, 'customer_attach') $$, '42501', NULL, + 'anon cannot INSERT grida_billing.audit' +); + +RESET ROLE; + +SELECT * FROM finish(); +ROLLBACK; diff --git a/test/billing-payment-and-money-safety.md b/test/billing-payment-and-money-safety.md new file mode 100644 index 0000000000..ec62aba19b --- /dev/null +++ b/test/billing-payment-and-money-safety.md @@ -0,0 +1,263 @@ +--- +id: TC-BILLING-PAY-000 +title: Payment failure, dunning, fraud, topups, refunds +module: billing +area: payment +tags: [stripe, dunning, past_due, fraud, abuse, money-safety, topup, refund] +status: untested +severity: critical +date: 2026-05-05 +updated: 2026-05-05 +automatable: partial +covered_by: [] +--- + +## Behavior + +The never-lose-money rule is the spine of these cases. A failed payment must block AI immediately. A past-due user must NOT silently fall back to the free allowance. Topups are deferred to a later release; cases below spec the expected behavior so that release has acceptance criteria ready. + +--- + +## Payment failure and dunning + +### TC-BILLING-PAY-001 — Card declines on the first invoice + +User upgrades to Pro. Stripe attempts to charge; card declines. +**Expected:** Subscription enters an unpaid state (incomplete or past_due). AI is blocked. The user sees a clear "payment didn't go through" message. + +### TC-BILLING-PAY-002 — Card declines on monthly renewal + +Active Pro user; renewal date arrives; card declined. +**Expected:** AI is blocked within seconds of the failure webhook. +**Niche:** Measure latency from webhook to blocked. Acceptable: under 30 seconds at p99. + +### TC-BILLING-PAY-003 — Smart Retries succeeds on attempt 2 + +Card declined on attempt 1; succeeds on attempt 2 a few days later. +**Expected:** Subscription returns to active. Allowance is restored to the plan level for the current period. + +### TC-BILLING-PAY-004 — Smart Retries exhausted + +After all retries fail, Stripe sets the subscription to unpaid or canceled per dunning settings. +**Expected:** AI stays blocked. Stripe sends its own cancellation email; our app reflects the canceled state on the next webhook. + +### TC-BILLING-PAY-005 — Past-due user does NOT get a free fallback + +Pro user goes past_due. Their allowance is zero. +**Expected:** Gate blocks. App may suggest "fix payment" but does NOT silently degrade to the Free $0.50 monthly. + +### TC-BILLING-PAY-006 — User updates card during past-due + +Past-due user adds a new card via Customer Portal. Stripe immediately retries. +**Expected:** On payment success, status flips to active and allowance is restored. + +### TC-BILLING-PAY-007 — Card declines mid-period after partial use + +Pro user used 70% of allowance before card declined. +**Expected:** Used amount stays where it was — don't zero it. When the card recovers, allowance returns to its full level minus what was already used. +**Niche:** Some platforms reset the meter on recovery. We don't. + +### TC-BILLING-PAY-008 — Card declines exactly at period rollover + +Renewal hits exactly at the boundary; card declines. +**Expected:** Old period closes normally. New period starts with zero allowance because the new period's payment failed. No spurious "fresh allowance" race. + +### TC-BILLING-PAY-009 — Webhook delayed 2 hours + +The "payment failed" webhook is delayed by Stripe. +**Expected:** During the 2-hour gap, the user has full allowance. Once the webhook arrives, AI blocks. +**Niche:** Bounded loss = 2h × user's call rate, capped by remaining allowance. + +### TC-BILLING-PAY-010 — Webhook delivery permanently fails + +Stripe retries for 3 days, then gives up. +**Expected:** The reconciliation cron pulls subscription state from Stripe daily, detects status drift, and fixes locally. Acceptable detection window: 24 hours. + +### TC-BILLING-PAY-011 — Customer disputes a charge (chargeback opens) + +User opens a Visa/Mastercard dispute on a Pro charge. +**Expected:** Stripe's dispute webhook arrives. Per policy, the org is flagged and the subscription is suspended (or scheduled to cancel). AI access ends per the chosen policy. +**Niche:** Document the policy. Without auto-suspend, user keeps paid-feature access until the natural cancellation. + +### TC-BILLING-PAY-012 — Chargeback reversed in our favor + +Stripe rules in our favor; funds returned. +**Expected:** Subscription status restored; no allowance change. + +### TC-BILLING-PAY-013 — Chargeback lost + +Stripe rules in cardholder's favor; funds lost plus the dispute fee. +**Expected:** Subscription canceled. AI blocked. The dispute fee is unavoidable; document as accepted loss. + +### TC-BILLING-PAY-014 — Card on file is about to expire + +Stripe sends an expiration notice well before the actual expiry. +**Expected:** App emails the user to update their card. No allowance effect yet. + +### TC-BILLING-PAY-015 — Customer Balance positive at cancellation + +User cancels Pro with leftover Stripe-side balance from a past topup (future feature). +**Expected:** Per documented policy, refund or roll forward for re-subscribe. Do not silently keep the user's money. + +### TC-BILLING-PAY-016 — Stripe is down for an hour + +Stripe API returns errors for an hour. +**Expected:** Existing subscriptions keep working (allowance reads are local). Webhooks queue and deliver when Stripe recovers. New checkouts fail gracefully with a "try again" message. + +### TC-BILLING-PAY-017 — Account-level fraud lock by Stripe + +Stripe locks our entire Stripe account (e.g. compliance review). +**Expected:** All webhooks stop. All checkouts fail. Existing users continue spending allowance. No revenue, no new signups. Operational alert. + +### TC-BILLING-PAY-018 — Renewal invoice for $0 (trial / coupon) + +Edge: a coupon or trial extension makes the renewal invoice zero. +**Expected:** Subscription stays active. Allowance is granted normally. No division-by-zero or other arithmetic surprise. + +--- + +## Fraud and abuse vectors + +### TC-BILLING-PAY-019 — Stolen card subscribes to Pro and burns AI + +Bad actor uses a stolen card to buy Pro, makes ~$2 of image generations, then disputes. +**Expected:** Stripe Radar may catch some. If not: the chargeback flips status, AI blocks. We've already paid the provider for those generations. +**Niche:** Per-org loss bounded by the period's allowance. Across many such actors, loss adds up. Mitigations: Stripe Radar rules, 3DS in EU. + +### TC-BILLING-PAY-020 — Disposable email mass signup for the Free pool + +Attacker signs up 1000 free orgs with disposable emails to harvest the free $0.50 monthly each. +**Expected:** 1000 × $0.50 = $500/month gift. +**Niche:** Mitigations: email verification, captcha at signup, signup-rate limiting, blocklist of disposable email domains. Document which are in place. + +### TC-BILLING-PAY-021 — Delete-and-recreate org for fresh allowance + +User burns through Free allowance, deletes org, creates a new org from the same account, gets a fresh allowance. +**Expected:** Today's design allows it (no per-user lifetime limit). Loss = unbounded. +**Niche:** Mitigation: per-user (not per-org) free quota cap, OR rate-limit org creation per user. Document policy. + +### TC-BILLING-PAY-022 — Same user pays for Pro on multiple orgs + +Heavy user creates 5 orgs, each on Pro, gets 5× the allowance. +**Expected:** Not fraud — they paid for it. Pattern suggests they should be on a Team plan instead. No action required. + +### TC-BILLING-PAY-023 — Topup + refund + already-spent (future) + +User funds a $25 topup, spends $2 of it, then disputes the topup. Stripe refunds. +**Expected:** Topup balance drops by $25 → goes negative. We've already paid the provider for the spent portion. Loss = $2. +**Niche:** Mitigations: hold-period on topups before usable, OR enforce non-negative balance. + +### TC-BILLING-PAY-024 — Past-due user with unspent topup disputes the topup + +User has topup balance from before going past_due. Disputes the topup. +**Expected:** Stripe reverses; balance drops to zero or negative. Bounded loss = topup amount. + +### TC-BILLING-PAY-025 — Support social-engineering for free credit + +Attacker convinces support to issue a manual credit grant. Spends it. +**Expected:** Mitigations: multi-person approval for grants, audit log review, capped grant size per ticket. + +### TC-BILLING-PAY-026 — Coordinated multi-account: shared API token + +10 users each on Free, sharing access via a backend proxy. Effective shared pool = $5. +**Expected:** Each account stays within its own allowance. Total loss = 10 × Free monthly = $5/month, treated as CAC. + +### TC-BILLING-PAY-027 — Race the gate: many concurrent calls + +Attacker scripts 100 concurrent AI calls when 100 mills remain. +**Expected:** All pre-flights pass concurrently. All record. Used = 100 + 99 × call_cost. +**Niche:** Worst case overshoot = (concurrent_count − 1) × max_per_call_cost per period. Document and accept, OR serialize the gate (slow). + +### TC-BILLING-PAY-028 — Webhook spoofing + +Attacker sends a forged "payment succeeded" webhook to our endpoint. +**Expected:** Stripe signature verification rejects it before any state changes. + +### TC-BILLING-PAY-029 — Replay an old subscription event + +Attacker captures a real subscription-create event and replays it. +**Expected:** The event id is recognized as already processed; the replay does nothing. + +### TC-BILLING-PAY-030 — Topup amount injection via metadata + +Attacker submits a checkout with an inflated amount in metadata while paying only the minimum. +**Expected:** The amount granted comes from the actual payment total, NOT from arbitrary metadata. +**Niche:** This is a real vulnerability vector — verify the amount source is the trustworthy field, not metadata. + +### TC-BILLING-PAY-031 — Org takeover via member-promotion bug + +A member becomes owner, removes the original owner, and changes payment / cancels for refund. +**Expected:** Out of scope for billing (auth/authorization), but the billing layer must accept ownership changes cleanly. + +### TC-BILLING-PAY-032 — Pre-flight passes, attacker rapidly invokes thousands of calls + +1000 calls in 100ms; all see the same remaining; all proceed. +**Expected:** All recorded. Used = (start) + 999 × call_cost. Loss bounded by max parallel × max cost. +**Niche:** Same as the concurrency race, taken to extreme. Mitigation: per-IP rate limit on the AI endpoint as a separate rail. + +--- + +## Topups (future) and refund handling + +### TC-BILLING-PAY-033 — Topup checkout success funds Stripe Customer Balance + +User clicks "Top up $25". Stripe Checkout completes successfully. +**Expected (future):** Webhook funds the customer balance on Stripe and refreshes the local cache within seconds. + +### TC-BILLING-PAY-034 — Topup amount below minimum rejected + +User tries to topup below the minimum. +**Expected:** App refuses at checkout creation, before any payment is collected. + +### TC-BILLING-PAY-035 — Topup amount above maximum rejected + +User tries to exceed the maximum. +**Expected:** Refused. + +### TC-BILLING-PAY-036 — Topup user pays processing fee on top + +User selects $25 of credit. +**Expected:** Card charged the topup amount plus the card-processor fee. Customer Balance receives the full topup amount. +**Niche:** UI preview must match the server charge to the cent. + +### TC-BILLING-PAY-037 — 3DS challenge timeout on topup + +User initiated topup; 3DS prompt times out. +**Expected:** Stripe marks payment incomplete. No balance funded. User sees "didn't complete" with a retry option. + +### TC-BILLING-PAY-038 — Topup during past-due + +Pro user is past_due. They top up to keep using AI. +**Expected (future):** Topup succeeds. Plan allowance stays zero; topup balance is spendable. The gate falls through: "no allowance → topup balance → allow." +**Niche:** Critical for the "card declined but I still need to use AI right now" UX. + +### TC-BILLING-PAY-039 — Topup balance never expires + +User tops up, uses some, cancels subscription, returns 6 months later. +**Expected (future):** Balance remains. + +### TC-BILLING-PAY-040 — Subscription refund on cancellation + +User cancels Pro mid-period and asks for prorated refund (out-of-policy normally). +**Expected:** Per ops decision, Stripe issues the refund. Subscription status itself is unaffected unless ops also cancels it. + +### TC-BILLING-PAY-041 — Topup refund — partially used + +User topped up, used some of it, then asks for refund. +**Expected (future):** Refund only the unused portion. Document policy: do we ever refund the used portion? + +### TC-BILLING-PAY-042 — Topup refund — fully used + +User topped up, used all of it, then disputes. +**Expected (future):** Refund processes via Stripe. We've already paid the provider. Loss = topup amount. +**Niche:** Same vector as the "disputed topup" cases. Mitigation: hold-period before usable. + +### TC-BILLING-PAY-043 — Manual ops grant + +Ops issues a manual goodwill credit after a P1 incident. +**Expected:** The customer's available amount goes up. The grant is logged in the audit trail. + +### TC-BILLING-PAY-044 — Manual ops grant during canceled state + +User has canceled, but ops wants to give them a final goodwill credit anyway. +**Expected:** Document the policy — either provision a temporary subscription or refuse the grant. Don't leave canceled-with-credit in an undefined state. diff --git a/test/billing-quota-and-ai.md b/test/billing-quota-and-ai.md new file mode 100644 index 0000000000..bde6adac8f --- /dev/null +++ b/test/billing-quota-and-ai.md @@ -0,0 +1,311 @@ +--- +id: TC-BILLING-AI-000 +title: Quota tracking, periods, AI gate, and usage recording +module: billing +area: ai +tags: [quota, period, ai, gate, idempotency, race, provider, cost] +status: untested +severity: critical +date: 2026-05-05 +updated: 2026-05-05 +automatable: partial +covered_by: [] +--- + +## Behavior + +Every plan includes a monthly AI allowance. Free is org-wide; Pro and Team scale per seat. Periods follow the Stripe billing cycle for paid plans and the calendar month for Free. + +Each AI request goes through a pre-flight check ("does this fit in the remaining allowance?"), then the provider, then a usage record. The cost-passthrough integrity (we charge what the provider charges us) and money-loss bounds depend on this sequence behaving correctly under concurrency, replays, and provider weirdness. + +--- + +## Period boundaries — math, time, edges + +### TC-BILLING-AI-001 — Call exactly at the period boundary + +A user's billing period ends at exactly midnight UTC on the 1st. They make a call at exactly that timestamp. +**Expected:** The system attributes the call deterministically to one period or the other — never both, never neither. + +### TC-BILLING-AI-002 — Call straddles a period boundary (long-running) + +A long predict-time call starts at 23:59:50 UTC and finishes at 00:00:05 UTC, crossing midnight. +**Expected:** The cost is recorded against whichever period the call completes in. Recorded once, not split. + +### TC-BILLING-AI-003 — Daylight Savings transitions + +Periods are tracked in UTC. +**Expected:** No DST conversions in the gate path. User-facing display may convert; the underlying period math does not. + +### TC-BILLING-AI-004 — Org created at the exact month boundary + +Free user signs up at exactly midnight UTC on the 1st. First call happens 1ms later. +**Expected:** They get the new month's allowance, not the previous month's. + +### TC-BILLING-AI-005 — Free user with abandoned org returns 6 months later + +User signed up in December, didn't use AI, returns in June. +**Expected:** No accumulated allowances. June starts fresh. No backfill needed for the missing months. + +### TC-BILLING-AI-006 — Allowance integer overflow safety + +Hypothetical org with 100,000 seats on Team would compute an allowance large enough to exceed a 32-bit signed integer. +**Expected:** Either the seat count is bounded at ingestion, or the allowance representation is wide enough not to overflow. First call from such an org must not silently wrap. + +### TC-BILLING-AI-007 — Past_due Pro user has zero allowance + +Pro user goes past_due. +**Expected:** Every AI request blocks. Audit log shows the blocks. No infinite-retry loop. + +### TC-BILLING-AI-008 — Used equals allowance, requested = 0 + +Idle browser polls the gate; used equals allowance; requested = 0. +**Expected:** Allowed. Don't spuriously block when nothing is requested. + +### TC-BILLING-AI-009 — Used equals allowance, requested = 1 mill + +Same setup, requested = 1 mill. +**Expected:** Blocked. + +### TC-BILLING-AI-010 — Single huge call larger than the entire allowance + +Pro user, 10000-mill allowance, single call estimated to cost 15000 mills. +**Expected:** Pre-flight refuses up front. User is told to upgrade or wait. + +### TC-BILLING-AI-011 — Concurrent calls both pass pre-flight, both record, total goes over + +Two browser tabs hit the gate at the same instant when 100 mills remain. Each call costs 80 mills. Both pre-flight checks see "100 remaining" and proceed. +**Expected:** Both calls succeed. Final used exceeds allowance by 60 mills (~$0.06). +**Niche:** Documented bound on concurrency loss: at most (concurrent_clients − 1) × max_per_call_cost per period. Mitigation would require serializing the gate, which costs latency. + +### TC-BILLING-AI-012 — Period rolled at Stripe but webhook arrives late + +The Stripe billing cycle advances at midnight; the local view is still on the old period at 02:00. +**Expected:** Reconciliation catches the drift within 24h. Documented detection window. +**Niche:** Real failure mode — Stripe may advance internally before firing a webhook. + +### TC-BILLING-AI-013 — Subscription period bounds are missing (data corruption) + +A bug or edge leaves the local period bounds null. +**Expected:** The gate falls back to calendar-month bounds. Users continue to function; no crash. + +### TC-BILLING-AI-014 — Same period, two usage rollup rows (data corruption / race) + +A bug attempts to create two usage-rollup rows for the same (org, period). +**Expected:** The system prevents this at the storage layer. Test the guard, not just the app code. + +### TC-BILLING-AI-015 — Ops manually adjusts a usage rollup + +Support adjusts a customer's used amount to honor a goodwill credit. +**Expected:** Allowed via privileged tooling. The adjustment is logged in the audit trail. + +### TC-BILLING-AI-016 — Free org with many members shares one pool + +3-member Free org. All 3 members hit AI in the same hour. +**Expected:** They share the single Free monthly pool. First-come, first-serve. Once exhausted, all members are blocked. +**Niche:** No per-member fairness in v1. Document as known. + +### TC-BILLING-AI-017 — Mid-period seat count fluctuates + +Pro 3 seats → owner adds 2 → 5 seats → removes 1 → 4 seats. All in one period. +**Expected:** The current period's allowance climbs as seats are added and STAYS at the high-water mark. Removing a seat does not shrink the current period's allowance. + +### TC-BILLING-AI-018 — Subscription period extends mid-cycle (annual switch) + +Pro monthly user switches to Pro annual. Stripe extends the cycle on the same subscription from monthly to annual. +**Expected:** Open gap — the annual cycle would change the period bounds. Annual needs separate monthly allowance bookkeeping. Document until annual is implemented. + +### TC-BILLING-AI-019 — Plan's allowance configuration is missing + +Catalogue seed bug: the row for a plan is missing. +**Expected:** Allowance defaults to zero — every call blocks, no crash. Default-deny on missing config. + +### TC-BILLING-AI-020 — Plan with explicitly zero AI included + +A configured plan that includes zero AI mills. +**Expected:** Every call blocks. (Possible "no AI included" tier in the future.) + +### TC-BILLING-AI-021 — Used amount overflow on pathological abuse + +A user manages to record over $2M of usage in one period. +**Expected:** Storage handles the magnitude without overflow. Math involving allowance minus used does not underflow into nonsense. + +### TC-BILLING-AI-022 — First-ever call from a fresh org races itself + +Two AI calls hit a brand-new org simultaneously. Both try to lazily create the period rollup. +**Expected:** Exactly one rollup row exists at the end. Both calls succeed. + +### TC-BILLING-AI-023 — Period boundary off by 1 second across services + +Application clock vs database clock differ. App pre-flights "still in period X"; database inserts under period X+1. +**Expected:** Database time is authoritative. Don't trust client clocks. + +### TC-BILLING-AI-024 — Seat count = 0 on a paid plan + +A bug sets the subscription seat count to zero. +**Expected:** The system refuses. Paid subscriptions always have at least 1 seat. + +### TC-BILLING-AI-025 — Org with a long history of usage rollups + +2 years of monthly rollups per org × 1M orgs = 24M rows. +**Expected:** Reads remain fast (indexed by org and period). Cleanup policy deferred; document the projected growth so ops know when to add cold-storage. + +--- + +## AI gate and usage recording + +### TC-BILLING-AI-026 — Happy path + +User has plenty of allowance. Pre-flight passes. Provider call succeeds. Usage is recorded. Allowance shrinks accordingly. + +### TC-BILLING-AI-027 — Pre-flight passes, provider fails, no recording + +Provider returns an error. +**Expected:** No usage record. Allowance unchanged. User sees the error. **No quota burned for a failed call.** + +### TC-BILLING-AI-028 — Provider succeeds, but cost computation throws + +Provider returned a malformed response that breaks our cost calculator. +**Expected:** Either record with cost=0 and flag for ops, or refuse to record and flag. **Don't crash and silently lose the usage.** + +### TC-BILLING-AI-029 — Provider succeeds, but our recording fails + +Network blip between the provider response and our database write. +**Expected:** A reconciliation job (daily) detects the orphan provider request and records it retroactively. Bounded loss until reconciliation = the per-call cost. + +### TC-BILLING-AI-030 — Same provider request id submitted twice (network retry) + +Provider's webhook delivers completion twice with the same id. +**Expected:** The second write detects the duplicate and does nothing. No double-charge. + +### TC-BILLING-AI-031 — Provider request id collision across distinct calls (UB) + +Hypothetical: the provider's id space collides between two genuinely different requests. +**Expected:** Documented as undefined behavior — the second request would be silently swallowed as a duplicate. In practice, all current providers use globally unique ids. + +### TC-BILLING-AI-032 — User aborts a streaming call mid-stream + +User clicks Cancel partway through a streaming text response. +**Expected:** The provider may still bill us for partial output. We record what we got. Cost reflects the partial response. +**Niche:** Verify the abort handler still records, e.g. from a finally block. + +### TC-BILLING-AI-033 — Provider returns a 0-cost call + +Some token models return cached prompts at deeply discounted rates; total comes out to 0. +**Expected:** Recorded with cost = 0. The system accepts 0 as a valid cost. + +### TC-BILLING-AI-034 — Provider returns a negative cost (UB) + +Bug in the cost calculator returns a negative number. +**Expected:** Refused at the storage layer. The faulty record is not persisted; ops is notified. + +### TC-BILLING-AI-035 — Local-dev superuser bypass + +Developer flag is set. AI call goes through. +**Expected:** Pre-flight is skipped. Usage is still logged for audit, but it does not count against allowance. +**Niche:** Two flags must both be set (non-production AND superuser env). Test the AND. + +### TC-BILLING-AI-036 — Superuser flag accidentally set in production + +Bug: developer env var leaks to prod. +**Expected:** Bypass is disabled because the production check fails. The gate enforces normally. + +### TC-BILLING-AI-037 — Disabled model attempted + +A model is marked disabled in the catalogue. App tries to call it. +**Expected:** The gate refuses with "model not available". Provider is not called. + +### TC-BILLING-AI-038 — Model not in catalogue at all + +App calls a model id we've never seen. +**Expected:** Refused. No fallback to a guessed cost. Default-deny on unknown. + +### TC-BILLING-AI-039 — Model price changed mid-period + +Ops updates a model's per-token rate mid-month. +**Expected:** Calls before the change retain the old rate in their forensic snapshot. Calls after use the new rate. Audit replay can recompute either accurately. +**Niche:** The pricing snapshot is captured at call time, not read back from the live catalogue. + +### TC-BILLING-AI-040 — Cost-card audit detects drift + +Weekly job compares recorded cost vs provider-reported cost per model. Drift > 5%. +**Expected:** Alert fires. Ops investigates. Audit reads the provider's reported cost as ground truth. + +### TC-BILLING-AI-041 — Long-running predict-time call (5 minutes) + +Replicate prediction takes 300s. +**Expected:** Cost is computed from the actual predict time and recorded once on completion. +**Niche:** Pre-flight at request time can't know the cost yet. Acceptable as long as the post-call recording is honest. + +### TC-BILLING-AI-042 — Reasoning tokens (o1/o3-style models) + +A call returns 5000 reasoning tokens. +**Expected:** The recorded cost includes the reasoning portion at its specific rate. The reasoning unit count is preserved for audit. + +### TC-BILLING-AI-043 — Cached input tokens (prompt caching) + +User repeats a long system prompt; provider reports 8000 cached input tokens. +**Expected:** Cached portion is charged at the cached-input rate (typically a discount). The snapshot captures which rate applied. + +### TC-BILLING-AI-044 — Token-billed model returns no token counts + +Provider edge: token-billed call response lacks the usage metadata. +**Expected:** Either fall back to a custom-strategy estimator or refuse and flag. **Don't insert with null counts and zero cost — that hides drift.** + +### TC-BILLING-AI-045 — Provider says success but returns no output + +Replicate prediction succeeded with empty output. +**Expected:** Treat as failure if output is required. Don't bill the user. Record audit trail for forensics. + +### TC-BILLING-AI-046 — Caller has no user identity (system / cron) + +A scheduled job runs AI on behalf of an org with no user attached. +**Expected:** Usage is recorded with no actor; forensics show "system." This is allowed. + +### TC-BILLING-AI-047 — Gate latency under load + +1000 concurrent gate calls. +**Expected:** p99 stays under 20ms. The hot path is one indexed read plus one write — no chained API calls, no per-call Stripe round-trip. + +### TC-BILLING-AI-048 — User switches active org mid-call + +User starts a call against org A; before completion they switch to org B in another tab. +**Expected:** The call is attributed to org A (org context captured at request time, not at completion). + +### TC-BILLING-AI-049 — Streaming text call straddles a period rollover + +User starts a streaming response in the middle of period rollover. +**Expected:** The call attributes to the period it started in (or completed in — pick one and document). The new period starts fresh. + +### TC-BILLING-AI-050 — Provider invoice arrives, exceeds our recorded cost + +The provider's monthly invoice shows we owe $1000. Our internal recorded total says $950. +**Expected:** Cost-card audit alerts on the $50 drift. +**Niche:** Canonical "we under-billed" detection. + +### TC-BILLING-AI-051 — Provider invoice arrives, less than our recorded cost + +Inverse: provider says $900, we recorded $950. +**Expected:** Audit alerts (we over-billed users). Ops investigates and may issue refunds. +**Niche:** Just as important — billing more than we paid breaks the at-cost promise. + +### TC-BILLING-AI-052 — Custom-strategy model + +A model whose pricing is too custom for the catalogue's standard strategies. App computes cost itself. +**Expected:** Catalogue check passes; cost comes from the application; the snapshot stores enough to audit the computation later. + +### TC-BILLING-AI-053 — Future high-cost model (e.g. video generation) + +Hypothetical $0.50 per call. Pro user has $0.06 remaining. +**Expected:** If the cost is known up front, pre-flight refuses. If only known after the call, the user goes a few cents over. +**Niche:** Bound: max single-call overrun = max known per-call cost. UI should estimate and refuse client-side too. + +### TC-BILLING-AI-054 — Two providers happen to share a request id + +Provider A returns id "abc123"; provider B independently returns id "abc123". +**Expected:** Treated as distinct events because the uniqueness key includes which provider it came from. + +### TC-BILLING-AI-055 — Spoofed provider webhook + +Attacker forges a provider callback claiming a free $0 successful call. +**Expected:** Provider webhooks are signature-verified before any meter update is triggered. diff --git a/test/billing-subscription-and-stripe.md b/test/billing-subscription-and-stripe.md new file mode 100644 index 0000000000..3a2a73a568 --- /dev/null +++ b/test/billing-subscription-and-stripe.md @@ -0,0 +1,354 @@ +--- +id: TC-BILLING-SUB-000 +title: Subscription lifecycle, seats, and Stripe consistency +module: billing +area: subscription +tags: [stripe, subscription, seats, webhook, consistency, money-safety] +status: untested +severity: critical +date: 2026-05-05 +updated: 2026-05-05 +automatable: partial +covered_by: [] +--- + +## Behavior + +Everything that touches subscription state and the Stripe-side mirror: lifecycle transitions, seat counting, webhook ordering, and reconciliation. The two non-negotiable invariants: + +1. **Grida never loses money.** No path grants more usage than was paid for. +2. **No double-spending.** Cancelling and resubscribing in the same billing period must not yield two periods' worth of usage. + +--- + +## Lifecycle — create, upgrade, downgrade, cancel, resubscribe + +### TC-BILLING-SUB-001 — Free org provisioned on signup + +A user signs up. An organization is auto-created on the Free plan. No Stripe customer exists yet. +**Expected:** The new org has the Free monthly allowance available immediately. No Stripe charges, no Stripe customer record yet. + +### TC-BILLING-SUB-002 — Free → Pro upgrade mid-period preserves used + +Solo user on Free has used 60% of this month's allowance. They upgrade to Pro (1 seat). Stripe issues a prorated invoice. +**Expected:** The org's allowance jumps to the Pro level. Already-used amount stays counted. Remaining = Pro allowance minus what was already used. +**Niche:** The allowance must never shrink mid-period as a side-effect of an upgrade. + +### TC-BILLING-SUB-003 — Free → Pro upgrade resets the period to the Stripe billing cycle + +A Free org tracks the calendar month. After upgrading to Pro mid-month, the active period switches to the Stripe billing cycle for that subscription. +**Expected:** A new period begins immediately, aligned to the Stripe cycle. The prior calendar-month usage is closed for forensics. +**Niche:** The user effectively gets a fresh allowance because they entered a paid billing cycle. Confirm intentional vs. abuse: a user could plan upgrades to maximize overlap. + +### TC-BILLING-SUB-004 — Pro → Team upgrade mid-period via Customer Portal + +3-seat Pro org has used most of its allowance. Owner switches to Team via Customer Portal. +**Expected:** Same period boundaries continue. Allowance recomputes to the Team level for 3 seats. Already-used carries over. Remaining grows. +**Niche:** Plan switch on the same subscription does NOT start a new period. + +### TC-BILLING-SUB-005 — Team → Pro downgrade mid-period (used > new ceiling) + +5-seat Team org has used more than the Pro ceiling at 5 seats would allow. Owner downgrades to Pro mid-period. +**Expected:** Current period's allowance does NOT shrink. User can continue using up to the previous (higher) ceiling. Next period uses the lower Pro ceiling. + +### TC-BILLING-SUB-006 — Team → Pro downgrade mid-period (used < new ceiling) + +Same setup but used is well within the new Pro ceiling. +**Expected:** Current period's ceiling stays at the higher Team value (no clawback). Stripe's prorated CREDIT does not retroactively shrink the user's experience. + +### TC-BILLING-SUB-007 — Cancel-at-period-end keeps allowance until period end + +Pro user clicks Cancel in Customer Portal. Period ends 18 days later. +**Expected:** Full allowance remains spendable for the next 18 days. At period end, the org reverts to Free with a fresh calendar-month allowance. +**Niche:** The Free allowance is honored on the _next_ calendar month, not stacked on top of any leftover Pro allowance. + +### TC-BILLING-SUB-008 — Cancel and resubscribe same day with same plan + +User cancels Pro mid-period. Stripe issues a prorated refund. Same user resubscribes 2 hours later. +**Expected:** A new billing cycle starts. The new period gets a fresh allowance; the old period's usage does not carry forward. +**Niche:** Verify net cost vs net allowance is honest. Two prorated invoices funding two prorated allowances is fine; two prorated invoices funding two full allowances is the abuse case to look for. + +### TC-BILLING-SUB-009 — Cancel and resubscribe with different plan (Pro → Team) + +User cancels Pro 5 days into period, resubscribes to Team same day. +**Expected:** Old subscription marked canceled. New Team subscription begins now; allowance reflects the Team ceiling. + +### TC-BILLING-SUB-010 — Reactivate immediately after cancel-at-period-end + +User clicks Cancel-at-period-end. 1 hour later they reactivate in Customer Portal. +**Expected:** Cancellation is undone on the same subscription. No new subscription is created. Allowance unchanged. + +### TC-BILLING-SUB-011 — Subscription created but invoice not yet paid (incomplete) + +User checks out Pro. Stripe places the subscription in `incomplete` because the card requires 3DS. User abandons the challenge. +**Expected:** AI is blocked. After ~24h, Stripe transitions to `incomplete_expired`; AI remains blocked. +**Niche:** No allowance is granted on `subscription_create` alone — only after `invoice.payment_succeeded`. + +### TC-BILLING-SUB-012 — Annual subscription should refresh allowance monthly + +User upgrades to annual Pro for $192/yr. +**Expected:** Each calendar month the AI allowance refreshes. The user does NOT receive 12 months of allowance up front. +**Niche:** Open gap if period bounds are read straight from the Stripe annual cycle. Annual needs separate monthly bookkeeping. + +### TC-BILLING-SUB-013 — Subscription period shorter than calendar month (e.g. 28 days) + +February signup: subscription cycle is 28 days. Next cycle also 28 days. +**Expected:** Each cycle covers its actual span. Same monthly value across all months regardless of length. + +### TC-BILLING-SUB-014 — Subscription anniversary on Feb 29, leap year + +User subscribes on Feb 29, 2028. Next year's renewal: Feb 28 or Mar 1? +**Expected:** Stripe's behavior governs; verify our system accepts whichever Stripe chooses without error. + +### TC-BILLING-SUB-015 — Subscription paused + +User pauses subscription via Customer Portal. +**Expected:** AI access is blocked while paused. Resume restores access. +**Niche:** A paused user does NOT silently fall back to the Free allowance. + +### TC-BILLING-SUB-016 — Org with no active subscription at all (data corruption) + +A bug or race leaves an org without any subscription record. +**Expected:** The system gracefully treats the org as Free with the calendar-month allowance. The AI gate does not crash. + +### TC-BILLING-SUB-017 — Org deleted while subscription is active + +Owner deletes the org via the workspace UI while a paid subscription is running. +**Expected:** The org and its records are removed. **Stripe continues to charge** unless the application explicitly cancels the subscription first. +**Niche:** Document the dependency: app must cancel Stripe sub BEFORE deleting org. A delete that skips this leaves the user being billed indefinitely with no service. + +### TC-BILLING-SUB-018 — Subscription transferred between orgs (admin op) + +Ops manually moves a paid subscription from org A to org B (e.g. user changed legal entity). +**Expected:** Org A loses paid access; org B gains it. Historical usage stays attributed to the original org. + +### TC-BILLING-SUB-019 — Trial subscription (future feature) + +Spec the expected shape for when trials ship. +**Expected:** During a trial the user has the full plan allowance at no charge. If the card fails at trial end, status flips to past_due and AI is blocked. + +### TC-BILLING-SUB-020 — Two active subscriptions on one org should be impossible + +A bug attempts to create a second active subscription for the same org. +**Expected:** The system refuses (DB-level uniqueness or app-level guard). One active subscription per org, always. + +--- + +## Seats — quantity sync, drift, ownership + +### TC-BILLING-SUB-021 — Add member to Pro increases the allowance immediately + +3-seat Pro org. Admin invites Bob; Bob accepts. +**Expected:** Seat count becomes 4. Per-seat allowance multiplies up. Next AI call sees the new ceiling. + +### TC-BILLING-SUB-022 — Remove member shrinks future allowance, not current + +4-seat Pro org with most of its allowance already used. Admin removes Charlie. +**Expected:** Current period's allowance is preserved (already paid for). Next period reflects the lower seat count. Stripe issues a prorated credit on the next invoice. + +### TC-BILLING-SUB-023 — Add member to Free is a no-op for billing + +4-member Free org. Add 5th member. +**Expected:** Seat count stays effectively 1; the Free allowance is org-level and does not multiply by member count. + +### TC-BILLING-SUB-024 — Pending invitation does NOT count + +Admin invites Dave. Dave hasn't accepted. +**Expected:** Seat count unchanged. Bill unchanged. + +### TC-BILLING-SUB-025 — Owner is a billable seat + +Solo Pro user. Owner pays for their own seat. +**Expected:** No "owner-free" exemption. The owner's per-seat fee is charged. + +### TC-BILLING-SUB-026 — Last member removed from Pro org + +Solo Pro user removes themselves (or org gets transferred and the new owner removes the old). +**Expected:** Seat count is floored at 1. Subscription stays valid until explicitly canceled. +**Niche:** A 0-quantity Stripe sub would zero-bill; flooring at 1 keeps the sub charging. + +### TC-BILLING-SUB-027 — Rapid add/remove does not lose updates + +Admin adds 10 members in quick succession via API. +**Expected:** Final seat count = old count + 10. No lost increments. + +### TC-BILLING-SUB-028 — Add member while subscription is past_due + +3-seat Pro org went past_due. Admin (somehow allowed) adds a 4th member. +**Expected:** Seat count grows. AI stays blocked (past_due overrides). When the card recovers, the new larger allowance applies. +**Niche:** UX should warn the admin: adding a seat during past_due locks in a future bigger bill. + +### TC-BILLING-SUB-029 — Remove member from past_due Pro org + +**Expected:** Seat count decreases. AI still blocked. Future allowance reflects fewer seats. + +### TC-BILLING-SUB-030 — Removing the org owner leaves ownership stale + +Owner removes themselves from the member list. +**Expected:** Seat count decreases. The org's ownership pointer is now stale. +**Niche:** App should refuse "remove self if owner". Ownership must be transferred explicitly first. + +### TC-BILLING-SUB-031 — Local seat count drifts from Stripe seat count + +Webhook flap: our records show 4 seats, Stripe shows 3. +**Expected:** Reconciliation job (daily) detects and alerts. Document which side wins: the local view (we set it) or Stripe (the billing source). +**Niche:** Possible loss = 1 seat × per-seat × period until reconciled. Document the tolerance. + +### TC-BILLING-SUB-032 — Member add succeeds locally but fails to sync to Stripe + +Local trigger fires, audit recorded; subsequent Stripe API call fails. +**Expected:** Local seat count = 4, Stripe stays at 3. Reconciliation job re-pushes. Until then, user has more allowance than they paid for. + +### TC-BILLING-SUB-033 — Member belongs to multiple orgs + +Alice is in 2 Pro orgs. +**Expected:** Each org has its own subscription, seat count, and allowance. Independent. + +### TC-BILLING-SUB-034 — Service-account / bot member + +A bot user is added to a Pro org. +**Expected:** Today bots count as billable seats. Document for the future when seat tiers (e.g. viewer-free) are introduced. + +### TC-BILLING-SUB-035 — Member added before account record exists (race) + +A bug or migration creates a membership row before the org's billing account is provisioned. +**Expected:** The system handles it gracefully — no error, no missed seat sync once the account catches up. + +### TC-BILLING-SUB-036 — Member removed via cascade (org deleted) + +Org deleted. All members removed in cascade. +**Expected:** Cascade unwinds without errors even though the parent subscription is also being deleted. + +### TC-BILLING-SUB-037 — Same user double-added (race) + +Two simultaneous "add member" calls for the same (org, user). +**Expected:** Only one membership row is created. Seat count increases by 1, not 2. + +--- + +## Stripe consistency — webhooks, ordering, reconciliation + +### TC-BILLING-SUB-038 — Webhook delivered out of order: updated before created + +`customer.subscription.updated` arrives before `.created`. +**Expected:** The first handler creates the local record; the second event no-ops or updates the same record. Final state is correct regardless of order. + +### TC-BILLING-SUB-039 — Webhook delivered duplicately + +Stripe delivers the same event twice (rare but documented). +**Expected:** The first delivery handles the event. The second delivery detects the replay and does nothing. + +### TC-BILLING-SUB-040 — Webhook handler crashes mid-handling + +Mid-handler exception rolls back the work. +**Expected:** A separate forensic record is written so we can see why it failed. Stripe retries; the handler runs again on retry. No silent loss. + +### TC-BILLING-SUB-041 — Handler succeeds but receiver crashes before responding 200 + +Side-effects committed; the HTTP response never reaches Stripe. +**Expected:** Stripe retries. The replay detects "already handled" and returns success. No double-handling. + +### TC-BILLING-SUB-042 — Local seat count differs from Stripe seat count (drift) + +Reconciliation discovers Stripe says 5, we say 4. +**Expected:** Alert fires. Document the canonical direction (trust local, push to Stripe — or trust Stripe, fix locally). + +### TC-BILLING-SUB-043 — Stripe shows canceled, we still show active + +Webhook missed. +**Expected:** Reconciliation pulls Stripe state daily, detects cancellation, updates locally. AI access blocks within 24h. + +### TC-BILLING-SUB-044 — We show canceled, Stripe still shows active + +Inverse. User has paid but is being denied. +**Expected:** Reconciliation un-cancels locally OR cancels on Stripe per policy. This direction is the worse one for user trust — detect early. + +### TC-BILLING-SUB-045 — Stripe customer ID drift + +Our records link the org to one Stripe customer; a webhook arrives claiming the same org via a different customer. +**Expected:** Refuse or alert. Don't silently overwrite the customer link. + +### TC-BILLING-SUB-046 — Multiple Stripe customers per org (data corruption) + +**Expected:** The system rejects a second customer attachment to the same org. + +### TC-BILLING-SUB-047 — Stripe webhook secret rotated, old secret still in env + +**Expected:** Webhook signature verification fails; we return 400. Stripe retries until it gives up. Ops must update the env. Document the runbook. + +### TC-BILLING-SUB-048 — Stripe API rate limit hit during a checkout burst + +Many new signups concurrently exceed Stripe's create-customer rate limit. +**Expected:** App catches and either retries with backoff or surfaces "try again" UX. No partial state. + +### TC-BILLING-SUB-049 — Webhook delivered after a 6-hour delay + +Stripe queues delivery because our endpoint was unreachable. +**Expected:** Handler still works (idempotent). State catches up. +**Niche:** Bounded loss = 6h × user's call rate during the window where status was inconsistent. + +### TC-BILLING-SUB-050 — Two webhooks for the same logical event arrive concurrently + +Possible after a Stripe region failover. +**Expected:** Only one handler runs to completion; the other no-ops via deduplication. + +### TC-BILLING-SUB-051 — Cancellation expressed two ways at once + +Customer Portal cancels via the newer cancel-at timestamp; the webhook payload has both the legacy boolean and the timestamp. +**Expected:** The system normalizes both — if either implies "cancel at period end," treat the subscription as scheduled to cancel. + +## Same-plan interval upgrade (monthly ↔ annual) + +The pricing page and `docs/platform/billing.mdx` advertise a 20% annual discount on Pro and Team. As of v1, **annual is unprovisioned** end-to-end — the cases below capture both the eventual desired behavior and the silent failure modes if a user gets there through the Stripe Dashboard before the implementation lands. + +### TC-BILLING-SUB-052 — Pro monthly → Pro annual via Customer Portal + +Pro monthly user, mid-period (15 days into a 30-day cycle, 5 seats), hits the portal and selects the annual price. +**Expected:** Stripe issues an immediate prorated invoice — credit the unused 15 days of monthly ($5 × 5 seats × 15/30 = $50 credit) against the annual line ($192 × 5 = $960), net charge $910 today. `subscription.updated` fires with the new annual price and `current_period_end` ≈ 365 days out. Local subscription mirrors plan='pro', status='active', new period bounds. Receipt shows both lines. +**Open gap:** The portal config (`setup-stripe-test.ts` `setupPortal`) only lists monthly prices in `subscription_update.products[].prices`. A user cannot today initiate this from inside Grida's Customer Portal — they'd have to do it from the Stripe Dashboard. Treat this TC as "what we want once annual is wired." + +### TC-BILLING-SUB-053 — Annual price not in product_catalogue → silent fallback + +Admin manually creates a Pro-annual price in Stripe Dashboard (no `metadata.grida_billing_id`) and switches a customer to it. Webhook fires. +**Expected:** Catalogue lookup in `fn_billing_apply_stripe_event` fails to match the price; `v_plan` defaults to 'pro'. Subscription row updates with the new period bounds but stays `plan='pro'`. **No error logged, no audit signal.** This is a silent forensic gap until annual is properly catalogued. +**Niche:** `plan` is `text CHECK plan IN ('free','pro','team')`, so even encoding "pro_annual" would fail the constraint. Today's behavior is "lose the interval silently"; tomorrow's design decision is whether interval becomes its own column or `plan_id` widens to include intervals. + +### TC-BILLING-SUB-054 — Annual upgrade jumps current_period_end forward 11 months + +Pro monthly user (`current_period_end = today + 15 days`) switches to annual. The projector mirrors Stripe's new period, so `current_period_end` becomes ~today + 365 days in a single update. +**Expected:** Local view is correct from a billing-cycle standpoint. UI's "next renewal" date jumps by ~11 months — confirm this is shown clearly and isn't mistaken for a bug by the user. +**Money/quota gap:** AI allowance uses `current_period_*` directly (see TC-BILLING-AI-018). After this jump the user receives one allowance lump intended to cover 12 months, then no refresh until the annual renewal. Annual needs separate monthly bookkeeping before the AI side passes. + +### TC-BILLING-SUB-055 — Proration invoice declines on monthly → annual + +User selects annual in the portal. Stripe's `always_invoice` proration tries to collect $910 immediately; card declines. +**Expected:** Stripe's behavior is to apply the price change anyway and mark the new invoice unpaid → subscription enters `past_due`. Local mirror reflects `past_due` on the new (annual) period. User keeps Pro access through Smart Retries grace, same as TC-BILLING-PAY-002. If retries exhaust, transitions to `unpaid`/`canceled` per existing past-due policy. +**Niche:** The price change is NOT reverted on decline. The user is now on annual at `past_due`, not on monthly at `active`. UI must say "your interval was changed but the invoice is unpaid" rather than implying the upgrade was rolled back. + +### TC-BILLING-SUB-056 — Annual discount applies correctly on the new line + +Same setup as SUB-052; verify the math. Annual line should be $192/seat (20% off $240 sticker), not $240/seat with a separate discount line. +**Expected:** `unit_amount` on the annual price is $19,200 cents ($192). Proration is computed against that price directly. No separate "discount" line item, no coupon. +**Open gap:** Annual prices are not yet provisioned by `setup-stripe-test.ts`. When they are, the discount must be encoded inline in `unit_amount`; otherwise the docs claim ("20% off comes out of platform margin") drifts from the receipt. + +### TC-BILLING-SUB-057 — Annual → monthly downgrade mid-cycle + +Pro annual user, 60 days in (305 days remaining), switches back to monthly via the portal. +**Expected:** Stripe credits the unused annual portion (~$160 × 5 seats = $800) to the Customer Balance, charges the new monthly invoice from balance, and rolls future monthly invoices off it. `current_period_end` shrinks from ~305 days to ~30 days. Audit log records both the price change and the credit. +**Niche:** Customer Balance interaction is asymmetric with the upgrade case (which charges immediately). TC-BILLING-PAY-015 covers Customer Balance at cancellation, but not the mid-cycle case where it accumulates from a downgrade. Confirm we display the balance to the user. +**Policy question:** Do we even want to allow this? An annual customer paid for the discount; letting them flip back monthly mid-cycle and harvest the credit may be against intent. May warrant blocking via portal config rather than enabling. + +### TC-BILLING-SUB-058 — Self-service interval switch via the upgrade page (not portal) + +User on Pro monthly clicks an "annual" toggle on the in-app upgrade page (or pricing page) — separate flow from the Customer Portal. +**Expected:** A new Stripe Checkout session targeting the annual price, with proration applied to the existing subscription. On success, same end state as SUB-052. +**Open gap:** `startSubscribeCheckout` currently accepts only `plan: 'pro' | 'team'` — no `interval` parameter. The upgrade page (`upgrade/_view.tsx`) shows no annual toggle. This TC documents the missing surface, not current behavior. + +### TC-BILLING-SUB-059 — Concurrent checkout sessions can produce duplicate live Stripe subscriptions (known issue) + +Two `startSubscribeCheckout` calls fire for the same Free org before the first completion is projected (e.g. user opens Stripe Checkout in two tabs and pays in both, or two devices race past the local `getActivePaidSubscription` guard). Both sessions complete in Stripe. +**Current behavior (v1):** The second `customer.subscription.created` webhook hits the `subscription_one_active_per_org_idx` partial unique index and the projector rejects the second row. The second Stripe subscription continues to bill the customer with no local mirror — Grida sees one paid sub, Stripe has two. +**Why we accept this for v1:** The race requires deliberate parallel checkouts in two browser tabs/devices within the same minute (the idempotency-key bucket). Realistic occurrence is very low; risk is to **us**, not the customer (we'd refund the duplicate manually). Investing in a Stripe-side `subscriptions.list` pre-check or a DB advisory lock during checkout creation is deferred until we see real-world incidence. +**Detection (manual):** Stripe Dashboard → Customers → search org → if a paid customer has 2+ `active` subscriptions, that's this case. +**Recovery (manual):** Cancel the orphan Stripe subscription via the Stripe Dashboard and refund the prorated amount. The local row already reflects the surviving subscription. +**Tracking:** GRIDA-60 (multi-seat work) will introduce the durable-intent / outbox layer that closes this race. diff --git a/test/billing-ux-and-edge-cases.md b/test/billing-ux-and-edge-cases.md new file mode 100644 index 0000000000..87ec0ccdec --- /dev/null +++ b/test/billing-ux-and-edge-cases.md @@ -0,0 +1,203 @@ +--- +id: TC-BILLING-OPS-000 +title: User-facing UX, observability, and operational edge cases +module: billing +area: ops +tags: [ux, dashboard, observability, audit, edge-case, clock, schema, ops] +status: untested +severity: high +date: 2026-05-05 +updated: 2026-05-05 +automatable: partial +covered_by: [] +--- + +## Behavior + +The billing model is invisible if it works. These cases verify that users see clear, accurate information about their plan, allowance, and any blocking states — and that operators have the audit trail and tooling to respond. Plus the long tail of "what about this weird thing" production resilience cases. + +--- + +## User-facing UX + +### TC-BILLING-OPS-001 — Dashboard shows accurate remaining allowance + +User has used 30% of this month's allowance. The dashboard says so. +**Expected:** Numbers match the underlying counter exactly. Updates within 1 second of any new usage. + +### TC-BILLING-OPS-002 — Approaching the cap — soft warning + +Remaining drops below 20%. +**Expected:** A non-blocking notice appears: "You've used 80% of this month's AI." No alarm; no modal. + +### TC-BILLING-OPS-003 — Allowance exhausted — hard block UI + +Used reaches the cap. User clicks "Generate." +**Expected:** Inline message: "AI quota exhausted. Resets in N days. Upgrade to keep using." With an upgrade button. No top-up CTA in v1. + +### TC-BILLING-OPS-004 — Past-due banner + +Pro user goes past_due. +**Expected:** Persistent banner: "Your card was declined. Update payment to restore AI access." Click → Customer Portal. + +### TC-BILLING-OPS-005 — Cancel-at-period-end banner + +User cancels with X days remaining. +**Expected:** Banner: "Subscription canceled. AI access ends MMM DD." Reactivation button → Customer Portal. + +### TC-BILLING-OPS-006 — Plan switch confirmation shows allowance delta + +User clicks "Upgrade to Team." +**Expected:** Confirmation dialog clearly states the per-seat price change AND the new monthly AI allowance for this period. + +### TC-BILLING-OPS-007 — Multi-org user sees correct org context + +User belongs to 3 orgs and switches between them. +**Expected:** The shown allowance is for the active org. No leakage between orgs. + +### TC-BILLING-OPS-008 — Cross-org read is prevented + +A user attempts to read another org's allowance directly via the API. +**Expected:** Empty result. Membership-based access control. + +### TC-BILLING-OPS-009 — Owner can see usage event log + +Owner navigates to Billing → Usage History. +**Expected:** Per-call log: timestamp, model, kind, cost, who. Paginated. Default to last 30 days. + +### TC-BILLING-OPS-010 — Non-owner cannot see audit log + +A regular member queries the audit endpoint. +**Expected:** Forbidden or empty. + +### TC-BILLING-OPS-011 — Anonymous unauthenticated query + +No user session at all. +**Expected:** All billing views return empty. No data leakage. + +### TC-BILLING-OPS-012 — Long stretches of zero usage + +User on Pro doesn't use AI for 3 months. +**Expected:** Each month displays "0 used of allowance." No errors. No accumulation. + +### TC-BILLING-OPS-013 — Per-user breakdown (future) + +Owner asks: who in my team is using the most AI? +**Expected:** A view summing per-actor usage by period. The data is captured today; the report is a future UI feature. + +### TC-BILLING-OPS-014 — Per-model breakdown + +Owner asks: which models are eating my allowance? +**Expected:** Per-kind aggregates available without scanning every event. Per-model breakdown is a separate query if needed. + +### TC-BILLING-OPS-015 — Time-zone display + +User in UTC+9 sees billing period bounds. +**Expected:** Displayed in the user's local time. Underlying storage stays UTC. + +### TC-BILLING-OPS-016 — Currency display + +Internal accounting unit is mills; UI always shows USD. +**Expected:** No internal-unit terminology leaks into the UI. + +--- + +## Operational edge cases — clocks, schemas, infrastructure + +### TC-BILLING-OPS-017 — Database clock skew vs Stripe clock + +Stripe sends an event whose timestamp is 30s ahead of our database clock. +**Expected:** Stripe's timestamp is used for forensic display; our own state transitions use our own time. Don't compare Stripe timestamps to our `now()` for correctness decisions. + +### TC-BILLING-OPS-018 — App restart loses in-flight gate decisions + +Server crashes between pre-flight "allowed" and the provider call. +**Expected:** No provider call, no usage record, allowance unchanged. User retries; sees the same available allowance. + +### TC-BILLING-OPS-019 — App restart loses in-flight provider calls + +Server crashes after the provider call but before recording. +**Expected:** Reconciliation cron sees an orphan provider request and inserts the missing usage record retroactively. + +### TC-BILLING-OPS-020 — Database connection pool exhausted under load + +1000 concurrent gate calls; pool size 50. +**Expected:** Latency rises; some calls queue. Beyond a wait threshold, app returns 503. No data corruption. + +### TC-BILLING-OPS-021 — Schema migration runs while live traffic flows + +A migration adds a new column. +**Expected:** Online ALTER, no table rewrite. Live traffic unaffected. Test in staging first. + +### TC-BILLING-OPS-022 — Restore from backup mid-period + +DR scenario: restore yesterday's snapshot. +**Expected:** Today's usage records are lost. Allowance counters revert. Stripe is unchanged (separate system). Document the resync runbook. + +### TC-BILLING-OPS-023 — Stripe write succeeds but local write fails + +A code path commits to Stripe but our database write fails. +**Expected:** Reconciliation catches the drift (e.g. a Stripe subscription we don't track). Document the eventual-consistency tolerance. + +### TC-BILLING-OPS-024 — Multi-region read replicas + +Reads from a stale replica show outdated allowance. +**Expected:** The gate must read from the primary, not a replica. Document. + +### TC-BILLING-OPS-025 — Forensic snapshot size explosion + +A buggy logger writes massive snapshots on every event (10KB each). +**Expected:** No schema-level limit, but storage compresses. Alert on table-size growth velocity. +**Niche:** With 10M events/month × 10KB = 100GB/month if uncapped. Document and consider capping snapshot size. + +### TC-BILLING-OPS-026 — New plan added mid-period + +A new plan tier is introduced. Existing users are unaffected; users who upgrade to the new plan get its allowance immediately. +**Expected:** No retroactive changes to existing periods. + +### TC-BILLING-OPS-027 — Plan removed from catalogue + +Hypothetical: a plan is discontinued. +**Expected:** Existing subscriptions on that plan continue working. The plan's allowance config must remain available to the gate or every call from those orgs would block. +**Niche:** Never delete plan config rows; mark them as discontinued. Document. + +### TC-BILLING-OPS-028 — Seasonal pricing change + +Holiday promo: Pro at a discount for one month. +**Expected:** Document the migration approach — new plan rows, new Stripe prices, or both. Existing subscriptions stay on their original plan unless explicitly migrated. + +### TC-BILLING-OPS-029 — Stripe webhook source-IP whitelist drift + +Stripe adds new webhook source IPs; our firewall blocks them. +**Expected:** Out of scope for billing logic, but document: webhook delivery should not depend on IP filtering — signature verification is the real auth. + +### TC-BILLING-OPS-030 — Email delivery failure on past-due notification + +The email provider is down when we try to send "your card declined." +**Expected:** Email is queued and retried. The state change in our system does not depend on email succeeding. + +### TC-BILLING-OPS-031 — Idempotency key reuse across distinct calls + +App accidentally sends the same Stripe idempotency key for two genuinely different subscribe attempts. +**Expected:** Stripe returns the original response; the second attempt is silently a no-op. Ensure idempotency keys are per-attempt, not per-user. + +### TC-BILLING-OPS-032 — Cost-card audit job fails silently + +The job throws but no alerting is wired. +**Expected:** Job failure must page ops. Document monitoring expectations. + +### TC-BILLING-OPS-033 — Org exists but billing setup is incomplete + +A bug or migration race left an org without its billing account record. +**Expected:** First gate call lazily provisions or falls back to the Free allowance. User can still use AI. A nightly fix-up job inserts missing records. + +### TC-BILLING-OPS-034 — Webhook arrives for an org that no longer exists + +Stripe webhook for a customer whose org was hard-deleted. +**Expected:** Handler fails gracefully (recorded as an error). Ops resolves manually. + +### TC-BILLING-OPS-035 — Hot-org contention + +A celebrity org has thousands of users hammering AI simultaneously. The single allowance counter is a hotspot. +**Expected:** Updates serialize on the row. Latency may climb but no corruption. +**Niche:** Worst case the meter becomes a bottleneck. Future mitigation: shard the counter per-(org, hour) for very hot orgs.