From fcb166cb00b581aa2c1573418c416167b1d18af9 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 4 May 2026 20:41:43 +0900 Subject: [PATCH 01/18] feat(pricing): add billing guide, FAQ, and clean up features table - Add user-facing billing doc at docs/platform/billing.mdx covering plans, AI credits, top-ups, $0.25 floor, per-seat Pro, and team credit pooling. - Add FAQ section to /pricing linking to the billing guide. - Remove legacy/dishonest features (Advanced Analytics, Google Sheets, Notion, Toss, Ticketing for Events, KakaoTalk, WhatsApp, Credits Rollover); drop Ticketing category and move Simulator under Forms. - Rename "Generated Image License" -> "Generated Media License"; gate "Buy extra credits" from Pro. - Reorder comparison table so Support is the last section. --- docs/platform/billing.mdx | 229 ++++++++++++++++++ .../(www)/(pricing)/pricing/_sections/faq.tsx | 84 +++++++ editor/app/(www)/(pricing)/pricing/page.tsx | 4 + editor/www/data/pricing.ts | 140 ++--------- .../www/pricing/pricing-comparison-table.tsx | 34 +-- 5 files changed, 337 insertions(+), 154 deletions(-) create mode 100644 docs/platform/billing.mdx create mode 100644 editor/app/(www)/(pricing)/pricing/_sections/faq.tsx diff --git a/docs/platform/billing.mdx b/docs/platform/billing.mdx new file mode 100644 index 0000000000..d6313f39b8 --- /dev/null +++ b/docs/platform/billing.mdx @@ -0,0 +1,229 @@ +--- +title: How Grida billing works +description: A plain-English guide to Grida's plans, AI credits, and billing. No legalese. +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 two plans today. + +| Plan | Price | Monthly AI credit | +| ---- | ----------------------- | ---------------------------------- | +| Free | $0 | $0.50 (shared by everyone in your org) | +| Pro | **$20 per user / month** | **$15 per user**, pooled across your team | + +Pro is the only paid tier right now. An annual plan is on the roadmap but not available yet. + +The Pro price breaks down like this, per user: $5 keeps the lights on (servers, storage, support), and the other $15 lands in your team's shared AI credit pool. **There is no separate "Team" plan — Pro is per-seat from day one.** A solo user is just a 1-seat Pro subscription. A 5-person team is a 5-seat Pro subscription that pays $100/month and gets $75 of pooled credit. + +> Heads up: AI credit pools at the **organization** level, not per individual. If your team has 3 Pro seats, the $45 pool is shared — anyone on the team can spend any of it. This matches how Cursor Teams, Linear, and most peer products work. + +## 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 $0.50 (Free) or $15 (Pro) 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 Pro 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 **$25.73** ($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 Pro-only. Free users have nothing to renew. + +Each month we try to charge your card for the $20 Pro renewal. If the charge fails (expired card, insufficient funds, anything), here's what happens: + +- **Pro pauses immediately.** Not in 24 hours, not after a 7-day grace period — the same minute the charge fails. +- **Your $15 monthly 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, Pro 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 Pro 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 Pro. + +## Working with a team + +If your organization has more than one person, Pro charges per seat — but everyone shares the same credit pool, the same way Cursor Teams or Linear handle it. + +A few things to know: + +- **Every member of a Pro org is a paid seat.** Each seat is $20/month and contributes $15 to the shared pool. The owner counts as a seat too. +- **Inviting someone to your org adds a seat.** Their cost is prorated for the rest of the current period — e.g., adding Bob halfway through the month adds about $10 to your next invoice. +- **Removing someone from your org removes their seat next period.** The current period's credit pool is unaffected (it was already paid for and minted at the start of the period). Stripe issues a small prorated credit for the unused portion of their seat. +- **Pending invitations don't count as seats.** A seat starts costing only when the person accepts and joins the org. +- **Free orgs can have multiple members.** They all share one $0.50 monthly credit pool — same total whether your org has 1 person or 5. If you need more credit, upgrade to Pro and seats scale automatically. +- **Only the org owner can manage seats and billing** today. Member-role permissions are coming. + +> Good to know: a "Team plan" as a separate product doesn't exist. Pro is per-seat from day one. You don't have to "upgrade to Team" to add a teammate — you just invite them, and the next invoice reflects the new seat count. + +## Cancel anytime + +You can cancel Pro from your account settings at any time, no questions asked. + +When you cancel: + +- You keep Pro 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 + +Three 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 (solo) + +Jordan is the only person in their org. Upgrading to Pro buys 1 seat — $20/month, $15 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 **$25.73** (the $25 top-up plus the processor fee). +- Jordan's balance becomes **$25.20**. + +Jordan keeps working. When the next month begins, $15 of fresh Pro credit lands on top of whatever top-up credit is still left. + +### Priya's team upgrades + +Priya runs a 4-person design team on Free. They share $0.50/month — clearly not enough for the kind of AI work they want to do. + +Priya upgrades the org to Pro from the billing settings. + +- The seat count is set automatically to **4** (everyone currently in the org). +- The first invoice is **$80** (4 × $20). +- The team's credit pool jumps to **$60/month** (4 × $15), shared across all 4 people. Anyone on the team can spend any of it. + +A week later, they invite a fifth person. + +- Bob accepts the invitation. Stripe charges a **prorated $15** for the rest of the period. +- The seat count is now 5. Next month's pool will be **$75** (5 × $15). + +A month later, someone leaves the team. Priya removes them. + +- The seat count drops to 4. Stripe issues a **prorated credit** of about $10 to the next invoice. +- The current month's $75 pool is unchanged (it was already paid for and minted at the start of the period). +- Next month's pool will be **$60**. + +### Sam's card fails + +Sam is 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 $15 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 $15 lands on schedule on Sam's normal renewal date. + +## Common questions + +**Do my unused monthly credits roll over?** +No. Monthly credit (Free or Pro) 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 Pro and come back later. + +**Can I get a refund?** +See the [refund policy](/support/refund-policy). The short version: we'll work with you on accidental top-ups and obvious billing errors. + +**What happens if I cancel Pro mid-month?** +You keep Pro 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 $5 base difference in the Pro plan, 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?** +Pro is per-seat: $20 per user per month. Every member of your org is a seat, including the owner. Adding a member adds a seat (prorated); removing one removes it. AI credit is shared across the team — a 5-seat Pro org has a $75/month pool that any member can spend from. There's no separate "Team plan" — Pro is the team product. + +**Is the credit really shared, or is each person locked to $15?** +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?** +Not yet. We'll add one once Pro has settled. + +**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. + +**Can I top up with crypto / wire / invoice?** +Not in v1. Card payments only for now. + +## Anything else? + +- [Refund policy](/support/refund-policy) +- [Terms and conditions](/support/terms-and-conditions) +- [Privacy policy](/support/privacy-policy) + +Still stuck? Email [support@grida.co](mailto:support@grida.co) and a real human will get back to you. diff --git a/editor/app/(www)/(pricing)/pricing/_sections/faq.tsx b/editor/app/(www)/(pricing)/pricing/_sections/faq.tsx new file mode 100644 index 0000000000..e97750e469 --- /dev/null +++ b/editor/app/(www)/(pricing)/pricing/_sections/faq.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import Link from "next/link"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; + +const faqs: { question: string; answer: React.ReactNode }[] = [ + { + question: "How do AI credits work?", + answer: ( + <> + Each plan includes a monthly AI credit — $0.50 on Free,{" "} + $15 per seat on Pro. AI features draw from this balance + at the model provider's cost; we never mark up AI usage. Unused + monthly credit resets at the start of the next billing period.{" "} + + Read the full guide + + . + + ), + }, + { + question: "Can I buy credit ahead of time?", + answer: + "Yes. You can top up at any time, in any amount from $5 to $1000. The amount you pick is exactly what lands in your balance — the card processor's fee is added on top of the charge and shown clearly at checkout. Top-up credit never expires, even if you cancel Pro and come back later.", + }, + { + question: "What happens when I run out of credit?", + answer: + "AI features pause until your monthly credit refreshes or you top up. Saving, editing, exporting — everything else in Grida keeps working. We never charge your card automatically to cover an AI call.", + }, + { + question: "Are AI prices marked up?", + answer: + "No. We charge you exactly what the model provider charges us. Our margin lives in the base plan ($5 of every $20 Pro plan), not in AI usage. When provider prices change, we update what we charge to match.", + }, + { + question: "What happens if I cancel?", + answer: + "You keep Pro 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 receiving $0.50 of credit each month. Any top-up credit you have stays in your account.", + }, + { + question: "How does pricing work for teams?", + answer: + "Pro is per-seat — $20 per user per month — and there is no separate 'Team plan.' A solo user is just a 1-seat Pro subscription. A 5-person team is 5 seats: $100/month with $75 of pooled AI credit that anyone on the team can spend. Adding a member is prorated; removing one credits the next invoice. The owner counts as a seat.", + }, +]; + +export default function PricingFAQ() { + return ( +
+

+ Frequently asked questions +

+ + {faqs.map((faq, index) => ( + + {faq.question} + + {faq.answer} + + + ))} + +

+ For the full breakdown — examples, edge cases, and policies — see the{" "} + + billing guide + + . +

