Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fcb166c
feat(pricing): add billing guide, FAQ, and clean up features table
softmarshmallow May 4, 2026
31a0719
chore(lefthook): include mdx in oxfmt pre-commit glob and fmt billing…
softmarshmallow May 4, 2026
72b3354
docs(billing): add Team plan to billing guide and pricing FAQ
softmarshmallow May 4, 2026
12c597b
fix(auth): ensure redirect URI is resolved with request origin
softmarshmallow May 5, 2026
74c0765
test(billing): add 185 manual test cases for v1 billing system
softmarshmallow May 5, 2026
de98143
feat(supabase/billing): add grida_billing schema and drop legacy pric…
softmarshmallow May 6, 2026
2fc305b
feat(billing): add lib/billing seam, Stripe setup script, and import …
softmarshmallow May 6, 2026
4cc6702
feat(billing): add Stripe webhook receiver
softmarshmallow May 6, 2026
e9dc2bc
feat(billing): add billing settings page and upgrade flow
softmarshmallow May 6, 2026
453f06c
refactor(workspace): derive plan from billing source, drop display_plan
softmarshmallow May 6, 2026
56018f1
fix(pricing): default to monthly view
softmarshmallow May 6, 2026
2d1ff14
test(billing): add webhook E2E suite gated by BILLING_E2E=1
softmarshmallow May 6, 2026
28019ce
test(billing): add monthly→annual interval upgrade test cases
softmarshmallow May 6, 2026
397fb39
fix(billing/test): keep billing E2E and Playwright excluded from defa…
softmarshmallow May 6, 2026
9aeef5a
fix(billing): lazy-init Stripe client so Vercel build doesn't need th…
softmarshmallow May 6, 2026
e4e716a
refactor(billing): defer multi-seat to v1.x, ship single-seat v1
softmarshmallow May 6, 2026
0e13140
review(billing): address review feedback + UX polish
softmarshmallow May 7, 2026
6605957
review(billing): second-pass review fixes
softmarshmallow May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -232,3 +234,6 @@ __pycache__/
.claude/worktrees
.claude/scheduled_tasks.lock
CLAUDE.local.md

# Local-only tracking docs
/TODO.md
160 changes: 153 additions & 7 deletions database/database-generated.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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: {
Expand All @@ -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:
| {
Expand Down Expand Up @@ -4746,7 +4895,6 @@ export type Database = {
| "ar"
| "hi"
| "nl"
pricing_tier: "free" | "v0_pro" | "v0_team" | "v0_enterprise"
}
CompositeTypes: {
[_ in never]: never
Expand Down Expand Up @@ -5240,8 +5388,6 @@ export const Constants = {
"hi",
"nl",
],
pricing_tier: ["free", "v0_pro", "v0_team", "v0_enterprise"],
},
},
} as const

115 changes: 115 additions & 0 deletions docs/contributing/billing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Contributing to Grida | Billing

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add required Markdown frontmatter with format: md.

For .md docs files, include frontmatter and set format: md to opt out of MDX parsing.

As per coding guidelines, "For files that don't use JSX/MDX features, add format: md to frontmatter to opt out of MDX parsing entirely and prevent angle-bracket issues."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/contributing/billing.md` at line 1, The Markdown file with the heading
"Contributing to Grida | Billing" is missing YAML frontmatter; add a frontmatter
block at the top containing at minimum "format: md" (e.g., ---\nformat: md\n---)
so the document opts out of MDX parsing; ensure the frontmatter appears before
the existing "# Contributing to Grida | Billing" heading and contains no JSX/MDX
content.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Move this page under an actively maintained docs directory.

This file is introduced under docs/contributing/, but this repository’s docs maintenance scope is limited to docs/wg/** and docs/reference/**.

As per coding guidelines, "When writing documentation, the root ./docs directory is the source of truth, and only actively maintain docs/wg/** and docs/reference/** directories."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/contributing/billing.md` at line 1, The "Contributing to Grida |
Billing" page lives in an unmaintained docs location; move the page titled
"Contributing to Grida | Billing" into an actively maintained docs subdirectory
(such as the project’s wg or reference docs area), update any
sidebars/navigation or cross-links that referenced the old location, and ensure
frontmatter/metadata (title, permalink) if present reflects the new location so
build/navigation continue to work.


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) |
Loading
Loading