allow invited to upgrade plan
This commit is contained in:
parent
f6e5c39dcc
commit
1113af72ac
8 changed files with 591 additions and 42 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.';
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue