Merge pull request #65 from artslidd/develop

Add signup billing plan flow and tablo overview layout support
This commit is contained in:
Arthur Belleville 2026-03-24 20:59:48 +01:00 committed by GitHub
commit 94fda7c829
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 601 additions and 44 deletions

View file

@ -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);

View file

@ -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", () => {

View file

@ -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();

View file

@ -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;
}

View file

@ -208,7 +208,7 @@ const createCheckoutSession = (
config: AppConfig,
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>
) =>
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");

View file

@ -69,6 +69,10 @@ describe("SignUpV2Page", () => {
it("submits founder billing intent when selected", async () => {
renderWithProviders(<SignUpV2Page />);
expect(
screen.getByText(/fonctionnalités illimitées\. limité à 50 sièges\./i)
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /founder/i }));
fillAndSubmitForm();

View file

@ -286,7 +286,7 @@ export function SignUpV2Page() {
>
<p className="text-sm font-medium">Founder (99/an)</p>
<p className="text-xs text-muted-foreground">
Paiement immédiat après la création du compte.
Fonctionnalités illimitées. Limité à 50 sièges.
</p>
</button>
</div>

View file

@ -232,6 +232,10 @@ describe("SignUpPage", () => {
it("submits founder billing intent when selected", async () => {
renderWithProviders(<SignUpPage />);
expect(
screen.getByText(/fonctionnalités illimitées\. limité à 50 sièges\./i)
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /founder/i }));
const submitButton = screen.getByRole("button", { name: /create my account/i });

View file

@ -375,7 +375,7 @@ export function SignUpPage() {
>
<p className="text-sm font-medium">Founder (99/an)</p>
<p className="text-xs text-muted-foreground">
Paiement immédiat après la création du compte.
Fonctionnalités illimitées. Limité à 50 sièges.
</p>
</button>
</div>

View file

@ -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 projects 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 projects 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.

View file

@ -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.';

View file

@ -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;