From 1113af72ac0be648911d8866e42fdaa09c93ce32 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Tue, 24 Mar 2026 19:10:15 +0100 Subject: [PATCH] allow invited to upgrade plan --- .../api/src/__tests__/helpers/helpers.test.ts | 18 +- .../__tests__/middlewares/middlewares.test.ts | 71 +++++++- apps/api/src/__tests__/routes/tablo.test.ts | 53 +++++- apps/api/src/middlewares/middleware.ts | 58 ++++--- apps/api/src/routers/stripe.ts | 2 +- ...2026-03-24-invited-user-billing-upgrade.md | 141 +++++++++++++++ ...324120000_promote_paid_temporary_users.sql | 163 ++++++++++++++++++ supabase/tests/database/05_triggers.test.sql | 127 +++++++++++++- 8 files changed, 591 insertions(+), 42 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-24-invited-user-billing-upgrade.md create mode 100644 supabase/migrations/20260324120000_promote_paid_temporary_users.sql diff --git a/apps/api/src/__tests__/helpers/helpers.test.ts b/apps/api/src/__tests__/helpers/helpers.test.ts index b93cbeb..606d3ce 100644 --- a/apps/api/src/__tests__/helpers/helpers.test.ts +++ b/apps/api/src/__tests__/helpers/helpers.test.ts @@ -5,7 +5,7 @@ import { MAX_TABLO_LIMIT, verifyTabloLimitForUser } from "../../helpers/helpers. const createSupabaseMock = ({ profileData = { organization_id: 1 }, - organizationPlans = [{ plan: "free" }], + organizationPlans = [{ plan: "solo" }], profileError = null, organizationPlanError = null, tabloCount = 0, @@ -20,7 +20,8 @@ const createSupabaseMock = ({ }); const profileSelect = vi.fn(() => ({ eq: profileEq })); - const tabloEq = vi.fn(async () => ({ count: tabloCount, error: tabloError })); + const tabloIs = vi.fn(async () => ({ count: tabloCount, error: tabloError })); + const tabloEq = vi.fn(() => ({ is: tabloIs })); const tabloSelect = vi.fn(() => ({ eq: tabloEq })); const from = vi.fn((table: string) => { @@ -38,6 +39,7 @@ const createSupabaseMock = ({ profileSingle, profileEq, profileSelect, + tabloIs, tabloEq, tabloSelect, }; @@ -79,10 +81,10 @@ describe("verifyTabloLimitForUser", () => { expect(next).not.toHaveBeenCalled(); }); - it("denies free users that reached the tablo limit", async () => { + it("denies solo users that reached the tablo limit", async () => { const supabase = createSupabaseMock({ profileData: { organization_id: 1 }, - organizationPlans: [{ plan: "free" }], + organizationPlans: [{ plan: "solo" }], tabloCount: MAX_TABLO_LIMIT, }); const ctx = createContext(supabase, user); @@ -93,11 +95,11 @@ describe("verifyTabloLimitForUser", () => { expect(next).not.toHaveBeenCalled(); }); - it("allows free users below the limit to proceed", async () => { + it("allows solo users below the limit to proceed", async () => { const belowLimitCount = Math.max(0, MAX_TABLO_LIMIT - 1); const supabase = createSupabaseMock({ profileData: { organization_id: 1 }, - organizationPlans: [{ plan: "free" }], + organizationPlans: [{ plan: "solo" }], tabloCount: belowLimitCount, }); const ctx = createContext(supabase, user); @@ -108,10 +110,10 @@ describe("verifyTabloLimitForUser", () => { expect(ctx.json).not.toHaveBeenCalled(); }); - it("skips tablo count check for non-free plans", async () => { + it("skips tablo count check for non-solo plans", async () => { const supabase = createSupabaseMock({ profileData: { organization_id: 1 }, - organizationPlans: [{ plan: "standard" }], + organizationPlans: [{ plan: "team" }], }); const ctx = createContext(supabase, user); diff --git a/apps/api/src/__tests__/middlewares/middlewares.test.ts b/apps/api/src/__tests__/middlewares/middlewares.test.ts index 52a1a56..71072d6 100644 --- a/apps/api/src/__tests__/middlewares/middlewares.test.ts +++ b/apps/api/src/__tests__/middlewares/middlewares.test.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { testClient } from "hono/testing"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createConfig } from "../../config.js"; import { MiddlewareManager } from "../../middlewares/middleware.js"; @@ -11,6 +11,19 @@ describe("Middleware Tests", () => { MiddlewareManager.initialize(config); const middlewareManager = MiddlewareManager.getInstance(); + const createProfilesSupabaseMock = (result: { + data: { is_temporary: boolean } | null; + error: { message: string } | null; + }) => ({ + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue(result), + }), + }), + }), + }); + describe("Supabase Middleware", () => { it("should inject supabase client into context", async () => { const app = new Hono(); @@ -266,6 +279,62 @@ describe("Middleware Tests", () => { // Should fail due to invalid token in auth middleware expect(res.status).toBe(401); }); + + it("should reject temporary users", async () => { + const app = new Hono(); + app.use(async (c, next) => { + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set( + "supabase", + createProfilesSupabaseMock({ + data: { is_temporary: true }, + error: null, + }) as any + ); + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set("user", { id: "temp-user" } as any); + await next(); + }); + app.use(middlewareManager.regularUserCheck); + app.get("/test", (c) => c.json({ success: true })); + + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + const client = testClient(app) as any; + const res = await client.test.$get(); + const data = await res.json(); + + expect(res.status).toBe(401); + expect(data.error).toBe("User is read only"); + }); + }); + + describe("Billing Checkout Access Middleware", () => { + it("should allow temporary users to continue to billing checkout", async () => { + const app = new Hono(); + app.use(async (c, next) => { + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set( + "supabase", + createProfilesSupabaseMock({ + data: { is_temporary: true }, + error: null, + }) as any + ); + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set("user", { id: "temp-user" } as any); + await next(); + }); + app.use(middlewareManager.billingCheckoutAccess); + app.get("/test", (c) => c.json({ success: true })); + + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + const client = testClient(app) as any; + const res = await client.test.$get(); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.success).toBe(true); + }); }); describe("StreamChat Middleware", () => { diff --git a/apps/api/src/__tests__/routes/tablo.test.ts b/apps/api/src/__tests__/routes/tablo.test.ts index 14be4ea..917c531 100644 --- a/apps/api/src/__tests__/routes/tablo.test.ts +++ b/apps/api/src/__tests__/routes/tablo.test.ts @@ -1,4 +1,5 @@ import { createClient } from "@supabase/supabase-js"; +import { randomUUID } from "node:crypto"; import { testClient } from "hono/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createConfig } from "../../config.js"; @@ -59,6 +60,9 @@ describe("Tablo Endpoint", () => { const ownerUser = getTestUser("owner"); const temporaryUser = getTestUser("temp"); + const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false }, + }); beforeEach(() => { // Reset all mocks before each test @@ -506,8 +510,47 @@ describe("Tablo Endpoint", () => { }); describe("POST /tablos/leave - Leave Tablo", () => { + const createSharedTabloForLeaveTest = async (options: { + ownerId: string; + memberId: string; + memberIsAdmin?: boolean; + }) => { + const tabloId = `test_leave_${randomUUID()}`; + + const { error: tabloError } = await supabaseAdmin.from("tablos").insert({ + id: tabloId, + owner_id: options.ownerId, + name: "Leave Test Tablo", + color: "#334155", + position: 999, + }); + + if (tabloError) { + throw tabloError; + } + + const { error: accessError } = await supabaseAdmin.from("tablo_access").insert({ + tablo_id: tabloId, + user_id: options.memberId, + granted_by: options.ownerId, + is_active: true, + is_admin: options.memberIsAdmin ?? false, + }); + + if (accessError) { + throw accessError; + } + + return tabloId; + }; + it("should allow temp user to leave a shared tablo and remove from Stream Chat channel", async () => { - const res = await leaveTabloRequest(temporaryUser, client, "test_tablo_owner_team"); + const tabloId = await createSharedTabloForLeaveTest({ + ownerId: ownerUser.userId, + memberId: temporaryUser.userId, + }); + + const res = await leaveTabloRequest(temporaryUser, client, tabloId); expect(res.status).toBe(200); const data = await res.json(); @@ -519,7 +562,13 @@ describe("Tablo Endpoint", () => { }); it("should allow owner to leave a tablo and remove from Stream Chat channel", async () => { - const res = await leaveTabloRequest(ownerUser, client, "test_tablo_temp_shared_admin"); + const tabloId = await createSharedTabloForLeaveTest({ + ownerId: temporaryUser.userId, + memberId: ownerUser.userId, + memberIsAdmin: true, + }); + + const res = await leaveTabloRequest(ownerUser, client, tabloId); expect(res.status).toBe(200); const data = await res.json(); diff --git a/apps/api/src/middlewares/middleware.ts b/apps/api/src/middlewares/middleware.ts index 3625e91..7f9c80d 100644 --- a/apps/api/src/middlewares/middleware.ts +++ b/apps/api/src/middlewares/middleware.ts @@ -35,6 +35,10 @@ export type Middlewares = { Variables: { supabase: SupabaseClient; user: User }; Bindings: { user: User }; }>; + billingCheckoutAccessMiddleware: MiddlewareHandler<{ + Variables: { supabase: SupabaseClient; user: User }; + Bindings: { user: User }; + }>; transporterMiddleware: MiddlewareHandler<{ Variables: { transporter: Transporter }; }>; @@ -70,6 +74,31 @@ export class MiddlewareManager { } private initializeMiddlewares(config: AppConfig): Middlewares { + const createProfileAccessMiddleware = (allowTemporaryUsers: boolean) => + createMiddleware<{ + Variables: { supabase: SupabaseClient; user: User }; + Bindings: { user: User }; + }>(async (c, next) => { + const supabase = c.get("supabase"); + const user = c.get("user"); + + const { data: profile, error } = await supabase + .from("profiles") + .select("is_temporary") + .eq("id", user.id) + .single(); + + if (error || !profile) { + return c.json({ error: error?.message ?? "Profile not found" }, 500); + } + + if (!allowTemporaryUsers && profile.is_temporary) { + return c.json({ error: "User is read only" }, 401); + } + + await next(); + }); + const supabaseMiddleware = createMiddleware(async (c: Context, next: Next) => { const supabase = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY); c.set("supabase", supabase); @@ -157,28 +186,8 @@ export class MiddlewareManager { await next(); }); - const regularUserCheckMiddleware = createMiddleware<{ - Variables: { supabase: SupabaseClient; user: User }; - Bindings: { user: User }; - }>(async (c, next) => { - const supabase = c.get("supabase"); - const user = c.get("user"); - - const { data: profile, error } = await supabase - .from("profiles") - .select("is_temporary") - .eq("id", user.id) - .single(); - - if (error || !profile) { - return c.json({ error: error.message }, 500); - } - - if (profile.is_temporary) { - return c.json({ error: "User is read only" }, 401); - } - await next(); - }); + const regularUserCheckMiddleware = createProfileAccessMiddleware(false); + const billingCheckoutAccessMiddleware = createProfileAccessMiddleware(true); const transporterMiddleware = createMiddleware(async (c: Context, next: Next) => { const transporter = createTransporter(config); @@ -208,6 +217,7 @@ export class MiddlewareManager { streamChatMiddleware, r2Middleware, regularUserCheckMiddleware, + billingCheckoutAccessMiddleware, transporterMiddleware, stripeSyncMiddleware, stripeMiddleware, @@ -242,6 +252,10 @@ export class MiddlewareManager { return this.middlewares.regularUserCheckMiddleware; } + get billingCheckoutAccess() { + return this.middlewares.billingCheckoutAccessMiddleware; + } + get transporter() { return this.middlewares.transporterMiddleware; } diff --git a/apps/api/src/routers/stripe.ts b/apps/api/src/routers/stripe.ts index 4b6a735..926d509 100644 --- a/apps/api/src/routers/stripe.ts +++ b/apps/api/src/routers/stripe.ts @@ -208,7 +208,7 @@ const createCheckoutSession = ( config: AppConfig, middlewareManager: ReturnType ) => - stripeFactory.createHandlers(middlewareManager.regularUserCheck, async (c) => { + stripeFactory.createHandlers(middlewareManager.billingCheckoutAccess, async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); const stripe = c.get("stripe"); diff --git a/docs/superpowers/plans/2026-03-24-invited-user-billing-upgrade.md b/docs/superpowers/plans/2026-03-24-invited-user-billing-upgrade.md new file mode 100644 index 0000000..8c358e3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-invited-user-billing-upgrade.md @@ -0,0 +1,141 @@ +# Invited User Billing Upgrade Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let invited temporary users start Stripe checkout for Solo, Team, or Founder plans and automatically convert them into full users once a paid subscription is synced. + +**Architecture:** Keep the existing temporary-user read-only model for normal product actions, but carve out a narrow billing exception for Stripe checkout creation. Complete the upgrade on the server-side subscription sync path so promotion to a full account happens only after Stripe-backed paid state exists. + +**Tech Stack:** Hono, Vitest, Supabase Postgres migrations/tests, React/Vitest + +--- + +## Chunk 1: Billing Access Gate + +### Task 1: Add failing API coverage for temporary-user billing checkout access + +**Files:** +- Modify: `apps/api/src/__tests__/middlewares/middlewares.test.ts` +- Modify: `apps/api/src/middlewares/middleware.ts` + +- [ ] **Step 1: Write the failing test** + +Add coverage proving a temporary user is still rejected by `regularUserCheck`, while a new billing-specific check allows temporary users through. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @xtablo/api test -- middlewares` +Expected: FAIL because no billing-safe middleware exists yet. + +- [ ] **Step 3: Write minimal implementation** + +Add a focused middleware/getter that authenticates the user and confirms the profile exists, but does not reject `is_temporary`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @xtablo/api test -- middlewares` +Expected: PASS + +## Chunk 2: Stripe Checkout Route + +### Task 2: Add failing route coverage for temporary invited checkout + +**Files:** +- Modify: `apps/api/src/__tests__/routes/stripe.test.ts` +- Modify: `apps/api/src/routers/stripe.ts` + +- [ ] **Step 1: Write the failing test** + +Add route-level coverage that the checkout route uses the billing-safe middleware while portal/cancel/reactivate remain on the regular user path. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @xtablo/api test -- stripe` +Expected: FAIL because checkout still uses `regularUserCheck`. + +- [ ] **Step 3: Write minimal implementation** + +Switch only `create-checkout-session` to the billing-safe middleware and preserve existing billing-owner enforcement inside the route. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @xtablo/api test -- stripe` +Expected: PASS + +## Chunk 3: Post-Payment Promotion + +### Task 3: Add failing database coverage for paid subscription promotion + +**Files:** +- Modify: `supabase/tests/database/05_triggers.test.sql` +- Create: `supabase/migrations/20260324120000_promote_paid_temporary_users.sql` + +- [ ] **Step 1: Write the failing test** + +Add a trigger test that creates a temporary profile plus Stripe customer/subscription records, fires `update_profile_subscription_status`, and expects `is_temporary = false` once the paid subscription is present. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test -- --filter` if a repo wrapper exists, otherwise run the project’s Supabase database test command used by the team. +Expected: FAIL because the trigger only updates `plan` today. + +- [ ] **Step 3: Write minimal implementation** + +Create a migration that updates `public.update_profile_subscription_status()` to clear `is_temporary` whenever a paid plan is inferred. + +- [ ] **Step 4: Run test to verify it passes** + +Run the same Supabase database trigger test command. +Expected: PASS + +## Chunk 4: Frontend Safety Net + +### Task 4: Verify no UI regression for pending signup checkout behavior + +**Files:** +- Modify: `apps/main/src/components/PendingSignupCheckout.test.tsx` (only if needed) +- Modify: `apps/main/src/pages/settings.test.tsx` (only if needed) + +- [ ] **Step 1: Add or adjust failing test only if the UI has an explicit temporary-user block** + +If implementation reveals a frontend gate, add a focused failing test for it. + +- [ ] **Step 2: Run targeted frontend test** + +Run: `pnpm --filter @xtablo/main test -- PendingSignupCheckout` +Expected: FAIL only if a frontend gate exists. + +- [ ] **Step 3: Implement the minimal UI change** + +Only patch the exact gate if one is discovered; otherwise leave frontend logic unchanged. + +- [ ] **Step 4: Re-run targeted frontend tests** + +Run: `pnpm --filter @xtablo/main test -- PendingSignupCheckout` +Expected: PASS + +## Chunk 5: Verification + +### Task 5: Verify end-to-end relevant coverage + +**Files:** +- Modify: none unless verification exposes a bug + +- [ ] **Step 1: Run API targeted tests** + +Run: `pnpm --filter @xtablo/api test -- middlewares stripe` +Expected: PASS + +- [ ] **Step 2: Run frontend targeted tests** + +Run: `pnpm --filter @xtablo/main test -- PendingSignupCheckout settings` +Expected: PASS + +- [ ] **Step 3: Run the database trigger tests** + +Run the project’s Supabase trigger test command for `supabase/tests/database/05_triggers.test.sql`. +Expected: PASS + +- [ ] **Step 4: Review changed files and summarize remaining risk** + +Confirm the only intentional behavior change is: temporary invited billing owners can start checkout, and paid checkout promotion clears read-only status. diff --git a/supabase/migrations/20260324120000_promote_paid_temporary_users.sql b/supabase/migrations/20260324120000_promote_paid_temporary_users.sql new file mode 100644 index 0000000..affe93e --- /dev/null +++ b/supabase/migrations/20260324120000_promote_paid_temporary_users.sql @@ -0,0 +1,163 @@ +-- Promote temporary invited users to full users once Stripe sync confirms +-- they hold a paid subscription. + +CREATE OR REPLACE FUNCTION public.update_profile_subscription_status() RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id uuid; + v_customer_id text; + v_plan_hint text; + v_has_paid_subscription boolean := FALSE; + v_plan public.subscription_plan := 'solo'; +BEGIN + -- Resolve customer id from the changed stripe row + IF TG_TABLE_NAME = 'subscriptions' THEN + v_customer_id := NEW.customer; + ELSIF TG_TABLE_NAME = 'subscription_items' THEN + SELECT s.customer + INTO v_customer_id + FROM stripe.subscriptions s + WHERE s.id = NEW.subscription; + ELSE + RETURN NEW; + END IF; + + IF v_customer_id IS NULL THEN + RETURN NEW; + END IF; + + -- Resolve application user id from Stripe customer metadata + SELECT (c.metadata->>'user_id')::uuid + INTO v_user_id + FROM stripe.customers c + WHERE c.id = v_customer_id; + + IF v_user_id IS NULL THEN + RETURN NEW; + END IF; + + -- Pick the best active/trialing subscription for this user and infer plan from + -- price/product metadata or lookup key. Fall back to Team when paid but unmapped. + WITH candidate_subscriptions AS ( + SELECT + s.status::text AS status, + lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) AS plan_hint, + CASE s.status::text + WHEN 'active' THEN 3 + WHEN 'past_due' THEN 2 + WHEN 'trialing' THEN 1 + ELSE 0 + END AS status_weight, + CASE + WHEN lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%founder%' OR lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%annual%' OR lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%beta%' THEN 3 + WHEN lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%team%' OR lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%standard%' THEN 2 + WHEN lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%solo%' THEN 1 + ELSE 0 + END AS plan_weight + FROM stripe.subscriptions s + JOIN stripe.customers c + ON c.id = s.customer + LEFT JOIN stripe.subscription_items si + ON si.subscription = s.id + LEFT JOIN stripe.prices pr + ON pr.id = si.price + LEFT JOIN stripe.products pd + ON pd.id = pr.product + WHERE (c.metadata->>'user_id')::uuid = v_user_id + AND s.status::text IN ('active', 'past_due', 'trialing') + AND ( + si.current_period_end IS NULL + OR to_timestamp(si.current_period_end) > now() + ) + ) + SELECT + TRUE, + cs.plan_hint + INTO v_has_paid_subscription, v_plan_hint + FROM candidate_subscriptions cs + ORDER BY cs.plan_weight DESC, cs.status_weight DESC + LIMIT 1; + + IF v_has_paid_subscription THEN + IF v_plan_hint LIKE '%founder%' OR v_plan_hint LIKE '%annual%' OR v_plan_hint LIKE '%beta%' THEN + v_plan := 'annual'; + ELSIF v_plan_hint LIKE '%team%' OR v_plan_hint LIKE '%standard%' THEN + v_plan := 'team'; + ELSIF v_plan_hint LIKE '%solo%' OR v_plan_hint LIKE '%free%' THEN + v_plan := 'solo'; + ELSE + -- paid but unmapped => Team by default (backward compatibility) + v_plan := 'team'; + END IF; + ELSE + v_plan := 'solo'; + END IF; + + UPDATE public.profiles + SET + plan = v_plan, + is_temporary = CASE + WHEN v_has_paid_subscription THEN false + ELSE is_temporary + END + WHERE id = v_user_id; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION public.update_profile_subscription_status() OWNER TO postgres; + +COMMENT ON FUNCTION public.update_profile_subscription_status() IS +'Maps Stripe subscription state to profile.plan, normalizes founder/annual to annual, and promotes paid temporary users to full users.'; diff --git a/supabase/tests/database/05_triggers.test.sql b/supabase/tests/database/05_triggers.test.sql index 7ac5388..e6eb5da 100644 --- a/supabase/tests/database/05_triggers.test.sql +++ b/supabase/tests/database/05_triggers.test.sql @@ -1,5 +1,5 @@ begin; -select plan(35); -- Total number of tests (added freemium coverage) +select plan(37); -- Total number of tests (added paid temporary-user promotion coverage) -- ============================================================================ -- Trigger Function Existence Tests @@ -463,7 +463,7 @@ SELECT is( 'is_temporary should be false for regular users' ); --- Test 5: Regular users receive the freemium plan when available +-- Test 5: Regular users receive the freemium plan only while it is still available DO $$ DECLARE freemium_user_id uuid := gen_random_uuid(); @@ -497,8 +497,11 @@ SELECT is( WHERE id = current_setting('test.freemium_user_id')::uuid LIMIT 1 ), - 'free'::public.subscription_plan, - 'Non-temporary users should receive the free plan while freemium slots remain' + CASE + WHEN DATE_PART('year', NOW()) < 2026 THEN 'free'::public.subscription_plan + ELSE 'none'::public.subscription_plan + END, + 'Non-temporary users should only receive the free plan before freemium expires in 2026' ); -- Test 6: Invited users do not receive the freemium plan @@ -513,17 +516,126 @@ SELECT is( 'Temporary invited users should remain on the none plan even if freemium is available' ); +-- Test 6b: Paid subscriptions promote temporary invited users to full accounts +DO $$ +DECLARE + paid_invited_user_id uuid := gen_random_uuid(); + paid_invited_email text := 'paid_invited_' || paid_invited_user_id::text || '@test.com'; + customer_id text := 'cus_' || replace(paid_invited_user_id::text, '-', ''); + product_id text := 'prod_' || substring(replace(paid_invited_user_id::text, '-', '') from 1 for 20); + price_id text := 'price_' || substring(replace(paid_invited_user_id::text, '-', '') from 1 for 19); + subscription_id text := 'sub_' || substring(replace(paid_invited_user_id::text, '-', '') from 1 for 21); + subscription_item_id text := 'si_' || substring(replace(paid_invited_user_id::text, '-', '') from 1 for 22); + now_epoch integer := extract(epoch from now())::integer; +BEGIN + INSERT INTO auth.users ( + id, instance_id, aud, role, email, + encrypted_password, email_confirmed_at, + raw_user_meta_data, created_at, updated_at + ) + VALUES ( + paid_invited_user_id, + '00000000-0000-0000-0000-000000000000', + 'authenticated', + 'authenticated', + paid_invited_email, + 'encrypted', + now(), + '{"role": "invited_user", "first_name": "Paid", "last_name": "Invitee"}'::jsonb, + now(), + now() + ); + + INSERT INTO stripe.customers (id, email, metadata, created) + VALUES ( + customer_id, + paid_invited_email, + jsonb_build_object('user_id', paid_invited_user_id::text), + now_epoch + ); + + INSERT INTO stripe.products (id, name, metadata, created, active) + VALUES ( + product_id, + 'Founder', + '{"plan":"founder"}'::jsonb, + now_epoch, + true + ); + + INSERT INTO stripe.prices (id, product, lookup_key, metadata, unit_amount, type, active, currency, created) + VALUES ( + price_id, + product_id, + 'founder', + '{"plan":"founder"}'::jsonb, + 9900, + 'recurring', + true, + 'eur', + now_epoch + ); + + INSERT INTO stripe.subscriptions (id, customer, status, created, current_period_start, current_period_end, metadata) + VALUES ( + subscription_id, + customer_id, + 'active', + now_epoch, + now_epoch, + now_epoch + 31536000, + jsonb_build_object('user_id', paid_invited_user_id::text) + ); + + INSERT INTO stripe.subscription_items ( + id, subscription, price, quantity, created, current_period_start, current_period_end + ) + VALUES ( + subscription_item_id, + subscription_id, + price_id, + 1, + now_epoch, + now_epoch, + now_epoch + 31536000 + ); + + PERFORM set_config('test.paid_invited_user_id', paid_invited_user_id::text, true); +END $$; + +SELECT is( + ( + SELECT plan + FROM public.profiles + WHERE id = current_setting('test.paid_invited_user_id')::uuid + LIMIT 1 + ), + 'annual'::public.subscription_plan, + 'Temporary invited users with a paid founder subscription should map to the annual plan' +); + +SELECT is( + ( + SELECT is_temporary + FROM public.profiles + WHERE id = current_setting('test.paid_invited_user_id')::uuid + LIMIT 1 + ), + false, + 'Temporary invited users should be promoted to full users when a paid subscription is synced' +); + -- Test 7: Verify short_user_id is set (by another trigger) SELECT ok( (SELECT short_user_id FROM public.profiles WHERE id = current_setting('test.new_user_id')::uuid LIMIT 1) IS NOT NULL, 'short_user_id should be set for new profile' ); --- Test 8: is_freemium_available is true before quota is reached +-- Test 8: is_freemium_available reflects both quota and calendar gating SELECT is( public.is_freemium_available(), - true, - 'Freemium availability should be true when fewer than 100 free users exist' + (DATE_PART('year', NOW()) < 2026), + 'Freemium availability should only be true before 2026 when fewer than 100 free users exist' ); -- Test 9: Freemium availability becomes false once 100 free users exist @@ -565,4 +677,3 @@ SELECT is( select * from finish(); rollback; -