+
+ ); +} diff --git a/editor/app/(www)/(pricing)/pricing/page.tsx b/editor/app/(www)/(pricing)/pricing/page.tsx index ff34c94c23..c31c985e63 100644 --- a/editor/app/(www)/(pricing)/pricing/page.tsx +++ b/editor/app/(www)/(pricing)/pricing/page.tsx @@ -2,6 +2,7 @@ import { Pricing } from "@/www/pricing/pricing"; import Header from "@/www/header"; import FooterWithCTA from "@/www/footer-with-cta"; import { Section } from "@/www/ui/section"; +import PricingFAQ from "./_sections/faq"; import type { Metadata } from "next"; export const metadata: Metadata = { @@ -17,6 +18,9 @@ export default function WWWPricingPage() {
+
+ +
diff --git a/editor/www/data/pricing.ts b/editor/www/data/pricing.ts index fdb34f2679..5239b4c344 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", @@ -66,21 +65,11 @@ export const pricing: Pricing = { }, usage_based: false, }, - { - title: "Credits Rollover", - plans: { - free: false, - pro: true, - team: true, - enterprise: true, - }, - usage_based: false, - }, { title: "Buy extra credits", plans: { free: false, - pro: false, + pro: true, team: true, enterprise: true, }, @@ -182,14 +171,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 +192,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 +220,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 +242,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 +255,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 +290,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 +306,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..50a8ebbd23 100644 --- a/editor/www/pricing/pricing-comparison-table.tsx +++ b/editor/www/pricing/pricing-comparison-table.tsx @@ -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" - />
From 31a0719f62d16d2a05bc58ae16a0c0870065a8c2 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 4 May 2026 21:22:27 +0900 Subject: [PATCH 02/18] chore(lefthook): include mdx in oxfmt pre-commit glob and fmt billing.mdx --- docs/platform/billing.mdx | 6 +++--- lefthook.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/platform/billing.mdx b/docs/platform/billing.mdx index d6313f39b8..43dcbc6932 100644 --- a/docs/platform/billing.mdx +++ b/docs/platform/billing.mdx @@ -15,9 +15,9 @@ This page explains how Grida charges you, how AI credits work, and what to expec Grida has two plans today. -| Plan | Price | Monthly AI credit | -| ---- | ----------------------- | ---------------------------------- | -| Free | $0 | $0.50 (shared by everyone in your org) | +| Plan | Price | Monthly AI credit | +| ---- | ------------------------ | ----------------------------------------- | +| Free | $0 | $0.50 (shared by everyone in your org) | | Pro | **$20 per user / month** | **$15 per user**, pooled across your team | Pro is the only paid tier right now. An annual plan is on the roadmap but not available yet. 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 From 72b335474c56d2b0973d7f80ee51777bc43e247e Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 4 May 2026 21:52:31 +0900 Subject: [PATCH 03/18] docs(billing): add Team plan to billing guide and pricing FAQ Bring the billing guide and pricing FAQ in line with the live plans data: four plans (Free / Pro / Team / Enterprise), Pro and Team both per-seat with their own per-seat AI credit allotment ($10 and $35), 20% annual discount available on both. Removes the stale "no separate Team plan" claim and updates examples (Priya's team upgrades to Team, Sam's Pro renewal shows $10 monthly credit). --- docs/platform/billing.mdx | 108 ++++++++++-------- .../(www)/(pricing)/pricing/_sections/faq.tsx | 20 ++-- 2 files changed, 73 insertions(+), 55 deletions(-) diff --git a/docs/platform/billing.mdx b/docs/platform/billing.mdx index 43dcbc6932..2f1b273d4e 100644 --- a/docs/platform/billing.mdx +++ b/docs/platform/billing.mdx @@ -13,18 +13,20 @@ This page explains how Grida charges you, how AI credits work, and what to expec ## Plans -Grida has two plans today. +Grida has four plans. -| Plan | Price | Monthly AI credit | -| ---- | ------------------------ | ----------------------------------------- | -| Free | $0 | $0.50 (shared by everyone in your org) | -| Pro | **$20 per user / month** | **$15 per user**, pooled across your team | +| Plan | Price | Monthly AI credit (per seat) | +| ---------- | -------------------------- | --------------------------------------- | +| Free | $0 | $0.50 (shared by everyone in your org) | +| Pro | **$20 per seat / month** | **$10 per seat**, pooled across the org | +| Team | **$60 per seat / month** | **$35 per seat**, pooled across the org | +| Enterprise | Custom (from $599 / month) | Custom | -Pro is the only paid tier right now. An annual plan is on the roadmap but not available yet. +**Pro and Team are both seat-based.** Every member of your organization is a paid seat, including the owner. Solo users are just a 1-seat subscription; a 5-person team is 5 seats. AI credit pools at the **organization** level, not per individual — anyone on the team can spend any of it. -The Pro price breaks down like this, per user: $5 keeps the lights on (servers, storage, support), and the other $15 lands in your team's shared AI credit pool. **There is no separate "Team" plan — Pro is per-seat from day one.** A solo user is just a 1-seat Pro subscription. A 5-person team is a 5-seat Pro subscription that pays $100/month and gets $75 of pooled credit. +The difference between Pro and Team is what each seat carries beyond the base price: Team gets a bigger AI credit allotment, more storage, more monthly active users on published projects, and chat support. Both plans charge per seat and prorate the same way; you can switch between them whenever your team's needs change. -> Heads up: AI credit pools at the **organization** level, not per individual. If your team has 3 Pro seats, the $45 pool is shared — anyone on the team can spend any of it. This matches how Cursor Teams, Linear, and most peer products work. +Annual billing on Pro and Team is available at a **20% discount**. The annual discount comes out of platform margin — your monthly AI credit is the same on monthly or annual. ## How AI credits work @@ -33,8 +35,8 @@ Every AI feature in Grida — generating an image, generating audio, and so on 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 $0.50 (Free) or $15 (Pro) 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 Pro and come back next year, it's still there. +- **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 @@ -72,40 +74,47 @@ We will never charge your card to "cover" an AI call. You only ever spend credit ## What happens if your card fails -This section is Pro-only. Free users have nothing to renew. +This section is for paid plans only. Free users have nothing to renew. -Each month we try to charge your card for the $20 Pro renewal. If the charge fails (expired card, insufficient funds, anything), here's what happens: +Each month we try to charge your card for the renewal. If the charge fails (expired card, insufficient funds, anything), here's what happens: -- **Pro pauses immediately.** Not in 24 hours, not after a 7-day grace period — the same minute the charge fails. -- **Your $15 monthly credit is suspended** until the card succeeds. +- **Your subscription pauses immediately.** Not in 24 hours, not after a 7-day grace period — the same minute the charge fails. +- **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, Pro turns back on and the monthly credit lands. +- **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 Pro 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 Pro. +> 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 -If your organization has more than one person, Pro charges per seat — but everyone shares the same credit pool, the same way Cursor Teams or Linear handle it. +Pro and Team both charge per seat — and everyone in your org shares the same credit pool, the same way Cursor Teams or Linear handle it. A few things to know: -- **Every member of a Pro org is a paid seat.** Each seat is $20/month and contributes $15 to the shared pool. The owner counts as a seat too. -- **Inviting someone to your org adds a seat.** Their cost is prorated for the rest of the current period — e.g., adding Bob halfway through the month adds about $10 to your next invoice. +- **Every member of a paid org is a paid seat.** Each seat charges the per-seat price and contributes its credit allotment to the shared pool. The owner counts as a seat too. +- **Inviting someone to your org adds a seat.** Their cost is prorated for the rest of the current period — e.g., adding Bob halfway through the month adds about half a seat's price to your next invoice. - **Removing someone from your org removes their seat next period.** The current period's credit pool is unaffected (it was already paid for and minted at the start of the period). Stripe issues a small prorated credit for the unused portion of their seat. - **Pending invitations don't count as seats.** A seat starts costing only when the person accepts and joins the org. -- **Free orgs can have multiple members.** They all share one $0.50 monthly credit pool — same total whether your org has 1 person or 5. If you need more credit, upgrade to Pro and seats scale automatically. +- **Free orgs can have multiple members.** They all share one $0.50 monthly credit pool — same total whether your org has 1 person or 5. If you need more credit, upgrade to Pro or Team and seats scale automatically. - **Only the org owner can manage seats and billing** today. Member-role permissions are coming. -> Good to know: a "Team plan" as a separate product doesn't exist. Pro is per-seat from day one. You don't have to "upgrade to Team" to add a teammate — you just invite them, and the next invoice reflects the new seat count. +## Pro vs. Team: how to choose + +Both plans charge per seat and pool credit at the org level. Pick based on what each seat needs: + +- **Pro** ($20/seat) is for individuals and small teams whose AI usage fits comfortably in $10/seat/month, or who plan to top up occasionally. +- **Team** ($60/seat) is for teams that lean heavily on AI — the larger $35/seat 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 Pro from your account settings at any time, no questions asked. +You can cancel from your account settings at any time, no questions asked. When you cancel: -- You keep Pro and the current month's credit until the end of the period you've already paid for. +- 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. @@ -113,7 +122,7 @@ There's nothing to "downgrade" manually. Cancellation handles it. ## Examples -Three quick walkthroughs to make this concrete. +A few quick walkthroughs to make this concrete. ### Maya is on Free @@ -127,7 +136,7 @@ She waits a week. The new month rolls over. Her balance refreshes to $0.50 and s ### Jordan upgrades to Pro (solo) -Jordan is the only person in their org. Upgrading to Pro buys 1 seat — $20/month, $15 of monthly AI credit. +Jordan is the only person in their org. Upgrading to Pro buys 1 seat — $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. @@ -136,62 +145,62 @@ Jordan tops up $25. - The card is charged about **$25.73** (the $25 top-up plus the processor fee). - Jordan's balance becomes **$25.20**. -Jordan keeps working. When the next month begins, $15 of fresh Pro credit lands on top of whatever top-up credit is still left. +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 +### 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 kind of AI work they want to do. +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 upgrades the org to Pro from the billing settings. +Priya considers Pro ($20/seat × 4 = $80/month with $40 of pooled credit) but the team generates a lot of images, so she upgrades to **Team** instead. - The seat count is set automatically to **4** (everyone currently in the org). -- The first invoice is **$80** (4 × $20). -- The team's credit pool jumps to **$60/month** (4 × $15), shared across all 4 people. Anyone on the team can spend any of it. +- The first invoice is **$240** (4 × $60). +- The team's credit pool jumps to **$140/month** (4 × $35), shared across all 4 people. Anyone on the team can spend any of it. A week later, they invite a fifth person. -- Bob accepts the invitation. Stripe charges a **prorated $15** for the rest of the period. -- The seat count is now 5. Next month's pool will be **$75** (5 × $15). +- Bob accepts the invitation. Stripe charges a **prorated $30** for the rest of the period. +- The seat count is now 5. Next month's pool will be **$175** (5 × $35). A month later, someone leaves the team. Priya removes them. -- The seat count drops to 4. Stripe issues a **prorated credit** of about $10 to the next invoice. -- The current month's $75 pool is unchanged (it was already paid for and minted at the start of the period). -- Next month's pool will be **$60**. +- The seat count drops to 4. Stripe issues a **prorated credit** of about $20 to the next invoice. +- The current month's $175 pool is unchanged (it was already paid for and minted at the start of the period). +- Next month's pool will be **$140**. ### Sam's card fails -Sam is Pro and has $25.00 in top-up credit sitting in the account. +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 $15 monthly credit is suspended. +- 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 $15 lands on schedule on Sam's normal renewal date. +- 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 or Pro) resets each billing period. This is how all monthly plans work. +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 Pro and come back later. +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 the [refund policy](/support/refund-policy). The short version: we'll work with you on accidental top-ups and obvious billing errors. -**What happens if I cancel Pro mid-month?** -You keep Pro 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. +**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 $5 base difference in the Pro plan, not on AI usage. +No. We charge you exactly what the model provider charges us. We make our money on the per-seat base difference (the part of the seat price 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. @@ -203,22 +212,25 @@ Major credit and debit cards. Apple Pay and Google Pay where supported by your b 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?** -Pro is per-seat: $20 per user per month. Every member of your org is a seat, including the owner. Adding a member adds a seat (prorated); removing one removes it. AI credit is shared across the team — a 5-seat Pro org has a $75/month pool that any member can spend from. There's no separate "Team plan" — Pro is the team product. +Pro and Team are both per-seat. Every member of your org is a seat, including the owner. Adding a member adds a seat (prorated); removing one credits the next invoice. AI credit is shared across the team. Pick Pro for lighter AI usage, Team for heavier — you can switch any time. + +**What's the difference between Pro and Team?** +Both are seat-based and both pool credit at the org level. Team raises the per-seat 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 $15?** +**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?** -Not yet. We'll add one once Pro has settled. +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. **Can I top up with crypto / wire / invoice?** -Not in v1. Card payments only for now. +Card payments only on Pro and Team. Enterprise plans support invoicing — talk to sales. ## Anything else? diff --git a/editor/app/(www)/(pricing)/pricing/_sections/faq.tsx b/editor/app/(www)/(pricing)/pricing/_sections/faq.tsx index e97750e469..e8a48f7a15 100644 --- a/editor/app/(www)/(pricing)/pricing/_sections/faq.tsx +++ b/editor/app/(www)/(pricing)/pricing/_sections/faq.tsx @@ -13,9 +13,10 @@ const faqs: { question: string; answer: React.ReactNode }[] = [ answer: ( <> Each plan includes a monthly AI credit — $0.50 on Free,{" "} - $15 per seat on Pro. AI features draw from this balance - at the model provider's cost; we never mark up AI usage. Unused - monthly credit resets at the start of the next billing period.{" "} + $10 per seat on Pro, $35 per seat on + Team. AI features draw from this balance at the model provider's + cost; we never mark up AI usage. Unused monthly credit resets at the + start of the next billing period.{" "} ), }, + { + question: "What's the difference between Pro and Team?", + answer: + "Both are per-seat and both pool AI credit at the org level. Team raises the per-seat AI credit ($35 vs $10), storage, and monthly active users on published projects, and adds chat support. Pro is for individuals and small teams with lighter AI usage; Team is for teams that lean heavily on AI. You can switch between them any time — Stripe prorates the difference automatically.", + }, { question: "Can I buy credit ahead of time?", answer: - "Yes. You can top up at any time, in any amount from $5 to $1000. The amount you pick is exactly what lands in your balance — the card processor's fee is added on top of the charge and shown clearly at checkout. Top-up credit never expires, even if you cancel Pro and come back later.", + "Yes. You can top up at any time, in any amount from $5 to $1000. The amount you pick is exactly what lands in your balance — the card processor's fee is added on top of the charge and shown clearly at checkout. Top-up credit never expires, even if you cancel and come back later.", }, { question: "What happens when I run out of credit?", @@ -39,17 +45,17 @@ const faqs: { question: string; answer: React.ReactNode }[] = [ { question: "Are AI prices marked up?", answer: - "No. We charge you exactly what the model provider charges us. Our margin lives in the base plan ($5 of every $20 Pro plan), not in AI usage. When provider prices change, we update what we charge to match.", + "No. We charge you exactly what the model provider charges us. Our margin lives in the per-seat base price (the part of the seat that isn't AI credit), not in AI usage. When provider prices change, we update what we charge to match.", }, { question: "What happens if I cancel?", answer: - "You keep Pro 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 receiving $0.50 of credit each month. Any top-up credit you have stays in your account.", + "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 receiving $0.50 of credit each month. Any top-up credit you have stays in your account.", }, { question: "How does pricing work for teams?", answer: - "Pro is per-seat — $20 per user per month — and there is no separate 'Team plan.' A solo user is just a 1-seat Pro subscription. A 5-person team is 5 seats: $100/month with $75 of pooled AI credit that anyone on the team can spend. Adding a member is prorated; removing one credits the next invoice. The owner counts as a seat.", + "Pro and Team are both per-seat. Every member of your org is a seat, including the owner — pending invites don't count until accepted. Adding a member adds a seat (prorated for the rest of the period); removing one credits the next invoice. AI credit is pooled at the org level, so anyone on the team can spend any of it. A 5-seat Team org pays $300/month and gets a $175/month shared AI pool.", }, ]; From 12c597b9b43fccbbd406209149539e860c0b995c Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 5 May 2026 19:45:25 +0900 Subject: [PATCH 04/18] fix(auth): ensure redirect URI is resolved with request origin --- editor/app/(insiders)/insiders/auth/basic/sign-in/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, }); } From 74c0765e0a857ef63fce377a4348eace4a784183 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 5 May 2026 20:21:17 +0900 Subject: [PATCH 05/18] test(billing): add 185 manual test cases for v1 billing system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add four behavior-spec files under `test/` covering the full v1 billing surface: subscription lifecycle and Stripe consistency, quota math and the AI gate, payment failures and fraud vectors, and user-facing UX + operational edge cases. Cases are written as natural-language flows (symptoms + expected outcomes), implementation-agnostic so the spec survives schema iteration. Also correct the top-up fee math in the user-facing billing guide ($25.73 → $26.06, accounting for the fee-on-fee), and trim a stale trailing FAQ entry. --- docs/platform/billing.mdx | 15 +- test/billing-payment-and-money-safety.md | 263 +++++++++++++++++++ test/billing-quota-and-ai.md | 311 +++++++++++++++++++++++ test/billing-subscription-and-stripe.md | 298 ++++++++++++++++++++++ test/billing-ux-and-edge-cases.md | 203 +++++++++++++++ 5 files changed, 1077 insertions(+), 13 deletions(-) create mode 100644 test/billing-payment-and-money-safety.md create mode 100644 test/billing-quota-and-ai.md create mode 100644 test/billing-subscription-and-stripe.md create mode 100644 test/billing-ux-and-edge-cases.md diff --git a/docs/platform/billing.mdx b/docs/platform/billing.mdx index 2f1b273d4e..a7ad8723c8 100644 --- a/docs/platform/billing.mdx +++ b/docs/platform/billing.mdx @@ -56,7 +56,7 @@ A top-up is a one-time purchase. You pick any amount between **$5 and $1000** an 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 **$25.73** ($25.00 + roughly 2.9% + $0.30 for the processor). +- 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. @@ -142,7 +142,7 @@ Jordan generates a lot of images and a lot of audio tracks throughout the month. Jordan tops up $25. -- The card is charged about **$25.73** (the $25 top-up plus the processor fee). +- 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. @@ -228,14 +228,3 @@ Yes — Pro and Team are both available annually at a 20% discount. The discount **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. - -**Can I top up with crypto / wire / invoice?** -Card payments only on Pro and Team. Enterprise plans support invoicing — talk to sales. - -## Anything else? - -- [Refund policy](/support/refund-policy) -- [Terms and conditions](/support/terms-and-conditions) -- [Privacy policy](/support/privacy-policy) - -Still stuck? Email [support@grida.co](mailto:support@grida.co) and a real human will get back to you. 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..ac24e08725 --- /dev/null +++ b/test/billing-subscription-and-stripe.md @@ -0,0 +1,298 @@ +--- +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. 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. From de981434f6a019c60f471e022ded7df6f89f9908 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 6 May 2026 23:36:36 +0900 Subject: [PATCH 06/18] feat(supabase/billing): add grida_billing schema and drop legacy pricing_tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the Stripe-backed billing schema (grida_billing.*) with account, customer, subscription, invoice, payment_method, stripe_event, billing_audit, and product_catalogue tables, plus the fn_billing_apply_stripe_event projector and supporting RPCs. Adds 38 pgTAP scenarios covering RLS, projector branches, idempotency, annual catalogue resolution, and the new is_enterprise flag. Drops the unused public.organization.display_plan column and public.pricing_tier enum — plan is now derived from v_billing_subscription. Adds organization.is_enterprise boolean as a separate ops flag. Includes the contributor setup guide at docs/contributing/billing.md. --- database/database-generated.types.ts | 180 ++- docs/contributing/billing.md | 115 ++ .../20260506132900_grida_billing.sql | 1145 +++++++++++++++++ ...0260506132901_drop_legacy_pricing_tier.sql | 20 + supabase/schemas/grida_billing.sql | 1145 +++++++++++++++++ supabase/seed.sql | 16 +- supabase/tests/test_grida_billing_test.sql | 404 ++++++ 7 files changed, 3011 insertions(+), 14 deletions(-) create mode 100644 docs/contributing/billing.md create mode 100644 supabase/migrations/20260506132900_grida_billing.sql create mode 100644 supabase/migrations/20260506132901_drop_legacy_pricing_tier.sql create mode 100644 supabase/schemas/grida_billing.sql create mode 100644 supabase/tests/test_grida_billing_test.sql diff --git a/database/database-generated.types.ts b/database/database-generated.types.ts index f03d85f262..fdb92aa04d 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,124 @@ 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_seat_drift: { + Row: { + db_quantity: number | null + drift: number | null + member_count: number | null + organization_id: number | null + plan: string | null + status: string | null + stripe_subscription_id: string | null + updated_at: string | null + } + Relationships: [ + { + foreignKeyName: "subscription_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 +4773,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_reason: string } + Returns: undefined + } gen_random_slug: { Args: never; Returns: string } generate_combinations: | { @@ -4746,7 +4916,6 @@ export type Database = { | "ar" | "hi" | "nl" - pricing_tier: "free" | "v0_pro" | "v0_team" | "v0_enterprise" } CompositeTypes: { [_ in never]: never @@ -5240,7 +5409,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..ba6666dd49 --- /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: [`test/billing-*.md`](../../test/). + +--- + +## 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/supabase/migrations/20260506132900_grida_billing.sql b/supabase/migrations/20260506132900_grida_billing.sql new file mode 100644 index 0000000000..c9731aa671 --- /dev/null +++ b/supabase/migrations/20260506132900_grida_billing.sql @@ -0,0 +1,1145 @@ +-- 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, + -- Per-month per-seat price for kind='plan'. + per_seat_price_cents integer CHECK (per_seat_price_cents IS NULL OR per_seat_price_cents >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT product_catalogue_plan_has_price CHECK ( + kind <> 'plan' OR per_seat_price_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, per_seat_price_cents) VALUES + ('plan.free', 'plan', 0), + ('plan.pro', 'plan', 2000), -- $20 / seat / mo + ('plan.team', 'plan', 6000), -- $60 / seat / mo + ('plan.pro.annual', 'plan', 19200), -- $192 / seat / yr (20% off $240) + ('plan.team.annual', 'plan', 57600) -- $576 / seat / 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, grida_billing, 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, grida_billing, 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, grida_billing, 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, grida_billing, 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; + + 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); + END IF; + + RETURN QUERY SELECT v_attached, v_existing; +END; +$$; + + +-- Seat-sync triggers ---------------------------------------------------------- +-- Bumping subscription.quantity when membership changes is a billing concern. + +--------------------------------------------------------------------- +-- [grida_billing.fn_seat_add] +-- Internal: bump quantity for org's active pro/team sub. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.fn_seat_add( + p_org_id bigint, + p_member_user_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, grida_billing, public +AS $$ +DECLARE + v_sub_id uuid; + v_quantity integer; +BEGIN + SELECT id, quantity INTO v_sub_id, v_quantity + FROM grida_billing.subscription + WHERE organization_id = p_org_id + AND is_free = false + AND status <> 'canceled' + LIMIT 1; + + IF v_sub_id IS NULL THEN + RETURN; + END IF; + + UPDATE grida_billing.subscription + SET quantity = quantity + 1, + updated_at = now() + WHERE id = v_sub_id; + + INSERT INTO grida_billing.audit ( + organization_id, user_id, operation, + member_user_id, prev_quantity, new_quantity, note + ) VALUES ( + p_org_id, (SELECT auth.uid()), 'seat_add', + p_member_user_id, v_quantity, v_quantity + 1, 'pending stripe sync' + ); +END; +$$; + + +--------------------------------------------------------------------- +-- [grida_billing.fn_seat_remove] +-- Internal: decrement quantity for org's active pro/team sub. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.fn_seat_remove( + p_org_id bigint, + p_member_user_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, grida_billing, public +AS $$ +DECLARE + v_sub_id uuid; + v_quantity integer; +BEGIN + SELECT id, quantity INTO v_sub_id, v_quantity + FROM grida_billing.subscription + WHERE organization_id = p_org_id + AND is_free = false + AND status <> 'canceled' + LIMIT 1; + + IF v_sub_id IS NULL THEN + RETURN; + END IF; + + UPDATE grida_billing.subscription + SET quantity = greatest(quantity - 1, 1), + updated_at = now() + WHERE id = v_sub_id; + + INSERT INTO grida_billing.audit ( + organization_id, user_id, operation, + member_user_id, prev_quantity, new_quantity, note + ) VALUES ( + p_org_id, (SELECT auth.uid()), 'seat_remove', + p_member_user_id, v_quantity, greatest(v_quantity - 1, 1), 'pending stripe sync' + ); +END; +$$; + + +--------------------------------------------------------------------- +-- [grida_billing.tg_organization_member_after_insert] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.tg_organization_member_after_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, grida_billing, public +AS $$ +BEGIN + PERFORM grida_billing.fn_seat_add(NEW.organization_id, NEW.user_id); + RETURN NULL; +END; +$$; + +CREATE TRIGGER tg_billing_organization_member_after_insert + AFTER INSERT ON public.organization_member + FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_organization_member_after_insert(); + + +--------------------------------------------------------------------- +-- [grida_billing.tg_organization_member_after_delete] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.tg_organization_member_after_delete() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, grida_billing, public +AS $$ +BEGIN + PERFORM grida_billing.fn_seat_remove(OLD.organization_id, OLD.user_id); + RETURN NULL; +END; +$$; + +CREATE TRIGGER tg_billing_organization_member_after_delete + AFTER DELETE ON public.organization_member + FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_organization_member_after_delete(); + + +--------------------------------------------------------------------- +-- [grida_billing.tg_organization_member_after_update] +-- Defensive: org transfer (organization_id change) maps to a +-- delete-from-old + add-to-new pair. user_id changes (account merge) +-- don't affect quantity since the seat is preserved. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.tg_organization_member_after_update() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, grida_billing, public +AS $$ +BEGIN + IF NEW.organization_id IS DISTINCT FROM OLD.organization_id THEN + PERFORM grida_billing.fn_seat_remove(OLD.organization_id, OLD.user_id); + PERFORM grida_billing.fn_seat_add(NEW.organization_id, NEW.user_id); + END IF; + RETURN NULL; +END; +$$; + +CREATE TRIGGER tg_billing_organization_member_after_update + AFTER UPDATE ON public.organization_member + FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_organization_member_after_update(); + + +--------------------------------------------------------------------- +-- [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, grida_billing, 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 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; + 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 + 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'; + + v_plan := 'pro'; + 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 'pro' + END + INTO v_plan + FROM grida_billing.product_catalogue + WHERE stripe_price_id = v_price_id; + v_plan := coalesce(v_plan, 'pro'); + 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; + + UPDATE grida_billing.subscription + SET status = 'past_due', updated_at = now() + WHERE stripe_subscription_id = v_sub_id; + + 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, 'past_due', 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. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.fn_stamp_failure( + p_event_id text, + p_reason text +) +RETURNS void +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, grida_billing +AS $$ + UPDATE grida_billing.stripe_event + SET failed_at = now(), + failure_reason = left(p_reason, 2000) + WHERE id = p_event_id; +$$; + + +-- ============================================================================ +-- 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.v_billing_seat_drift] +-- Operator (service_role) view: orgs whose subscription.quantity +-- does not match count(organization_member). Surfaces seat-sync +-- divergence (TC-BILLING-SUB-031). +--------------------------------------------------------------------- + +CREATE OR REPLACE VIEW public.v_billing_seat_drift +WITH (security_invoker = false) +AS +SELECT + s.organization_id, + s.plan, + s.status, + s.quantity AS db_quantity, + coalesce(mc.member_count, 0)::integer AS member_count, + (s.quantity - coalesce(mc.member_count, 0))::integer AS drift, + s.stripe_subscription_id, + s.updated_at +FROM grida_billing.subscription s +LEFT JOIN LATERAL ( + SELECT count(*)::integer AS member_count + FROM public.organization_member om + WHERE om.organization_id = s.organization_id +) mc ON true +WHERE s.is_free = false + AND s.status NOT IN ('canceled') + AND s.quantity <> coalesce(mc.member_count, 0); + +REVOKE ALL ON public.v_billing_seat_drift FROM PUBLIC, authenticated, anon; +GRANT SELECT ON public.v_billing_seat_drift TO 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 = grida_billing, public, pg_temp +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] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.fn_billing_stamp_failure( + p_event_id text, + p_reason text +) +RETURNS void +LANGUAGE sql +SECURITY DEFINER +SET search_path = grida_billing, public, pg_temp +AS $$ + SELECT grida_billing.fn_stamp_failure(p_event_id, p_reason); +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_stamp_failure(text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_stamp_failure(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 = grida_billing, public, pg_temp +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 = grida_billing, public, pg_temp +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 = grida_billing, public, pg_temp +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 = grida_billing, public, pg_temp +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 = grida_billing, public, pg_temp +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..c9731aa671 --- /dev/null +++ b/supabase/schemas/grida_billing.sql @@ -0,0 +1,1145 @@ +-- 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, + -- Per-month per-seat price for kind='plan'. + per_seat_price_cents integer CHECK (per_seat_price_cents IS NULL OR per_seat_price_cents >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT product_catalogue_plan_has_price CHECK ( + kind <> 'plan' OR per_seat_price_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, per_seat_price_cents) VALUES + ('plan.free', 'plan', 0), + ('plan.pro', 'plan', 2000), -- $20 / seat / mo + ('plan.team', 'plan', 6000), -- $60 / seat / mo + ('plan.pro.annual', 'plan', 19200), -- $192 / seat / yr (20% off $240) + ('plan.team.annual', 'plan', 57600) -- $576 / seat / 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, grida_billing, 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, grida_billing, 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, grida_billing, 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, grida_billing, 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; + + 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); + END IF; + + RETURN QUERY SELECT v_attached, v_existing; +END; +$$; + + +-- Seat-sync triggers ---------------------------------------------------------- +-- Bumping subscription.quantity when membership changes is a billing concern. + +--------------------------------------------------------------------- +-- [grida_billing.fn_seat_add] +-- Internal: bump quantity for org's active pro/team sub. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.fn_seat_add( + p_org_id bigint, + p_member_user_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, grida_billing, public +AS $$ +DECLARE + v_sub_id uuid; + v_quantity integer; +BEGIN + SELECT id, quantity INTO v_sub_id, v_quantity + FROM grida_billing.subscription + WHERE organization_id = p_org_id + AND is_free = false + AND status <> 'canceled' + LIMIT 1; + + IF v_sub_id IS NULL THEN + RETURN; + END IF; + + UPDATE grida_billing.subscription + SET quantity = quantity + 1, + updated_at = now() + WHERE id = v_sub_id; + + INSERT INTO grida_billing.audit ( + organization_id, user_id, operation, + member_user_id, prev_quantity, new_quantity, note + ) VALUES ( + p_org_id, (SELECT auth.uid()), 'seat_add', + p_member_user_id, v_quantity, v_quantity + 1, 'pending stripe sync' + ); +END; +$$; + + +--------------------------------------------------------------------- +-- [grida_billing.fn_seat_remove] +-- Internal: decrement quantity for org's active pro/team sub. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.fn_seat_remove( + p_org_id bigint, + p_member_user_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, grida_billing, public +AS $$ +DECLARE + v_sub_id uuid; + v_quantity integer; +BEGIN + SELECT id, quantity INTO v_sub_id, v_quantity + FROM grida_billing.subscription + WHERE organization_id = p_org_id + AND is_free = false + AND status <> 'canceled' + LIMIT 1; + + IF v_sub_id IS NULL THEN + RETURN; + END IF; + + UPDATE grida_billing.subscription + SET quantity = greatest(quantity - 1, 1), + updated_at = now() + WHERE id = v_sub_id; + + INSERT INTO grida_billing.audit ( + organization_id, user_id, operation, + member_user_id, prev_quantity, new_quantity, note + ) VALUES ( + p_org_id, (SELECT auth.uid()), 'seat_remove', + p_member_user_id, v_quantity, greatest(v_quantity - 1, 1), 'pending stripe sync' + ); +END; +$$; + + +--------------------------------------------------------------------- +-- [grida_billing.tg_organization_member_after_insert] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.tg_organization_member_after_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, grida_billing, public +AS $$ +BEGIN + PERFORM grida_billing.fn_seat_add(NEW.organization_id, NEW.user_id); + RETURN NULL; +END; +$$; + +CREATE TRIGGER tg_billing_organization_member_after_insert + AFTER INSERT ON public.organization_member + FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_organization_member_after_insert(); + + +--------------------------------------------------------------------- +-- [grida_billing.tg_organization_member_after_delete] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.tg_organization_member_after_delete() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, grida_billing, public +AS $$ +BEGIN + PERFORM grida_billing.fn_seat_remove(OLD.organization_id, OLD.user_id); + RETURN NULL; +END; +$$; + +CREATE TRIGGER tg_billing_organization_member_after_delete + AFTER DELETE ON public.organization_member + FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_organization_member_after_delete(); + + +--------------------------------------------------------------------- +-- [grida_billing.tg_organization_member_after_update] +-- Defensive: org transfer (organization_id change) maps to a +-- delete-from-old + add-to-new pair. user_id changes (account merge) +-- don't affect quantity since the seat is preserved. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.tg_organization_member_after_update() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, grida_billing, public +AS $$ +BEGIN + IF NEW.organization_id IS DISTINCT FROM OLD.organization_id THEN + PERFORM grida_billing.fn_seat_remove(OLD.organization_id, OLD.user_id); + PERFORM grida_billing.fn_seat_add(NEW.organization_id, NEW.user_id); + END IF; + RETURN NULL; +END; +$$; + +CREATE TRIGGER tg_billing_organization_member_after_update + AFTER UPDATE ON public.organization_member + FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_organization_member_after_update(); + + +--------------------------------------------------------------------- +-- [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, grida_billing, 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 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; + 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 + 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'; + + v_plan := 'pro'; + 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 'pro' + END + INTO v_plan + FROM grida_billing.product_catalogue + WHERE stripe_price_id = v_price_id; + v_plan := coalesce(v_plan, 'pro'); + 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; + + UPDATE grida_billing.subscription + SET status = 'past_due', updated_at = now() + WHERE stripe_subscription_id = v_sub_id; + + 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, 'past_due', 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. +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION grida_billing.fn_stamp_failure( + p_event_id text, + p_reason text +) +RETURNS void +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, grida_billing +AS $$ + UPDATE grida_billing.stripe_event + SET failed_at = now(), + failure_reason = left(p_reason, 2000) + WHERE id = p_event_id; +$$; + + +-- ============================================================================ +-- 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.v_billing_seat_drift] +-- Operator (service_role) view: orgs whose subscription.quantity +-- does not match count(organization_member). Surfaces seat-sync +-- divergence (TC-BILLING-SUB-031). +--------------------------------------------------------------------- + +CREATE OR REPLACE VIEW public.v_billing_seat_drift +WITH (security_invoker = false) +AS +SELECT + s.organization_id, + s.plan, + s.status, + s.quantity AS db_quantity, + coalesce(mc.member_count, 0)::integer AS member_count, + (s.quantity - coalesce(mc.member_count, 0))::integer AS drift, + s.stripe_subscription_id, + s.updated_at +FROM grida_billing.subscription s +LEFT JOIN LATERAL ( + SELECT count(*)::integer AS member_count + FROM public.organization_member om + WHERE om.organization_id = s.organization_id +) mc ON true +WHERE s.is_free = false + AND s.status NOT IN ('canceled') + AND s.quantity <> coalesce(mc.member_count, 0); + +REVOKE ALL ON public.v_billing_seat_drift FROM PUBLIC, authenticated, anon; +GRANT SELECT ON public.v_billing_seat_drift TO 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 = grida_billing, public, pg_temp +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] +--------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.fn_billing_stamp_failure( + p_event_id text, + p_reason text +) +RETURNS void +LANGUAGE sql +SECURITY DEFINER +SET search_path = grida_billing, public, pg_temp +AS $$ + SELECT grida_billing.fn_stamp_failure(p_event_id, p_reason); +$$; + +REVOKE ALL ON FUNCTION public.fn_billing_stamp_failure(text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.fn_billing_stamp_failure(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 = grida_billing, public, pg_temp +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 = grida_billing, public, pg_temp +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 = grida_billing, public, pg_temp +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 = grida_billing, public, pg_temp +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 = grida_billing, public, pg_temp +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..cc760cbf76 --- /dev/null +++ b/supabase/tests/test_grida_billing_test.sql @@ -0,0 +1,404 @@ +-- 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 / v_billing_seat_drift. + +BEGIN; + +SELECT plan(38); + +-- 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 $$; + +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'); + +-- --------------------------------------------------------------------- +-- 8. v_billing_seat_drift requires service_role. +-- --------------------------------------------------------------------- + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', current_setting('test.alice_uid'), true); +SELECT throws_ok($$ + SELECT * FROM public.v_billing_seat_drift +$$, '42501', NULL, + 'authenticated cannot SELECT v_billing_seat_drift'); +RESET ROLE; + +-- --------------------------------------------------------------------- +-- 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' +); + +SELECT * FROM finish(); +ROLLBACK; From 2fc305b31ee9a7a6821f5fc533b63fc6b9623ee5 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 6 May 2026 23:37:16 +0900 Subject: [PATCH 07/18] feat(billing): add lib/billing seam, Stripe setup script, and import lint rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - editor/lib/billing/index.ts: single Stripe seam exporting the typed client, BillingError, auth helpers, redirect-URL validation, data helpers, and the dispatchStripeEvent projector entry point. - editor/.oxlintrc.jsonc: enforces the seam — no-restricted-imports blocks 'stripe' value imports outside lib/billing/index.ts. - editor/lib/supabase/server.ts: documents the inline-callsite rule for service_role..* (don't alias — preserves grep-ability). - editor/scripts/billing/setup-stripe-test.ts: idempotent test-mode setup of products, monthly+annual prices, and Customer Portal config. - tsconfig: exclude scripts/ from typecheck (script lives outside the Next.js compile root). --- editor/.oxlintrc.jsonc | 29 ++ editor/lib/billing/index.ts | 396 ++++++++++++++++++++ editor/lib/supabase/server.ts | 13 + editor/package.json | 1 + editor/scripts/billing/setup-stripe-test.ts | 335 +++++++++++++++++ editor/tsconfig.json | 2 +- pnpm-lock.yaml | 43 ++- 7 files changed, 815 insertions(+), 4 deletions(-) create mode 100644 editor/lib/billing/index.ts create mode 100644 editor/scripts/billing/setup-stripe-test.ts 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/lib/billing/index.ts b/editor/lib/billing/index.ts new file mode 100644 index 0000000000..6a35b38252 --- /dev/null +++ b/editor/lib/billing/index.ts @@ -0,0 +1,396 @@ +// 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; + +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." + ); +} + +export const stripe = new Stripe(stripeKey, { + apiVersion: STRIPE_API_VERSION, + httpClient: Stripe.createFetchHttpClient(), + typescript: true, +}); +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; + quantity: number; +} | 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; + quantity?: number | null; + }>(data); + if (!row?.stripe_subscription_id) return null; + return { + stripe_subscription_id: row.stripe_subscription_id, + status: row.status ?? "active", + quantity: row.quantity ?? 1, + }; +} + +export async function countOrgMembers(org_id: number): Promise { + const { count, error } = await service_role.workspace + .from("organization_member") + .select("id", { count: "exact", head: true }) + .eq("organization_id", org_id); + if (error) throw new Error(`countOrgMembers: ${error.message}`); + return count ?? 0; +} + +// Resolve `account.stripe_customer_id` for an org; mint + persist if absent. +// Race-safe via `fn_billing_attach_stripe_customer` — concurrent callers +// 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) }, + }); + + 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. +export async function stampStripeEventFailure( + eventId: string, + reason: string +): Promise { + const { error } = await service_role.workspace.rpc( + "fn_billing_stamp_failure", + { + p_event_id: eventId, + p_reason: reason, + } + ); + if (error) console.warn("[billing] stamp_failure:", error.message); +} 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/scripts/billing/setup-stripe-test.ts b/editor/scripts/billing/setup-stripe-test.ts new file mode 100644 index 0000000000..9dd1ebec4c --- /dev/null +++ b/editor/scripts/billing/setup-stripe-test.ts @@ -0,0 +1,335 @@ +#!/usr/bin/env -S pnpm tsx +// Idempotent Stripe test-mode setup: products, per-seat 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 + per-seat 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. + + type Interval = "month" | "year"; + + type PlanProduct = { + name: string; + description: string; + product_grida_id: "plan.pro" | "plan.team"; + }; + + type PriceSpec = { + catalogue_id: + | "plan.pro" + | "plan.team" + | "plan.pro.annual" + | "plan.team.annual"; + interval: Interval; + unit_amount_cents: number; + nickname: string; + }; + + const PRO: PlanProduct = { + name: "Grida Pro", + description: "Grida Pro: $20/seat/mo or $192/seat/yr (20% off).", + product_grida_id: "plan.pro", + }; + const TEAM: PlanProduct = { + name: "Grida Team", + description: "Grida Team: $60/seat/mo or $576/seat/yr (20% off).", + product_grida_id: "plan.team", + }; + + const PRICES: { product: PlanProduct; price: PriceSpec }[] = [ + { + product: PRO, + price: { + catalogue_id: "plan.pro", + interval: "month", + unit_amount_cents: 2000, + nickname: "Pro monthly per-seat", + }, + }, + { + product: PRO, + price: { + catalogue_id: "plan.pro.annual", + interval: "year", + unit_amount_cents: 19200, + nickname: "Pro annual per-seat", + }, + }, + { + product: TEAM, + price: { + catalogue_id: "plan.team", + interval: "month", + unit_amount_cents: 6000, + nickname: "Team monthly per-seat", + }, + }, + { + product: TEAM, + price: { + catalogue_id: "plan.team.annual", + interval: "year", + unit_amount_cents: 57600, + nickname: "Team annual per-seat", + }, + }, + ]; + + // 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(PRO), + ensureProduct(TEAM), + ]); + + const productIdFor = (p: PlanProduct): string => + p === 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/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: From 4cc6702cc6f1d7017b407a86f01270a7cc80b632 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 6 May 2026 23:37:24 +0900 Subject: [PATCH 08/18] feat(billing): add Stripe webhook receiver Verifies the Stripe signature and dispatches the event into the PL/pgSQL projector via dispatchStripeEvent. Signature verification runs before any DB write; tampered or missing signatures return 400 without touching grida_billing.stripe_event. --- .../(api)/private/webhooks/stripe/route.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 editor/app/(api)/private/webhooks/stripe/route.ts 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..30f5b0f3b3 --- /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, 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, + }); +} From e9dc2bcdfde738c4ef304e07a3fbb1f38e2911ef Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 6 May 2026 23:37:49 +0900 Subject: [PATCH 09/18] feat(billing): add billing settings page and upgrade flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /organizations//settings/billing — Subscription Plan, Past Invoices, Payment Methods. Adjust plan and Cancel buttons guarded by past_due/unpaid/paused/incomplete states. - /settings/billing/upgrade — Monthly/Annual toggle, plan cards (Free → Paid via Stripe Checkout, paid → paid via Customer Portal flow_data subscription_update_confirm). Embedded as a modal via parallel-route @modal slot for in-place upgrade. - Server actions: getBillingSummary, listInvoices, updateSeats, startSubscribeCheckout, startPlanChangeConfirm, startCancelSubscription, startPaymentMethodUpdate, listBillingAudit. - Settings shell with sidebar nav (Profile / Billing / Members). Profile page drops its bespoke breadcrumb header. - host/url.ts: adds 'organization' route scope so universal links can target /organizations//. - Universal route picker handles organization-scope: redirects when the user has exactly one org, otherwise renders an org selector. --- .../[organization_name]/settings/_shell.tsx | 88 +++ .../billing/@modal/(.)upgrade/page.tsx | 34 + .../settings/billing/@modal/default.tsx | 6 + .../settings/billing/_actions.ts | 714 ++++++++++++++++++ .../settings/billing/_modal-shell.tsx | 45 ++ .../settings/billing/_view.tsx | 503 ++++++++++++ .../settings/billing/layout.tsx | 17 + .../settings/billing/page.tsx | 26 + .../settings/billing/upgrade/_view.tsx | 384 ++++++++++ .../settings/billing/upgrade/page.tsx | 26 + .../[organization_name]/settings/layout.tsx | 40 + .../settings/profile/page.tsx | 42 +- .../universal/[[...path]]/page.tsx | 80 ++ editor/host/url.ts | 32 +- 14 files changed, 1991 insertions(+), 46 deletions(-) create mode 100644 editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx create mode 100644 editor/app/(site)/organizations/[organization_name]/settings/billing/@modal/(.)upgrade/page.tsx create mode 100644 editor/app/(site)/organizations/[organization_name]/settings/billing/@modal/default.tsx create mode 100644 editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts create mode 100644 editor/app/(site)/organizations/[organization_name]/settings/billing/_modal-shell.tsx create mode 100644 editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx create mode 100644 editor/app/(site)/organizations/[organization_name]/settings/billing/layout.tsx create mode 100644 editor/app/(site)/organizations/[organization_name]/settings/billing/page.tsx create mode 100644 editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx create mode 100644 editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/page.tsx create mode 100644 editor/app/(site)/organizations/[organization_name]/settings/layout.tsx 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..0476cd90df --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx @@ -0,0 +1,88 @@ +"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) => { + const isActive = + pathname === c.href || pathname?.startsWith(`${c.href}/`); + 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..f6516e0ce0 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts @@ -0,0 +1,714 @@ +"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, + countOrgMembers, + assertAllowedRedirect, +} from "@/lib/billing"; +import { headers } from "next/headers"; + +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: string; + status: string; + is_free: boolean; + seat_count: number; + 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: "month" | "year" | null; +}; + +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: "month" | "year" | 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: sub?.plan ?? "free", + status: sub?.status ?? "active", + is_free: sub?.is_free ?? true, + seat_count: sub?.quantity ?? 1, + 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, + }; +} + +// --------------------------------------------------------------------------- +// 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: "pro" | "team"; + interval: "month" | "year"; + 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" + ); + } + + 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 catalogueId = + params.interval === "year" + ? (`plan.${params.plan}.annual` as const) + : (`plan.${params.plan}` as const); + const cat = await getCatalogueStripeIds(catalogueId); + if (!cat) { + throw new BillingError( + `Stripe price for ${catalogueId} is not yet wired.`, + "billing_not_provisioned", + 500 + ); + } + + // Fetch the current subscription's first item id — Portal flow_data + // requires it to know which item to update in place. + const stripeSub = await stripe.subscriptions.retrieve( + sub.stripe_subscription_id + ); + const itemId = stripeSub.items.data[0]?.id; + if (!itemId) { + 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: itemId, + price: cat.stripe_price_id, + quantity: sub.quantity, + }, + ], + }, + after_completion: { + type: "redirect", + redirect: { return_url }, + }, + }, + }); + + return { portal_url: session.url }; +} + +// --------------------------------------------------------------------------- +// startSubscribeCheckout +// --------------------------------------------------------------------------- + +export type SubscribeCheckoutResult = { + checkout_url: string | null; + session_id: string; + quantity: number; +}; + +export async function startSubscribeCheckout( + org_id: number, + params: { + plan: "pro" | "team"; + interval?: "month" | "year"; + quantity?: number; + 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: "month" | "year" = 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); + + // Annual catalogue rows are keyed `plan..annual`; monthly stays `plan.`. + const catalogueId = + interval === "year" + ? (`plan.${params.plan}.annual` as const) + : (`plan.${params.plan}` as const); + const cat = await getCatalogueStripeIds(catalogueId); + if (!cat) { + throw new BillingError( + `Stripe price for ${catalogueId} is not yet wired.`, + "billing_not_provisioned", + 500 + ); + } + + const memberCount = await countOrgMembers(org_id); + const quantity = Math.max( + 1, + Math.min( + Number.isInteger(params.quantity) ? Number(params.quantity) : memberCount, + memberCount || 1 + ) + ); + + 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, + quantity: String(quantity), + }, + allow_promotion_codes: true, + }, + { idempotencyKey } + ); + + return { + checkout_url: session.url, + session_id: session.id, + quantity, + }; +} + +// --------------------------------------------------------------------------- +// 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 }; +} + +// --------------------------------------------------------------------------- +// updateSeats +// +// Stripe-side mutation. Quantity is server-bounded by `members + 50` to +// prevent a runaway request from billing 1M seats. +// --------------------------------------------------------------------------- + +export type UpdateSeatsResult = { + new_quantity: number; + no_op?: boolean; + stripe_subscription_id?: string; +}; + +export async function updateSeats( + org_id: number, + params: { delta?: number; target_quantity?: number } +): Promise { + const user_id = await requireUserId(); + await assertOrgOwner(user_id, org_id); + + const sub = await getActivePaidSubscription(org_id); + if (!sub) { + throw new BillingError( + "Organization is on Free. Upgrade before changing seat count.", + "not_subscribed", + 400, + "billing/upgrade" + ); + } + if (sub.status !== "active" && sub.status !== "trialing") { + throw new BillingError( + `Subscription status is '${sub.status}'. Resolve payment issues first.`, + "subscription_not_active", + 400, + "billing" + ); + } + + let target: number; + if (typeof params.target_quantity === "number") { + target = Math.trunc(params.target_quantity); + } else if (typeof params.delta === "number") { + target = sub.quantity + Math.trunc(params.delta); + } else { + throw new BillingError( + "Provide either delta or target_quantity", + "invalid_body", + 400 + ); + } + + if (!Number.isInteger(target) || target < 1) { + throw new BillingError( + "target quantity must be >= 1", + "invalid_quantity", + 400 + ); + } + + const memberCount = await countOrgMembers(org_id); + if (target > memberCount + 50) { + throw new BillingError( + `target ${target} exceeds reasonable bound (members=${memberCount})`, + "quantity_too_large", + 400 + ); + } + + if (target === sub.quantity) { + return { new_quantity: target, no_op: true }; + } + + const stripeSub = await stripe.subscriptions.retrieve( + sub.stripe_subscription_id + ); + const itemId = stripeSub.items.data[0]?.id; + if (!itemId) { + throw new Error( + `Stripe subscription ${sub.stripe_subscription_id} has no items` + ); + } + + const updated = await stripe.subscriptions.update( + sub.stripe_subscription_id, + { + items: [{ id: itemId, quantity: target }], + proration_behavior: "create_prorations", + metadata: { + grida_organization_id: String(org_id), + last_seat_change_actor: user_id, + }, + }, + { idempotencyKey: `${org_id}:seat_change:${target}` } + ); + + return { + new_quantity: updated.items?.data?.[0]?.quantity ?? target, + stripe_subscription_id: updated.id, + }; +} + +// --------------------------------------------------------------------------- +// 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) + ); + + 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 (cursor) q = q.lt("created_at", cursor); + + const { data, error } = await q; + if (error) throw new Error(`audit list: ${error.message}`); + + const rows = (data ?? []) as AuditRow[]; + const next_cursor = + rows.length === limit ? rows[rows.length - 1].created_at : 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..92106fec16 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx @@ -0,0 +1,503 @@ +"use client"; + +import React, { useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +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, + startCancelSubscription, + startPaymentMethodUpdate, +} from "./_actions"; + +type BillingState = { + org_id: number; + plan: string; + status: string; + seat_count: number; + current_period_start: string | null; + current_period_end: string | null; + cancel_at_period_end: boolean; + has_active_subscription: 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 searchParams = useSearchParams(); + 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]); + + useEffect(() => { + const sub = searchParams.get("subscribe"); + if (sub === "success") { + toast.success("Welcome!", { + description: "Your subscription is provisioning.", + }); + } + }, [searchParams]); + + // After Stripe Checkout / Portal return, poll the read view a few times so + // the UI catches the webhook-driven state change. This NEVER reaches out to + // Stripe — the webhook is the only source of truth. If the webhook is slow + // or missing (e.g., `stripe listen` not running), the page stays stale; the + // fix is to deliver the webhook, not to bypass it from the client. + useEffect(() => { + const sub = searchParams.get("subscribe"); + if (sub !== "success") return; + + let cancelled = false; + let attempts = 0; + const interval = setInterval(() => { + attempts += 1; + if (cancelled || attempts > 5) { + clearInterval(interval); + return; + } + void refresh(); + }, 2_000); + + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [searchParams, refresh]); + + const updatePaymentMethod = useCallback(async () => { + try { + // `?subscribe=success` triggers the polling effect on return so the UI + // catches the webhook-driven status flip from past_due → active. + const result = await startPaymentMethodUpdate(orgId, { + return_url: `${window.location.origin}${baseUrl}?subscribe=success`, + }); + 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}?subscribe=success`, + }); + window.location.href = result.portal_url; + } catch (e) { + toast.error("Could not open cancellation", { + description: e instanceof Error ? e.message : String(e), + }); + } + }, [orgId, baseUrl]); + + if (loading || !state) { + return ( +
+
+

Billing

+
+
+ + + +
+
+ ); + } + + if (err) { + return ( +
+

Billing

+

Failed to load billing: {err}

+ +
+ ); + } + + const isPaid = state.plan === "pro" || state.plan === "team"; + const planLabel = + state.plan === "team" ? "Team" : state.plan === "pro" ? "Pro" : "Free"; + const seatPriceDollars = state.plan === "team" ? 60 : 20; + // 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 subscription, seats, 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 + ? `${state.seat_count} seat${state.seat_count === 1 ? "" : "s"} × $${seatPriceDollars}/mo = $${( + state.seat_count * seatPriceDollars + ).toFixed(2)}/mo` + : "$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. */} + {!isPastDue && !isPaused && ( + + + + )} + {isPaid && !state.cancel_at_period_end && !isPastDue && ( + + )} + +
+
+ + {/* 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. +

+ )} + +
+
+
+ + +

+ 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/upgrade/_view.tsx b/editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx new file mode 100644 index 0000000000..4d3d071151 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx @@ -0,0 +1,384 @@ +"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"; + +type CurrentState = { + plan: "free" | "pro" | "team" | string; + status: string; + seat_count: number; + interval: "month" | "year" | null; +} | null; + +type Interval = "month" | "year"; + +type PlanDef = { + id: "free" | "pro" | "team"; + name: string; + description: string; + /** Sticker monthly price per seat. Annual is this × 12 × 0.8 (20% off). */ + monthly_per_seat: number; + features: ReadonlyArray; +}; + +// 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. +const PLANS: ReadonlyArray = [ + { + id: "pro", + name: "Pro", + description: "For teams with creative workflows.", + monthly_per_seat: 20, + features: [ + "Stripe-managed billing & invoices", + "Per-seat pricing scales with your team", + "Cancel or switch plans anytime via the Customer Portal", + ], + }, + { + id: "team", + name: "Team", + description: "For larger teams.", + monthly_per_seat: 60, + features: [ + "Everything in Pro", + "More storage & monthly active users", + "Chat support", + ], + }, +] as const; + +const PLAN_RANK: Record = { free: 0, pro: 1, team: 2 }; + +// Annual discount is encoded inline (20% off the sticker monthly × 12). +function annualPerSeat(plan: PlanDef): number { + return plan.monthly_per_seat * 12 * 0.8; +} +function effectiveMonthly(plan: PlanDef, interval: Interval): number { + return interval === "year" ? annualPerSeat(plan) / 12 : plan.monthly_per_seat; +} + +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", + seat_count: data.seat_count ?? 1, + interval: data.interval ?? null, + }); + if (data.interval) setInterval(data.interval); + }) + .catch(() => + setCurrent({ + plan: "free", + status: "active", + seat_count: 1, + interval: null, + }) + ); + return () => { + cancel = true; + }; + }, [orgId]); + + const subscribe = async (plan: PlanDef) => { + if (!current || plan.id === "free") return; + if (plan.id !== "pro" && plan.id !== "team") return; + setSubmittingPlan(plan.id); + try { + const origin = window.location.origin; + const result = await startSubscribeCheckout(orgId, { + plan: plan.id, + interval, + quantity: current.seat_count, + success_url: `${origin}${baseUrl}?subscribe=success`, + 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: PlanDef) => { + if (plan.id === "free") return; + 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: PlanDef) => { + if (!current) return ; + const currentRank = PLAN_RANK[current.plan as PlanDef["id"]] ?? 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 as PlanDef["id"]] ?? 0; + const hasUpgrade = PLANS.some((p) => PLAN_RANK[p.id] > currentRank); + return hasUpgrade ? "Upgrade" : "Adjust plan"; + })(); + + const headerSubtitle = (() => { + if (!current) return "Choose a plan to fit your team."; + const currentRank = PLAN_RANK[current.plan as PlanDef["id"]] ?? 0; + const hasUpgrade = PLANS.some((p) => PLAN_RANK[p.id] > currentRank); + return hasUpgrade + ? "Pick a plan that scales with your team." + : "You're on the highest plan. Manage seats or downgrade in the Stripe Portal."; + })(); + + const intervalToggle = ( +
+ + +
+ ); + + const body = ( + <> +
{intervalToggle}
+ +
+ {PLANS.map((plan) => { + const isCurrent = current?.plan === plan.id; + const monthlyEquivalent = effectiveMonthly(plan, interval); + const annualSticker = annualPerSeat(plan); + return ( + + + + {plan.name} + {isCurrent && Current} + + {plan.description} + + +
+

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

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

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

+ )} +
+
    + {plan.features.map((f) => ( +
  • + + {f} +
  • + ))} +
+ {current && ( +

+ Total at {current.seat_count} seat + {current.seat_count === 1 ? "" : "s"}:{" "} + + $ + {(interval === "year" + ? current.seat_count * annualSticker + : current.seat_count * plan.monthly_per_seat + ).toFixed(2)} + {interval === "year" ? " / yr" : " / mo"} + +

+ )} +
+ {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 ( -
-
- {isPaid - ? `${state.seat_count} seat${state.seat_count === 1 ? "" : "s"} × $${seatPriceDollars}/mo = $${( - state.seat_count * seatPriceDollars - ).toFixed(2)}/mo` - : "$0/mo"} + {isPaid ? priceLabel : "$0/mo"} {isPaid && (periodStartLabel || periodEndLabel) && ( 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 index 4d3d071151..04334fe26f 100644 --- a/editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx @@ -20,62 +20,25 @@ import { startSubscribeCheckout, startPlanChangeConfirm, } from "../_actions"; - -type CurrentState = { - plan: "free" | "pro" | "team" | string; - status: string; - seat_count: number; - interval: "month" | "year" | null; -} | null; - -type Interval = "month" | "year"; - -type PlanDef = { - id: "free" | "pro" | "team"; - name: string; - description: string; - /** Sticker monthly price per seat. Annual is this × 12 × 0.8 (20% off). */ - monthly_per_seat: number; - features: ReadonlyArray; -}; +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. -const PLANS: ReadonlyArray = [ - { - id: "pro", - name: "Pro", - description: "For teams with creative workflows.", - monthly_per_seat: 20, - features: [ - "Stripe-managed billing & invoices", - "Per-seat pricing scales with your team", - "Cancel or switch plans anytime via the Customer Portal", - ], - }, - { - id: "team", - name: "Team", - description: "For larger teams.", - monthly_per_seat: 60, - features: [ - "Everything in Pro", - "More storage & monthly active users", - "Chat support", - ], - }, -] as const; - -const PLAN_RANK: Record = { free: 0, pro: 1, team: 2 }; -// Annual discount is encoded inline (20% off the sticker monthly × 12). -function annualPerSeat(plan: PlanDef): number { - return plan.monthly_per_seat * 12 * 0.8; -} -function effectiveMonthly(plan: PlanDef, interval: Interval): number { - return interval === "year" ? annualPerSeat(plan) / 12 : plan.monthly_per_seat; -} +type CurrentState = { + plan: PlanId; + status: string; + interval: Interval | null; +} | null; export default function UpgradeView({ orgId, @@ -101,7 +64,6 @@ export default function UpgradeView({ setCurrent({ plan: data.plan ?? "free", status: data.status ?? "active", - seat_count: data.seat_count ?? 1, interval: data.interval ?? null, }); if (data.interval) setInterval(data.interval); @@ -110,7 +72,6 @@ export default function UpgradeView({ setCurrent({ plan: "free", status: "active", - seat_count: 1, interval: null, }) ); @@ -119,16 +80,14 @@ export default function UpgradeView({ }; }, [orgId]); - const subscribe = async (plan: PlanDef) => { - if (!current || plan.id === "free") return; - if (plan.id !== "pro" && plan.id !== "team") return; + 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, - quantity: current.seat_count, success_url: `${origin}${baseUrl}?subscribe=success`, cancel_url: `${origin}${baseUrl}?subscribe=canceled`, }); @@ -146,8 +105,7 @@ export default function UpgradeView({ // 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: PlanDef) => { - if (plan.id === "free") return; + const changePlan = async (plan: PaidPlanDefinition) => { setSubmittingPlan(plan.id); try { const result = await startPlanChangeConfirm(orgId, { @@ -164,9 +122,9 @@ export default function UpgradeView({ } }; - const renderPlanAction = (plan: PlanDef) => { + const renderPlanAction = (plan: PaidPlanDefinition) => { if (!current) return ; - const currentRank = PLAN_RANK[current.plan as PlanDef["id"]] ?? 0; + const currentRank = PLAN_RANK[current.plan] ?? 0; const planRank = PLAN_RANK[plan.id]; const samePlan = plan.id === current.plan; const sameInterval = current.interval === interval; @@ -242,18 +200,22 @@ export default function UpgradeView({ const headerTitle = (() => { if (!current) return "Plans"; - const currentRank = PLAN_RANK[current.plan as PlanDef["id"]] ?? 0; - const hasUpgrade = PLANS.some((p) => PLAN_RANK[p.id] > currentRank); + 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 to fit your team."; - const currentRank = PLAN_RANK[current.plan as PlanDef["id"]] ?? 0; - const hasUpgrade = PLANS.some((p) => PLAN_RANK[p.id] > currentRank); + 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 scales with your team." - : "You're on the highest plan. Manage seats or downgrade in the Stripe Portal."; + ? "Pick a plan that fits your work." + : "You're on the highest plan. Switch interval or downgrade anytime."; })(); const intervalToggle = ( @@ -297,10 +259,13 @@ export default function UpgradeView({
{intervalToggle}
- {PLANS.map((plan) => { + {PAID_PLAN_LIST.map((plan) => { const isCurrent = current?.plan === plan.id; - const monthlyEquivalent = effectiveMonthly(plan, interval); - const annualSticker = annualPerSeat(plan); + const monthlyEquivalent = price_monthly_equivalent_dollars( + plan.id, + interval + ); + const annualSticker = price_dollars(plan.id, "year"); return ( {" "} - / seat / mo + / mo

{interval === "year" && (

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

)}
@@ -336,20 +301,6 @@ export default function UpgradeView({ ))} - {current && ( -

- Total at {current.seat_count} seat - {current.seat_count === 1 ? "" : "s"}:{" "} - - $ - {(interval === "year" - ? current.seat_count * annualSticker - : current.seat_count * plan.monthly_per_seat - ).toFixed(2)} - {interval === "year" ? " / yr" : " / mo"} - -

- )} {renderPlanAction(plan)} diff --git a/editor/lib/billing/index.ts b/editor/lib/billing/index.ts index 3a1bffa745..0c5984bf46 100644 --- a/editor/lib/billing/index.ts +++ b/editor/lib/billing/index.ts @@ -249,7 +249,6 @@ export async function getCatalogueStripeIds( export async function getActivePaidSubscription(org_id: number): Promise<{ stripe_subscription_id: string; status: string; - quantity: number; } | null> { const { data, error } = await service_role.workspace.rpc( "fn_billing_get_active_subscription", @@ -261,25 +260,14 @@ export async function getActivePaidSubscription(org_id: number): Promise<{ const row = firstRow<{ stripe_subscription_id?: string | null; status?: string | null; - quantity?: number | null; }>(data); if (!row?.stripe_subscription_id) return null; return { stripe_subscription_id: row.stripe_subscription_id, status: row.status ?? "active", - quantity: row.quantity ?? 1, }; } -export async function countOrgMembers(org_id: number): Promise { - const { count, error } = await service_role.workspace - .from("organization_member") - .select("id", { count: "exact", head: true }) - .eq("organization_id", org_id); - if (error) throw new Error(`countOrgMembers: ${error.message}`); - return count ?? 0; -} - // Resolve `account.stripe_customer_id` for an org; mint + persist if absent. // Race-safe via `fn_billing_attach_stripe_customer` — concurrent callers // converge on the first-written id. diff --git a/editor/www/data/plans.ts b/editor/lib/billing/marketing-plans.ts similarity index 50% rename from editor/www/data/plans.ts rename to editor/lib/billing/marketing-plans.ts index 4188d9686a..b3e4848f66 100644 --- a/editor/www/data/plans.ts +++ b/editor/lib/billing/marketing-plans.ts @@ -1,3 +1,19 @@ +// 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; @@ -19,6 +35,17 @@ export interface PricingInformation { 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", @@ -29,32 +56,13 @@ export const plans: PricingInformation[] = [ 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", - }, + { 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", }, @@ -67,37 +75,17 @@ export const plans: PricingInformation[] = [ href: "/dashboard/new?plan=pro", priceLabel: "From", warning: "$10 in compute credits included", - priceMonthly: `$20`, + 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: "♾️", - }, - { - name: "30GB Storage", - }, - { - name: "Email support", - }, + { 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", }, @@ -109,37 +97,17 @@ export const plans: PricingInformation[] = [ href: "/dashboard/new?plan=team", priceLabel: "From", warning: "$10 in compute credits included", - priceMonthly: `$60`, + 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: "♾️", - }, - { - name: "500GB Storage", - }, - { - name: "Chat support", - }, + { 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", }, @@ -166,12 +134,12 @@ export const save_plans: PricingInformation[] = [ plans[0], { ...plans[1], - priceMonthly: `$16`, + priceMonthly: `$${proAnnualMonthlyEquivDollars}`, href: "/dashboard/new?plan=pro&period=yearly", }, { ...plans[2], - priceMonthly: `$48`, + 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/scripts/billing/setup-stripe-test.ts b/editor/scripts/billing/setup-stripe-test.ts index 9dd1ebec4c..d83faa4aa0 100644 --- a/editor/scripts/billing/setup-stripe-test.ts +++ b/editor/scripts/billing/setup-stripe-test.ts @@ -1,5 +1,5 @@ #!/usr/bin/env -S pnpm tsx -// Idempotent Stripe test-mode setup: products, per-seat prices, and Customer +// 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`. // @@ -53,21 +53,26 @@ async function main(): Promise { const { service_role } = await import("../../lib/supabase/server"); // ----------------------------------------------------------------------- - // Plan products + per-seat prices (monthly + annual) + // 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. + // `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, catalogueId } = + await import("../../lib/billing/plans"); type Interval = "month" | "year"; type PlanProduct = { name: string; description: string; - product_grida_id: "plan.pro" | "plan.team"; + product_grida_id: `plan.${"pro" | "team"}`; }; type PriceSpec = { @@ -81,55 +86,40 @@ async function main(): Promise { nickname: string; }; - const PRO: PlanProduct = { - name: "Grida Pro", - description: "Grida Pro: $20/seat/mo or $192/seat/yr (20% off).", - product_grida_id: "plan.pro", - }; - const TEAM: PlanProduct = { - name: "Grida Team", - description: "Grida Team: $60/seat/mo or $576/seat/yr (20% off).", - product_grida_id: "plan.team", - }; - - const PRICES: { product: PlanProduct; price: PriceSpec }[] = [ - { - product: PRO, - price: { - catalogue_id: "plan.pro", - interval: "month", - unit_amount_cents: 2000, - nickname: "Pro monthly per-seat", - }, - }, - { - product: PRO, - price: { - catalogue_id: "plan.pro.annual", - interval: "year", - unit_amount_cents: 19200, - nickname: "Pro annual per-seat", + 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}`, }, - }, - { - product: TEAM, - price: { - catalogue_id: "plan.team", - interval: "month", - unit_amount_cents: 6000, - nickname: "Team monthly per-seat", + ]) + ) as Record<"pro" | "team", PlanProduct>; + + const PRICES: { product: PlanProduct; price: PriceSpec }[] = + PAID_PLAN_LIST.flatMap((p) => [ + { + product: PRODUCTS[p.id], + price: { + catalogue_id: catalogueId(p.id, "month"), + interval: "month" as const, + unit_amount_cents: p.monthly_cents, + nickname: `${p.name} monthly`, + }, }, - }, - { - product: TEAM, - price: { - catalogue_id: "plan.team.annual", - interval: "year", - unit_amount_cents: 57600, - nickname: "Team annual per-seat", + { + product: PRODUCTS[p.id], + price: { + catalogue_id: catalogueId(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 @@ -290,12 +280,12 @@ async function main(): Promise { // Provision both products in parallel, then their 4 prices in parallel. const [pro_product_id, team_product_id] = await Promise.all([ - ensureProduct(PRO), - ensureProduct(TEAM), + ensureProduct(PRODUCTS.pro), + ensureProduct(PRODUCTS.team), ]); const productIdFor = (p: PlanProduct): string => - p === PRO ? pro_product_id : team_product_id; + p === PRODUCTS.pro ? pro_product_id : team_product_id; const priceIds = await Promise.all( PRICES.map(async ({ product, price }) => { diff --git a/editor/www/pricing/pricing-comparison-table.tsx b/editor/www/pricing/pricing-comparison-table.tsx index 50a8ebbd23..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, diff --git a/editor/www/pricing/pricing.tsx b/editor/www/pricing/pricing.tsx index 51301da5c2..c25d821e0e 100644 --- a/editor/www/pricing/pricing.tsx +++ b/editor/www/pricing/pricing.tsx @@ -2,7 +2,10 @@ 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"; diff --git a/supabase/migrations/20260506132900_grida_billing.sql b/supabase/migrations/20260506132900_grida_billing.sql index c9731aa671..351b17e143 100644 --- a/supabase/migrations/20260506132900_grida_billing.sql +++ b/supabase/migrations/20260506132900_grida_billing.sql @@ -109,11 +109,13 @@ CREATE TABLE grida_billing.product_catalogue ( surface text NOT NULL DEFAULT '*' CHECK (surface IN ('editor','cors','*')), stripe_product_id text, stripe_price_id text, - -- Per-month per-seat price for kind='plan'. - per_seat_price_cents integer CHECK (per_seat_price_cents IS NULL OR per_seat_price_cents >= 0), - created_at timestamptz NOT NULL DEFAULT now(), + -- 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 per_seat_price_cents IS NOT NULL + kind <> 'plan' OR unit_amount_cents IS NOT NULL ) ); @@ -127,12 +129,12 @@ CREATE POLICY default_deny_anon ON grida_billing.product_catalogue AS R 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, per_seat_price_cents) VALUES +INSERT INTO grida_billing.product_catalogue (id, kind, unit_amount_cents) VALUES ('plan.free', 'plan', 0), - ('plan.pro', 'plan', 2000), -- $20 / seat / mo - ('plan.team', 'plan', 6000), -- $60 / seat / mo - ('plan.pro.annual', 'plan', 19200), -- $192 / seat / yr (20% off $240) - ('plan.team.annual', 'plan', 57600) -- $576 / seat / yr (20% off $720) + ('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; @@ -370,166 +372,12 @@ END; $$; --- Seat-sync triggers ---------------------------------------------------------- --- Bumping subscription.quantity when membership changes is a billing concern. - ---------------------------------------------------------------------- --- [grida_billing.fn_seat_add] --- Internal: bump quantity for org's active pro/team sub. ---------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION grida_billing.fn_seat_add( - p_org_id bigint, - p_member_user_id uuid -) -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public -AS $$ -DECLARE - v_sub_id uuid; - v_quantity integer; -BEGIN - SELECT id, quantity INTO v_sub_id, v_quantity - FROM grida_billing.subscription - WHERE organization_id = p_org_id - AND is_free = false - AND status <> 'canceled' - LIMIT 1; - - IF v_sub_id IS NULL THEN - RETURN; - END IF; - - UPDATE grida_billing.subscription - SET quantity = quantity + 1, - updated_at = now() - WHERE id = v_sub_id; - - INSERT INTO grida_billing.audit ( - organization_id, user_id, operation, - member_user_id, prev_quantity, new_quantity, note - ) VALUES ( - p_org_id, (SELECT auth.uid()), 'seat_add', - p_member_user_id, v_quantity, v_quantity + 1, 'pending stripe sync' - ); -END; -$$; - - ---------------------------------------------------------------------- --- [grida_billing.fn_seat_remove] --- Internal: decrement quantity for org's active pro/team sub. ---------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION grida_billing.fn_seat_remove( - p_org_id bigint, - p_member_user_id uuid -) -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public -AS $$ -DECLARE - v_sub_id uuid; - v_quantity integer; -BEGIN - SELECT id, quantity INTO v_sub_id, v_quantity - FROM grida_billing.subscription - WHERE organization_id = p_org_id - AND is_free = false - AND status <> 'canceled' - LIMIT 1; - - IF v_sub_id IS NULL THEN - RETURN; - END IF; - - UPDATE grida_billing.subscription - SET quantity = greatest(quantity - 1, 1), - updated_at = now() - WHERE id = v_sub_id; - - INSERT INTO grida_billing.audit ( - organization_id, user_id, operation, - member_user_id, prev_quantity, new_quantity, note - ) VALUES ( - p_org_id, (SELECT auth.uid()), 'seat_remove', - p_member_user_id, v_quantity, greatest(v_quantity - 1, 1), 'pending stripe sync' - ); -END; -$$; - - ---------------------------------------------------------------------- --- [grida_billing.tg_organization_member_after_insert] ---------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION grida_billing.tg_organization_member_after_insert() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public -AS $$ -BEGIN - PERFORM grida_billing.fn_seat_add(NEW.organization_id, NEW.user_id); - RETURN NULL; -END; -$$; - -CREATE TRIGGER tg_billing_organization_member_after_insert - AFTER INSERT ON public.organization_member - FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_organization_member_after_insert(); - - ---------------------------------------------------------------------- --- [grida_billing.tg_organization_member_after_delete] ---------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION grida_billing.tg_organization_member_after_delete() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public -AS $$ -BEGIN - PERFORM grida_billing.fn_seat_remove(OLD.organization_id, OLD.user_id); - RETURN NULL; -END; -$$; - -CREATE TRIGGER tg_billing_organization_member_after_delete - AFTER DELETE ON public.organization_member - FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_organization_member_after_delete(); - - ---------------------------------------------------------------------- --- [grida_billing.tg_organization_member_after_update] --- Defensive: org transfer (organization_id change) maps to a --- delete-from-old + add-to-new pair. user_id changes (account merge) --- don't affect quantity since the seat is preserved. ---------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION grida_billing.tg_organization_member_after_update() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public -AS $$ -BEGIN - IF NEW.organization_id IS DISTINCT FROM OLD.organization_id THEN - PERFORM grida_billing.fn_seat_remove(OLD.organization_id, OLD.user_id); - PERFORM grida_billing.fn_seat_add(NEW.organization_id, NEW.user_id); - END IF; - RETURN NULL; -END; -$$; - -CREATE TRIGGER tg_billing_organization_member_after_update - AFTER UPDATE ON public.organization_member - FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_organization_member_after_update(); +-- 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. --------------------------------------------------------------------- @@ -939,39 +787,6 @@ WHERE a.organization_id IN ( GRANT SELECT ON public.v_billing_audit TO authenticated, service_role; ---------------------------------------------------------------------- --- [public.v_billing_seat_drift] --- Operator (service_role) view: orgs whose subscription.quantity --- does not match count(organization_member). Surfaces seat-sync --- divergence (TC-BILLING-SUB-031). ---------------------------------------------------------------------- - -CREATE OR REPLACE VIEW public.v_billing_seat_drift -WITH (security_invoker = false) -AS -SELECT - s.organization_id, - s.plan, - s.status, - s.quantity AS db_quantity, - coalesce(mc.member_count, 0)::integer AS member_count, - (s.quantity - coalesce(mc.member_count, 0))::integer AS drift, - s.stripe_subscription_id, - s.updated_at -FROM grida_billing.subscription s -LEFT JOIN LATERAL ( - SELECT count(*)::integer AS member_count - FROM public.organization_member om - WHERE om.organization_id = s.organization_id -) mc ON true -WHERE s.is_free = false - AND s.status NOT IN ('canceled') - AND s.quantity <> coalesce(mc.member_count, 0); - -REVOKE ALL ON public.v_billing_seat_drift FROM PUBLIC, authenticated, anon; -GRANT SELECT ON public.v_billing_seat_drift TO service_role; - - --------------------------------------------------------------------- -- [public.fn_billing_apply_stripe_event] --------------------------------------------------------------------- diff --git a/supabase/schemas/grida_billing.sql b/supabase/schemas/grida_billing.sql index c9731aa671..351b17e143 100644 --- a/supabase/schemas/grida_billing.sql +++ b/supabase/schemas/grida_billing.sql @@ -109,11 +109,13 @@ CREATE TABLE grida_billing.product_catalogue ( surface text NOT NULL DEFAULT '*' CHECK (surface IN ('editor','cors','*')), stripe_product_id text, stripe_price_id text, - -- Per-month per-seat price for kind='plan'. - per_seat_price_cents integer CHECK (per_seat_price_cents IS NULL OR per_seat_price_cents >= 0), - created_at timestamptz NOT NULL DEFAULT now(), + -- 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 per_seat_price_cents IS NOT NULL + kind <> 'plan' OR unit_amount_cents IS NOT NULL ) ); @@ -127,12 +129,12 @@ CREATE POLICY default_deny_anon ON grida_billing.product_catalogue AS R 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, per_seat_price_cents) VALUES +INSERT INTO grida_billing.product_catalogue (id, kind, unit_amount_cents) VALUES ('plan.free', 'plan', 0), - ('plan.pro', 'plan', 2000), -- $20 / seat / mo - ('plan.team', 'plan', 6000), -- $60 / seat / mo - ('plan.pro.annual', 'plan', 19200), -- $192 / seat / yr (20% off $240) - ('plan.team.annual', 'plan', 57600) -- $576 / seat / yr (20% off $720) + ('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; @@ -370,166 +372,12 @@ END; $$; --- Seat-sync triggers ---------------------------------------------------------- --- Bumping subscription.quantity when membership changes is a billing concern. - ---------------------------------------------------------------------- --- [grida_billing.fn_seat_add] --- Internal: bump quantity for org's active pro/team sub. ---------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION grida_billing.fn_seat_add( - p_org_id bigint, - p_member_user_id uuid -) -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public -AS $$ -DECLARE - v_sub_id uuid; - v_quantity integer; -BEGIN - SELECT id, quantity INTO v_sub_id, v_quantity - FROM grida_billing.subscription - WHERE organization_id = p_org_id - AND is_free = false - AND status <> 'canceled' - LIMIT 1; - - IF v_sub_id IS NULL THEN - RETURN; - END IF; - - UPDATE grida_billing.subscription - SET quantity = quantity + 1, - updated_at = now() - WHERE id = v_sub_id; - - INSERT INTO grida_billing.audit ( - organization_id, user_id, operation, - member_user_id, prev_quantity, new_quantity, note - ) VALUES ( - p_org_id, (SELECT auth.uid()), 'seat_add', - p_member_user_id, v_quantity, v_quantity + 1, 'pending stripe sync' - ); -END; -$$; - - ---------------------------------------------------------------------- --- [grida_billing.fn_seat_remove] --- Internal: decrement quantity for org's active pro/team sub. ---------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION grida_billing.fn_seat_remove( - p_org_id bigint, - p_member_user_id uuid -) -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public -AS $$ -DECLARE - v_sub_id uuid; - v_quantity integer; -BEGIN - SELECT id, quantity INTO v_sub_id, v_quantity - FROM grida_billing.subscription - WHERE organization_id = p_org_id - AND is_free = false - AND status <> 'canceled' - LIMIT 1; - - IF v_sub_id IS NULL THEN - RETURN; - END IF; - - UPDATE grida_billing.subscription - SET quantity = greatest(quantity - 1, 1), - updated_at = now() - WHERE id = v_sub_id; - - INSERT INTO grida_billing.audit ( - organization_id, user_id, operation, - member_user_id, prev_quantity, new_quantity, note - ) VALUES ( - p_org_id, (SELECT auth.uid()), 'seat_remove', - p_member_user_id, v_quantity, greatest(v_quantity - 1, 1), 'pending stripe sync' - ); -END; -$$; - - ---------------------------------------------------------------------- --- [grida_billing.tg_organization_member_after_insert] ---------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION grida_billing.tg_organization_member_after_insert() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public -AS $$ -BEGIN - PERFORM grida_billing.fn_seat_add(NEW.organization_id, NEW.user_id); - RETURN NULL; -END; -$$; - -CREATE TRIGGER tg_billing_organization_member_after_insert - AFTER INSERT ON public.organization_member - FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_organization_member_after_insert(); - - ---------------------------------------------------------------------- --- [grida_billing.tg_organization_member_after_delete] ---------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION grida_billing.tg_organization_member_after_delete() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public -AS $$ -BEGIN - PERFORM grida_billing.fn_seat_remove(OLD.organization_id, OLD.user_id); - RETURN NULL; -END; -$$; - -CREATE TRIGGER tg_billing_organization_member_after_delete - AFTER DELETE ON public.organization_member - FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_organization_member_after_delete(); - - ---------------------------------------------------------------------- --- [grida_billing.tg_organization_member_after_update] --- Defensive: org transfer (organization_id change) maps to a --- delete-from-old + add-to-new pair. user_id changes (account merge) --- don't affect quantity since the seat is preserved. ---------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION grida_billing.tg_organization_member_after_update() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public -AS $$ -BEGIN - IF NEW.organization_id IS DISTINCT FROM OLD.organization_id THEN - PERFORM grida_billing.fn_seat_remove(OLD.organization_id, OLD.user_id); - PERFORM grida_billing.fn_seat_add(NEW.organization_id, NEW.user_id); - END IF; - RETURN NULL; -END; -$$; - -CREATE TRIGGER tg_billing_organization_member_after_update - AFTER UPDATE ON public.organization_member - FOR EACH ROW EXECUTE FUNCTION grida_billing.tg_organization_member_after_update(); +-- 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. --------------------------------------------------------------------- @@ -939,39 +787,6 @@ WHERE a.organization_id IN ( GRANT SELECT ON public.v_billing_audit TO authenticated, service_role; ---------------------------------------------------------------------- --- [public.v_billing_seat_drift] --- Operator (service_role) view: orgs whose subscription.quantity --- does not match count(organization_member). Surfaces seat-sync --- divergence (TC-BILLING-SUB-031). ---------------------------------------------------------------------- - -CREATE OR REPLACE VIEW public.v_billing_seat_drift -WITH (security_invoker = false) -AS -SELECT - s.organization_id, - s.plan, - s.status, - s.quantity AS db_quantity, - coalesce(mc.member_count, 0)::integer AS member_count, - (s.quantity - coalesce(mc.member_count, 0))::integer AS drift, - s.stripe_subscription_id, - s.updated_at -FROM grida_billing.subscription s -LEFT JOIN LATERAL ( - SELECT count(*)::integer AS member_count - FROM public.organization_member om - WHERE om.organization_id = s.organization_id -) mc ON true -WHERE s.is_free = false - AND s.status NOT IN ('canceled') - AND s.quantity <> coalesce(mc.member_count, 0); - -REVOKE ALL ON public.v_billing_seat_drift FROM PUBLIC, authenticated, anon; -GRANT SELECT ON public.v_billing_seat_drift TO service_role; - - --------------------------------------------------------------------- -- [public.fn_billing_apply_stripe_event] --------------------------------------------------------------------- diff --git a/supabase/tests/test_grida_billing_test.sql b/supabase/tests/test_grida_billing_test.sql index cc760cbf76..8d37a72098 100644 --- a/supabase/tests/test_grida_billing_test.sql +++ b/supabase/tests/test_grida_billing_test.sql @@ -2,11 +2,11 @@ -- -- Covers: provisioning trigger, Stripe projector (subscription, invoice, -- dispute, idempotency), org-delete guard, RLS on --- public.v_billing_subscription / v_billing_audit / v_billing_seat_drift. +-- public.v_billing_subscription / v_billing_audit. BEGIN; -SELECT plan(38); +SELECT plan(37); -- Stash seed UUIDs (regenerated on every `supabase db reset`). DO $$ @@ -279,18 +279,6 @@ SELECT lives_ok($$ DELETE FROM public.organization WHERE id = 1 $$, 'org deletes after Stripe sub canceled'); --- --------------------------------------------------------------------- --- 8. v_billing_seat_drift requires service_role. --- --------------------------------------------------------------------- - -SET LOCAL ROLE authenticated; -SELECT set_config('request.jwt.claim.sub', current_setting('test.alice_uid'), true); -SELECT throws_ok($$ - SELECT * FROM public.v_billing_seat_drift -$$, '42501', NULL, - 'authenticated cannot SELECT v_billing_seat_drift'); -RESET ROLE; - -- --------------------------------------------------------------------- -- 9. RLS on v_billing_subscription. (Local org was deleted above, use acme.) -- --------------------------------------------------------------------- From 0e13140c2371b8becb6823dd07893ec0ab26e656 Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 7 May 2026 20:51:37 +0900 Subject: [PATCH 17/18] review(billing): address review feedback + UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projector hardening (SQL): - Fail-closed mapping for unknown Stripe price ids — refuse to default to 'pro' - SELECT FOR UPDATE on stripe_event so concurrent deliveries serialise - IF NOT FOUND in fn_attach_stripe_customer (refuse silent "attached") - Preserve incomplete*/incomplete_expired in invoice.payment_failed handler - Safe SECURITY DEFINER search_path = pg_catalog, public Server actions: - Server-side guard rejects plan-change while past_due/incomplete/paused - Stripe customer create now uses idempotencyKey + post-create recheck - Composite (created_at, id) cursor on listBillingAudit - New resumeSubscription server action: undoes cancel_at_period_end without charge, projects via webhook (no Portal flow_data exists for this) UI: - Dedicated /billing/return callback page owns post-Stripe-flow polling + redirect. Fullscreen loading spinner only; main billing page no longer watches ?subscribe=success or runs the polling loop - Cancel moved to bottom-of-page Danger zone; primary CTAs stay clean - Resume subscription button surfaces inline when cancel_at_period_end=true. Optimistic UI flip; webhook reconciles - Annual price label includes monthly equivalent ($192/yr (~$16/mo)) - First-load error renders before skeleton gate so Retry is reachable - Sandbox disclaimer gated on BILLING_TEST_MODE - Settings sidebar fixes double-active when on /upgrade Marketing/docs (single-seat v1 reality): - Strip per-seat language from pricing tiles, FAQ, billing.mdx examples - Disable "Buy extra credits" advert until top-up flow ships - SEO frontmatter keywords on billing.mdx; fix dead refund-policy link Tooling/tests: - Fix catalogueId import in setup-stripe-test.ts (renamed price_catalogue_id) - Org-scoped /_/settings collision: drop ambiguous org settings shorthand - buildUniversalDestination throws on missing project/document context - E2E fixtures: rethrow Stripe cleanup failures, read body as text first - pgTAP wires fn_billing_setup_product before subscription.created tests - Env precedence comments fixed (highest priority wins) Known issue documented: - TC-BILLING-SUB-059: concurrent checkout sessions can produce duplicate live Stripe subs. Risk is to Grida (manual refund), not customer. Closure tracked in GRIDA-60. --- docs/contributing/billing.md | 2 +- docs/platform/billing.mdx | 77 ++++---- editor/.env.test | 9 +- .../[organization_name]/settings/_shell.tsx | 14 +- .../settings/billing/_actions.ts | 85 ++++++++- .../settings/billing/_view.tsx | 171 +++++++++++------- .../settings/billing/return/_view.tsx | 121 +++++++++++++ .../settings/billing/return/page.tsx | 39 ++++ .../settings/billing/upgrade/_view.tsx | 4 +- .../(www)/(pricing)/pricing/_sections/faq.tsx | 16 +- editor/host/url.ts | 20 +- .../__tests__/e2e/fixtures/deliver-event.ts | 9 +- .../lib/billing/__tests__/e2e/fixtures/org.ts | 16 +- editor/lib/billing/index.ts | 29 ++- editor/lib/billing/marketing-plans.ts | 8 +- editor/scripts/billing/setup-stripe-test.ts | 6 +- editor/vitest.config.ts | 6 +- editor/www/data/pricing.ts | 9 +- .../20260506132900_grida_billing.sql | 73 +++++--- supabase/schemas/grida_billing.sql | 73 +++++--- supabase/tests/test_grida_billing_test.sql | 4 + test/billing-subscription-and-stripe.md | 9 + 22 files changed, 605 insertions(+), 195 deletions(-) create mode 100644 editor/app/(site)/organizations/[organization_name]/settings/billing/return/_view.tsx create mode 100644 editor/app/(site)/organizations/[organization_name]/settings/billing/return/page.tsx diff --git a/docs/contributing/billing.md b/docs/contributing/billing.md index ba6666dd49..319334e8a8 100644 --- a/docs/contributing/billing.md +++ b/docs/contributing/billing.md @@ -90,7 +90,7 @@ A few names that don't change and are useful to know: 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: [`test/billing-*.md`](../../test/). +User-facing billing docs: [`docs/platform/billing.mdx`](../platform/billing.mdx). Behaviour test cases live at `test/billing-*.md` in the repo root. --- diff --git a/docs/platform/billing.mdx b/docs/platform/billing.mdx index a7ad8723c8..ab874b5ba1 100644 --- a/docs/platform/billing.mdx +++ b/docs/platform/billing.mdx @@ -1,6 +1,15 @@ --- 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 --- @@ -15,18 +24,18 @@ This page explains how Grida charges you, how AI credits work, and what to expec Grida has four plans. -| Plan | Price | Monthly AI credit (per seat) | -| ---------- | -------------------------- | --------------------------------------- | -| Free | $0 | $0.50 (shared by everyone in your org) | -| Pro | **$20 per seat / month** | **$10 per seat**, pooled across the org | -| Team | **$60 per seat / month** | **$35 per seat**, pooled across the org | -| Enterprise | Custom (from $599 / month) | Custom | +| 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 | -**Pro and Team are both seat-based.** Every member of your organization is a paid seat, including the owner. Solo users are just a 1-seat subscription; a 5-person team is 5 seats. AI credit pools at the **organization** level, not per individual — anyone on the team can spend any of it. +**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 each seat carries beyond the base price: Team gets a bigger AI credit allotment, more storage, more monthly active users on published projects, and chat support. Both plans charge per seat and prorate the same way; you can switch between them whenever your team's needs change. +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**. The annual discount comes out of platform margin — your monthly AI credit is the same on monthly or annual. +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 @@ -88,23 +97,23 @@ Each month we try to charge your card for the renewal. If the charge fails (expi ## Working with a team -Pro and Team both charge per seat — and everyone in your org shares the same credit pool, the same way Cursor Teams or Linear handle it. +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: -- **Every member of a paid org is a paid seat.** Each seat charges the per-seat price and contributes its credit allotment to the shared pool. The owner counts as a seat too. -- **Inviting someone to your org adds a seat.** Their cost is prorated for the rest of the current period — e.g., adding Bob halfway through the month adds about half a seat's price to your next invoice. -- **Removing someone from your org removes their seat next period.** The current period's credit pool is unaffected (it was already paid for and minted at the start of the period). Stripe issues a small prorated credit for the unused portion of their seat. -- **Pending invitations don't count as seats.** A seat starts costing only when the person accepts and joins the org. -- **Free orgs can have multiple members.** They all share one $0.50 monthly credit pool — same total whether your org has 1 person or 5. If you need more credit, upgrade to Pro or Team and seats scale automatically. -- **Only the org owner can manage seats and billing** today. Member-role permissions are coming. +- **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 charge per seat and pool credit at the org level. Pick based on what each seat needs: +Both plans pool credit at the org level. Pick based on how much AI your team uses: -- **Pro** ($20/seat) is for individuals and small teams whose AI usage fits comfortably in $10/seat/month, or who plan to top up occasionally. -- **Team** ($60/seat) is for teams that lean heavily on AI — the larger $35/seat credit pool means less time spent topping up, and the plan also raises storage and monthly-active-user limits. +- **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. @@ -134,9 +143,9 @@ Maya has $0.50 of credit at the start of the month. 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 (solo) +### Jordan upgrades to Pro -Jordan is the only person in their org. Upgrading to Pro buys 1 seat — $20/month, $10 of monthly AI credit. +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. @@ -151,22 +160,14 @@ Jordan keeps working. When the next month begins, $10 of fresh Pro credit lands 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/seat × 4 = $80/month with $40 of pooled credit) but the team generates a lot of images, so she upgrades to **Team** instead. +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 seat count is set automatically to **4** (everyone currently in the org). -- The first invoice is **$240** (4 × $60). -- The team's credit pool jumps to **$140/month** (4 × $35), shared across all 4 people. Anyone on the team can spend any of it. +- 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. +A week later, they invite a fifth person. The invoice doesn't change — the flat $60/month covers the whole org. -- Bob accepts the invitation. Stripe charges a **prorated $30** for the rest of the period. -- The seat count is now 5. Next month's pool will be **$175** (5 × $35). - -A month later, someone leaves the team. Priya removes them. - -- The seat count drops to 4. Stripe issues a **prorated credit** of about $20 to the next invoice. -- The current month's $175 pool is unchanged (it was already paid for and minted at the start of the period). -- Next month's pool will be **$140**. +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 @@ -191,7 +192,7 @@ No. Monthly credit (Free, Pro, or Team) resets each billing period. This is how 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 the [refund policy](/support/refund-policy). The short version: we'll work with you on accidental top-ups and obvious billing errors. +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. @@ -200,7 +201,7 @@ You keep your plan and your remaining monthly credit until the end of the period 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 per-seat base difference (the part of the seat price that isn't AI credit), not on AI usage. +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. @@ -212,10 +213,10 @@ Major credit and debit cards. Apple Pay and Google Pay where supported by your b 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?** -Pro and Team are both per-seat. Every member of your org is a seat, including the owner. Adding a member adds a seat (prorated); removing one credits the next invoice. AI credit is shared across the team. Pick Pro for lighter AI usage, Team for heavier — you can switch any time. +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 are seat-based and both pool credit at the org level. Team raises the per-seat 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. +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. diff --git a/editor/.env.test b/editor/.env.test index fdef35c647..f39f50ed6b 100644 --- a/editor/.env.test +++ b/editor/.env.test @@ -8,10 +8,11 @@ # 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 (later overrides earlier): -# 1. process.env (from your shell) -# 2. .env.test ← this file (committed) -# 3. .env.test.local ← per-developer secrets (gitignored) +# 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 diff --git a/editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx b/editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx index 0476cd90df..d315cf3e74 100644 --- a/editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx +++ b/editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx @@ -67,8 +67,18 @@ export default function SettingsShell({ {categories.map((c) => { - const isActive = - pathname === c.href || pathname?.startsWith(`${c.href}/`); + // 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 ( diff --git a/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts b/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts index 21e9ad48bf..7adfe3b0a1 100644 --- a/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts @@ -63,6 +63,9 @@ export type BillingSummary = { 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( @@ -102,6 +105,7 @@ export async function getBillingSummary( has_active_subscription: !!sub?.stripe_subscription_id && sub.status !== "canceled", interval, + is_test_mode: process.env.BILLING_TEST_MODE === "true", }; } @@ -324,6 +328,18 @@ export async function startPlanChangeConfirm( "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) { @@ -449,6 +465,14 @@ export async function startSubscribeCheckout( // 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)}`; @@ -539,6 +563,40 @@ export async function startCancelSubscription( 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 // @@ -582,6 +640,20 @@ export async function listBillingAudit( 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") @@ -593,14 +665,23 @@ export async function listBillingAudit( .order("id", { ascending: false }) .limit(limit); - if (cursor) q = q.lt("created_at", cursor); + 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 ? rows[rows.length - 1].created_at : null; + 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/_view.tsx b/editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx index 315cc89e2c..0689c0421e 100644 --- a/editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx @@ -1,7 +1,6 @@ "use client"; import React, { useCallback, useEffect, useState } from "react"; -import { useSearchParams } from "next/navigation"; import Link from "next/link"; import { toast } from "sonner"; import { CreditCardIcon, ExternalLinkIcon, FileTextIcon } from "lucide-react"; @@ -29,12 +28,14 @@ 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, @@ -49,6 +50,7 @@ type BillingState = { current_period_end: string | null; cancel_at_period_end: boolean; has_active_subscription: boolean; + is_test_mode: boolean; }; type PaymentMethod = { @@ -120,7 +122,6 @@ export default function BillingView({ orgId: number; orgName: string; }) { - const searchParams = useSearchParams(); const [state, setState] = useState(null); const [invoices, setInvoices] = useState(null); const [loading, setLoading] = useState(true); @@ -166,47 +167,16 @@ export default function BillingView({ void refresh(); }, [refresh]); - useEffect(() => { - const sub = searchParams.get("subscribe"); - if (sub === "success") { - toast.success("Welcome!", { - description: "Your subscription is provisioning.", - }); - } - }, [searchParams]); - - // After Stripe Checkout / Portal return, poll the read view a few times so - // the UI catches the webhook-driven state change. This NEVER reaches out to - // Stripe — the webhook is the only source of truth. If the webhook is slow - // or missing (e.g., `stripe listen` not running), the page stays stale; the - // fix is to deliver the webhook, not to bypass it from the client. - useEffect(() => { - const sub = searchParams.get("subscribe"); - if (sub !== "success") return; - - let cancelled = false; - let attempts = 0; - const interval = setInterval(() => { - attempts += 1; - if (cancelled || attempts > 5) { - clearInterval(interval); - return; - } - void refresh(); - }, 2_000); - - return () => { - cancelled = true; - clearInterval(interval); - }; - }, [searchParams, 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 { - // `?subscribe=success` triggers the polling effect on return so the UI - // catches the webhook-driven status flip from past_due → active. + // 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}?subscribe=success`, + return_url: `${window.location.origin}${baseUrl}/return?intent=payment_method`, }); window.location.href = result.portal_url; } catch (e) { @@ -219,7 +189,7 @@ export default function BillingView({ const cancelSubscription = useCallback(async () => { try { const result = await startCancelSubscription(orgId, { - return_url: `${window.location.origin}${baseUrl}?subscribe=success`, + return_url: `${window.location.origin}${baseUrl}`, }); window.location.href = result.portal_url; } catch (e) { @@ -229,6 +199,45 @@ export default function BillingView({ } }, [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 (
@@ -244,26 +253,15 @@ export default function BillingView({ ); } - if (err) { - return ( -
-

Billing

-

Failed to load billing: {err}

- -
- ); - } - 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. + // 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_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` @@ -371,7 +369,9 @@ export default function BillingView({ )} {/* Plan changes blocked while billing is in a degraded state — - Stripe rejects price-change on past_due/incomplete subs. */} + 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 && ( )} - {isPaid && !state.cancel_at_period_end && !isPastDue && ( - )} @@ -500,13 +502,48 @@ export default function BillingView({
+ + {/* 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. +

+
+ +
+
+
+ )} - -

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

+ {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/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 index 04334fe26f..69490d4df8 100644 --- a/editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx @@ -88,7 +88,9 @@ export default function UpgradeView({ const result = await startSubscribeCheckout(orgId, { plan: plan.id, interval, - success_url: `${origin}${baseUrl}?subscribe=success`, + // 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) { diff --git a/editor/app/(www)/(pricing)/pricing/_sections/faq.tsx b/editor/app/(www)/(pricing)/pricing/_sections/faq.tsx index e8a48f7a15..992f70d3de 100644 --- a/editor/app/(www)/(pricing)/pricing/_sections/faq.tsx +++ b/editor/app/(www)/(pricing)/pricing/_sections/faq.tsx @@ -13,10 +13,10 @@ const faqs: { question: string; answer: React.ReactNode }[] = [ answer: ( <> Each plan includes a monthly AI credit — $0.50 on Free,{" "} - $10 per seat on Pro, $35 per seat on - Team. AI features draw from this balance at the model provider's - cost; we never mark up AI usage. Unused monthly credit resets at the - start of the next billing period.{" "} + $10 on Pro, $35 on Team. AI features + draw from this balance at the model provider's cost; we never mark + up AI usage. Unused monthly credit resets at the start of the next + billing period.{" "} /` and require only org context (no project, - * no document). Settings, members, billing all belong here. + * 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: { scope: "organization" }, "settings/billing": { scope: "organization" }, "settings/billing/upgrade": { scope: "organization" }, people: { scope: "organization" }, @@ -331,13 +335,23 @@ export function buildUniversalDestination( 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/lib/billing/__tests__/e2e/fixtures/deliver-event.ts b/editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts index 53e7d52d58..362bece3ee 100644 --- a/editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts +++ b/editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts @@ -72,11 +72,16 @@ export async function deliverEvent( }, 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 = (await res.json()) as DeliverResult["body"]; + body = JSON.parse(raw) as DeliverResult["body"]; } catch { - body = { error: `non-json: ${await res.text()}` }; + 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 index 2ec33e5164..e5f208762d 100644 --- a/editor/lib/billing/__tests__/e2e/fixtures/org.ts +++ b/editor/lib/billing/__tests__/e2e/fixtures/org.ts @@ -78,18 +78,22 @@ export async function teardownOrg(org: EphemeralOrg): Promise { } } } catch (err) { - console.warn( - `[e2e/org] subscription cleanup: ${err instanceof Error ? err.message : 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); - if (!/No such customer|resource_missing/i.test(msg)) { - console.warn(`[e2e/org] customer delete: ${msg}`); - } + // "Already deleted" is fine; everything 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)) return; + throw new Error(`[e2e/org] customer delete failed: ${msg}`); } } diff --git a/editor/lib/billing/index.ts b/editor/lib/billing/index.ts index 0c5984bf46..754ef71793 100644 --- a/editor/lib/billing/index.ts +++ b/editor/lib/billing/index.ts @@ -269,8 +269,13 @@ export async function getActivePaidSubscription(org_id: number): Promise<{ } // Resolve `account.stripe_customer_id` for an org; mint + persist if absent. -// Race-safe via `fn_billing_attach_stripe_customer` — concurrent callers -// converge on the first-written id. +// 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 { @@ -286,11 +291,21 @@ export async function resolveOrCreateStripeCustomer( 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) }, - }); + 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", diff --git a/editor/lib/billing/marketing-plans.ts b/editor/lib/billing/marketing-plans.ts index b3e4848f66..25645abcab 100644 --- a/editor/lib/billing/marketing-plans.ts +++ b/editor/lib/billing/marketing-plans.ts @@ -71,7 +71,7 @@ export const plans: PricingInformation[] = [ name: "Pro", highlight: true, nameBadge: "Most Popular", - costUnit: "per seat/month", + costUnit: "per month", href: "/dashboard/new?plan=pro", priceLabel: "From", warning: "$10 in compute credits included", @@ -83,7 +83,7 @@ export const plans: PricingInformation[] = [ { name: "AI Credits", trail: "10,000" }, { name: "Designs", trail: "♾️" }, { name: "Forms", trail: "♾️" }, - { name: "Seats", trail: "♾️" }, + { name: "Seats", trail: "1" }, { name: "30GB Storage" }, { name: "Email support" }, ], @@ -93,7 +93,7 @@ export const plans: PricingInformation[] = [ id: "tier_team", name: "Team", nameBadge: "", - costUnit: "per seat/month", + costUnit: "per month", href: "/dashboard/new?plan=team", priceLabel: "From", warning: "$10 in compute credits included", @@ -105,7 +105,7 @@ export const plans: PricingInformation[] = [ { name: "AI Credits", trail: "35,000" }, { name: "Designs", trail: "♾️" }, { name: "Forms", trail: "♾️" }, - { name: "Seats", trail: "♾️" }, + { name: "Seats", trail: "1" }, { name: "500GB Storage" }, { name: "Chat support" }, ], diff --git a/editor/scripts/billing/setup-stripe-test.ts b/editor/scripts/billing/setup-stripe-test.ts index d83faa4aa0..7afff3d7f7 100644 --- a/editor/scripts/billing/setup-stripe-test.ts +++ b/editor/scripts/billing/setup-stripe-test.ts @@ -65,7 +65,7 @@ async function main(): Promise { // Numeric prices come from `lib/billing/plans.ts` (single source of // truth) — never hardcode them here. - const { PAID_PLAN_LIST, catalogueId } = + const { PAID_PLAN_LIST, price_catalogue_id } = await import("../../lib/billing/plans"); type Interval = "month" | "year"; @@ -104,7 +104,7 @@ async function main(): Promise { { product: PRODUCTS[p.id], price: { - catalogue_id: catalogueId(p.id, "month"), + catalogue_id: price_catalogue_id(p.id, "month"), interval: "month" as const, unit_amount_cents: p.monthly_cents, nickname: `${p.name} monthly`, @@ -113,7 +113,7 @@ async function main(): Promise { { product: PRODUCTS[p.id], price: { - catalogue_id: catalogueId(p.id, "year"), + catalogue_id: price_catalogue_id(p.id, "year"), interval: "year" as const, unit_amount_cents: p.annual_cents, nickname: `${p.name} annual`, diff --git a/editor/vitest.config.ts b/editor/vitest.config.ts index 98c8fc44d6..05178740aa 100644 --- a/editor/vitest.config.ts +++ b/editor/vitest.config.ts @@ -4,8 +4,10 @@ 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 (later loses): -// .env.test.local > .env.test > shell. Existing process.env always wins. +// 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")) { diff --git a/editor/www/data/pricing.ts b/editor/www/data/pricing.ts index 5239b4c344..e2fb85b263 100644 --- a/editor/www/data/pricing.ts +++ b/editor/www/data/pricing.ts @@ -66,12 +66,15 @@ export const pricing: Pricing = { 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: true, - team: true, - enterprise: true, + pro: false, + team: false, + enterprise: false, }, usage_based: false, }, diff --git a/supabase/migrations/20260506132900_grida_billing.sql b/supabase/migrations/20260506132900_grida_billing.sql index 351b17e143..2e57b38ddd 100644 --- a/supabase/migrations/20260506132900_grida_billing.sql +++ b/supabase/migrations/20260506132900_grida_billing.sql @@ -243,7 +243,7 @@ CREATE OR REPLACE FUNCTION grida_billing.fn_provision_account(p_org_id bigint) RETURNS void LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public +SET search_path = pg_catalog, public AS $$ BEGIN INSERT INTO grida_billing.account (organization_id) @@ -268,7 +268,7 @@ CREATE OR REPLACE FUNCTION grida_billing.tg_provision_on_org_insert() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public +SET search_path = pg_catalog, public AS $$ BEGIN PERFORM grida_billing.fn_provision_account(NEW.id); @@ -300,7 +300,7 @@ CREATE OR REPLACE FUNCTION grida_billing.tg_organization_before_delete() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public +SET search_path = pg_catalog, public AS $$ DECLARE v_active_sub_id text; @@ -344,7 +344,7 @@ RETURNS TABLE ( ) LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public +SET search_path = pg_catalog, public AS $$ DECLARE v_existing text; @@ -355,6 +355,14 @@ BEGIN 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, @@ -408,7 +416,7 @@ RETURNS TABLE ( ) LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public +SET search_path = pg_catalog, public AS $$ DECLARE v_existing grida_billing.stripe_event%ROWTYPE; @@ -434,12 +442,18 @@ BEGIN RAISE EXCEPTION 'fn_apply_stripe_event: missing required argument'; END IF; - -- Idempotency. + -- 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; + 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; @@ -499,17 +513,25 @@ BEGIN v_period_end := to_timestamp(nullif(v_first_item->>'current_period_end', '')::bigint); v_price_id := v_first_item->'price'->>'id'; - v_plan := 'pro'; + -- 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 'pro' + ELSE NULL END INTO v_plan FROM grida_billing.product_catalogue WHERE stripe_price_id = v_price_id; - v_plan := coalesce(v_plan, 'pro'); + 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 @@ -628,16 +650,25 @@ BEGIN 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 = 'past_due', updated_at = now() - WHERE stripe_subscription_id = v_sub_id; + 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, 'past_due', v_attempt_count + v_invoice_id, p_event_type, v_status, v_attempt_count ); v_handler := 'invoice_payment_failed'; END IF; @@ -706,7 +737,7 @@ CREATE OR REPLACE FUNCTION grida_billing.fn_stamp_failure( RETURNS void LANGUAGE sql SECURITY DEFINER -SET search_path = pg_catalog, grida_billing +SET search_path = pg_catalog, public AS $$ UPDATE grida_billing.stripe_event SET failed_at = now(), @@ -799,7 +830,7 @@ CREATE OR REPLACE FUNCTION public.fn_billing_apply_stripe_event( RETURNS TABLE (result text, handler text) LANGUAGE sql SECURITY DEFINER -SET search_path = grida_billing, public, pg_temp +SET search_path = pg_catalog, public AS $$ SELECT * FROM grida_billing.fn_apply_stripe_event(p_event_id, p_event_type, p_payload); $$; @@ -819,7 +850,7 @@ CREATE OR REPLACE FUNCTION public.fn_billing_stamp_failure( RETURNS void LANGUAGE sql SECURITY DEFINER -SET search_path = grida_billing, public, pg_temp +SET search_path = pg_catalog, public AS $$ SELECT grida_billing.fn_stamp_failure(p_event_id, p_reason); $$; @@ -839,7 +870,7 @@ CREATE OR REPLACE FUNCTION public.fn_billing_attach_stripe_customer( RETURNS TABLE (attached boolean, stripe_customer_id text) LANGUAGE sql SECURITY DEFINER -SET search_path = grida_billing, public, pg_temp +SET search_path = pg_catalog, public AS $$ SELECT * FROM grida_billing.fn_attach_stripe_customer(p_org_id, p_stripe_customer_id); $$; @@ -858,7 +889,7 @@ CREATE OR REPLACE FUNCTION public.fn_billing_get_customer_id(p_org_id bigint) RETURNS text LANGUAGE sql SECURITY DEFINER -SET search_path = grida_billing, public, pg_temp +SET search_path = pg_catalog, public AS $$ SELECT stripe_customer_id FROM grida_billing.account WHERE organization_id = p_org_id; $$; @@ -885,7 +916,7 @@ RETURNS TABLE ( ) LANGUAGE sql SECURITY DEFINER -SET search_path = grida_billing, public, pg_temp +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 @@ -915,7 +946,7 @@ RETURNS TABLE ( ) LANGUAGE sql SECURITY DEFINER -SET search_path = grida_billing, public, pg_temp +SET search_path = pg_catalog, public AS $$ SELECT stripe_product_id, stripe_price_id FROM grida_billing.product_catalogue @@ -938,7 +969,7 @@ CREATE OR REPLACE FUNCTION public.fn_billing_setup_product( RETURNS TABLE (id text, stripe_product_id text, stripe_price_id text) LANGUAGE plpgsql SECURITY DEFINER -SET search_path = grida_billing, public, pg_temp +SET search_path = pg_catalog, public AS $$ BEGIN IF p_grida_billing_id NOT IN ( diff --git a/supabase/schemas/grida_billing.sql b/supabase/schemas/grida_billing.sql index 351b17e143..2e57b38ddd 100644 --- a/supabase/schemas/grida_billing.sql +++ b/supabase/schemas/grida_billing.sql @@ -243,7 +243,7 @@ CREATE OR REPLACE FUNCTION grida_billing.fn_provision_account(p_org_id bigint) RETURNS void LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public +SET search_path = pg_catalog, public AS $$ BEGIN INSERT INTO grida_billing.account (organization_id) @@ -268,7 +268,7 @@ CREATE OR REPLACE FUNCTION grida_billing.tg_provision_on_org_insert() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public +SET search_path = pg_catalog, public AS $$ BEGIN PERFORM grida_billing.fn_provision_account(NEW.id); @@ -300,7 +300,7 @@ CREATE OR REPLACE FUNCTION grida_billing.tg_organization_before_delete() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public +SET search_path = pg_catalog, public AS $$ DECLARE v_active_sub_id text; @@ -344,7 +344,7 @@ RETURNS TABLE ( ) LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public +SET search_path = pg_catalog, public AS $$ DECLARE v_existing text; @@ -355,6 +355,14 @@ BEGIN 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, @@ -408,7 +416,7 @@ RETURNS TABLE ( ) LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, grida_billing, public +SET search_path = pg_catalog, public AS $$ DECLARE v_existing grida_billing.stripe_event%ROWTYPE; @@ -434,12 +442,18 @@ BEGIN RAISE EXCEPTION 'fn_apply_stripe_event: missing required argument'; END IF; - -- Idempotency. + -- 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; + 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; @@ -499,17 +513,25 @@ BEGIN v_period_end := to_timestamp(nullif(v_first_item->>'current_period_end', '')::bigint); v_price_id := v_first_item->'price'->>'id'; - v_plan := 'pro'; + -- 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 'pro' + ELSE NULL END INTO v_plan FROM grida_billing.product_catalogue WHERE stripe_price_id = v_price_id; - v_plan := coalesce(v_plan, 'pro'); + 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 @@ -628,16 +650,25 @@ BEGIN 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 = 'past_due', updated_at = now() - WHERE stripe_subscription_id = v_sub_id; + 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, 'past_due', v_attempt_count + v_invoice_id, p_event_type, v_status, v_attempt_count ); v_handler := 'invoice_payment_failed'; END IF; @@ -706,7 +737,7 @@ CREATE OR REPLACE FUNCTION grida_billing.fn_stamp_failure( RETURNS void LANGUAGE sql SECURITY DEFINER -SET search_path = pg_catalog, grida_billing +SET search_path = pg_catalog, public AS $$ UPDATE grida_billing.stripe_event SET failed_at = now(), @@ -799,7 +830,7 @@ CREATE OR REPLACE FUNCTION public.fn_billing_apply_stripe_event( RETURNS TABLE (result text, handler text) LANGUAGE sql SECURITY DEFINER -SET search_path = grida_billing, public, pg_temp +SET search_path = pg_catalog, public AS $$ SELECT * FROM grida_billing.fn_apply_stripe_event(p_event_id, p_event_type, p_payload); $$; @@ -819,7 +850,7 @@ CREATE OR REPLACE FUNCTION public.fn_billing_stamp_failure( RETURNS void LANGUAGE sql SECURITY DEFINER -SET search_path = grida_billing, public, pg_temp +SET search_path = pg_catalog, public AS $$ SELECT grida_billing.fn_stamp_failure(p_event_id, p_reason); $$; @@ -839,7 +870,7 @@ CREATE OR REPLACE FUNCTION public.fn_billing_attach_stripe_customer( RETURNS TABLE (attached boolean, stripe_customer_id text) LANGUAGE sql SECURITY DEFINER -SET search_path = grida_billing, public, pg_temp +SET search_path = pg_catalog, public AS $$ SELECT * FROM grida_billing.fn_attach_stripe_customer(p_org_id, p_stripe_customer_id); $$; @@ -858,7 +889,7 @@ CREATE OR REPLACE FUNCTION public.fn_billing_get_customer_id(p_org_id bigint) RETURNS text LANGUAGE sql SECURITY DEFINER -SET search_path = grida_billing, public, pg_temp +SET search_path = pg_catalog, public AS $$ SELECT stripe_customer_id FROM grida_billing.account WHERE organization_id = p_org_id; $$; @@ -885,7 +916,7 @@ RETURNS TABLE ( ) LANGUAGE sql SECURITY DEFINER -SET search_path = grida_billing, public, pg_temp +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 @@ -915,7 +946,7 @@ RETURNS TABLE ( ) LANGUAGE sql SECURITY DEFINER -SET search_path = grida_billing, public, pg_temp +SET search_path = pg_catalog, public AS $$ SELECT stripe_product_id, stripe_price_id FROM grida_billing.product_catalogue @@ -938,7 +969,7 @@ CREATE OR REPLACE FUNCTION public.fn_billing_setup_product( RETURNS TABLE (id text, stripe_product_id text, stripe_price_id text) LANGUAGE plpgsql SECURITY DEFINER -SET search_path = grida_billing, public, pg_temp +SET search_path = pg_catalog, public AS $$ BEGIN IF p_grida_billing_id NOT IN ( diff --git a/supabase/tests/test_grida_billing_test.sql b/supabase/tests/test_grida_billing_test.sql index 8d37a72098..5f02963e81 100644 --- a/supabase/tests/test_grida_billing_test.sql +++ b/supabase/tests/test_grida_billing_test.sql @@ -49,6 +49,10 @@ SELECT ok( -- --------------------------------------------------------------------- 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), diff --git a/test/billing-subscription-and-stripe.md b/test/billing-subscription-and-stripe.md index b5d8527003..3a2a73a568 100644 --- a/test/billing-subscription-and-stripe.md +++ b/test/billing-subscription-and-stripe.md @@ -343,3 +343,12 @@ Pro annual user, 60 days in (305 days remaining), switches back to monthly via t 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. From 66059572cc0b0273ba8f227f7c25cecc0c384ca1 Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 7 May 2026 21:20:14 +0900 Subject: [PATCH 18/18] review(billing): second-pass review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - e2e teardown: drop early `return` on resource_missing — was skipping the local org delete and leaving orphaned fixtures - fn_stamp_failure: UPSERT + p_event_type. The projector's stripe_event INSERT rolls back with the RAISE, so the prior UPDATE-only stamp silently matched nothing on first failure and the forensic record was lost. Caller passes event.type so the row can be inserted with required type metadata. Defense-in-depth: - Fail closed on stripe_customer_id drift in both fn_attach_stripe_customer and the customer.created/customer.updated projector branch. Mismatched ids now RAISE instead of silently no-oping; Stripe retries while ops fixes the binding. UI polish: - Button asChild around the upgrade Link (avoid invalid - + + )} {/* Resume = undo a pending `cancel_at_period_end`. Stripe charges nothing — the existing sub continues on its current schedule. 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 index 69490d4df8..a7c003e093 100644 --- a/editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx +++ b/editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx @@ -262,7 +262,11 @@ export default function UpgradeView({
{PAID_PLAN_LIST.map((plan) => { - const isCurrent = current?.plan === plan.id; + // 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 diff --git a/editor/lib/billing/__tests__/e2e/fixtures/org.ts b/editor/lib/billing/__tests__/e2e/fixtures/org.ts index e5f208762d..77c59902f6 100644 --- a/editor/lib/billing/__tests__/e2e/fixtures/org.ts +++ b/editor/lib/billing/__tests__/e2e/fixtures/org.ts @@ -89,11 +89,13 @@ export async function teardownOrg(org: EphemeralOrg): Promise { await stripe.customers.del(customerId); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - // "Already deleted" is fine; everything 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)) return; - throw new Error(`[e2e/org] customer delete failed: ${msg}`); + // "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}`); + } } } diff --git a/editor/lib/billing/index.ts b/editor/lib/billing/index.ts index 754ef71793..50fe99e190 100644 --- a/editor/lib/billing/index.ts +++ b/editor/lib/billing/index.ts @@ -402,15 +402,19 @@ export async function dispatchStripeEvent( } // Failure stamps go through a separate RPC so they survive the projector's -// RAISE-driven rollback. +// 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, } ); diff --git a/supabase/migrations/20260506132900_grida_billing.sql b/supabase/migrations/20260506132900_grida_billing.sql index 2e57b38ddd..85dde312fd 100644 --- a/supabase/migrations/20260506132900_grida_billing.sql +++ b/supabase/migrations/20260506132900_grida_billing.sql @@ -373,6 +373,15 @@ BEGIN 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; @@ -464,6 +473,27 @@ BEGIN 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() @@ -728,21 +758,32 @@ $$; -- [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_reason text + p_event_id text, + p_event_type text, + p_reason text ) RETURNS void LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ - UPDATE grida_billing.stripe_event - SET failed_at = now(), - failure_reason = left(p_reason, 2000) - WHERE id = p_event_id; + 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; $$; @@ -843,20 +884,23 @@ GRANT EXECUTE ON FUNCTION public.fn_billing_apply_stripe_event(text, text, jsonb -- [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_reason text + 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_reason); + 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) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.fn_billing_stamp_failure(text, text) TO service_role; +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; --------------------------------------------------------------------- diff --git a/supabase/schemas/grida_billing.sql b/supabase/schemas/grida_billing.sql index 2e57b38ddd..85dde312fd 100644 --- a/supabase/schemas/grida_billing.sql +++ b/supabase/schemas/grida_billing.sql @@ -373,6 +373,15 @@ BEGIN 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; @@ -464,6 +473,27 @@ BEGIN 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() @@ -728,21 +758,32 @@ $$; -- [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_reason text + p_event_id text, + p_event_type text, + p_reason text ) RETURNS void LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ - UPDATE grida_billing.stripe_event - SET failed_at = now(), - failure_reason = left(p_reason, 2000) - WHERE id = p_event_id; + 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; $$; @@ -843,20 +884,23 @@ GRANT EXECUTE ON FUNCTION public.fn_billing_apply_stripe_event(text, text, jsonb -- [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_reason text + 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_reason); + 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) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.fn_billing_stamp_failure(text, text) TO service_role; +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; --------------------------------------------------------------------- diff --git a/supabase/tests/test_grida_billing_test.sql b/supabase/tests/test_grida_billing_test.sql index 5f02963e81..da7e63ba8e 100644 --- a/supabase/tests/test_grida_billing_test.sql +++ b/supabase/tests/test_grida_billing_test.sql @@ -6,7 +6,7 @@ BEGIN; -SELECT plan(37); +SELECT plan(57); -- Stash seed UUIDs (regenerated on every `supabase db reset`). DO $$ @@ -392,5 +392,105 @@ SELECT is( '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;