Skip to content

Commit 8bd09cf

Browse files
feat(supabase): bill Team plans to the workspace and inherit entitlement (#6674)
Subscriptions attached to auth.users, so there was no way to buy a plan for a team: every member needed their own. Seats did not exist at all. Give shared workspaces their own Stripe customer and seat limit, so billing follows the workspace rather than whoever owns it today and survives an ownership transfer. The auth hook now unions the account's own entitlements with those of any workspace it belongs to, and falls back to the workspace subscription status when the member has none of their own -- which feature set a Team plan carries stays a Stripe product decision, so no existing hyprnote_pro gate has to learn about teams. Enforce seats with a constraint trigger rather than in one RPC, so every path is covered: memberships and still-open invitations both hold a seat, and an invitation stops holding one once its invitee is seated. Also restate SET search_path on the auth hook: CREATE OR REPLACE drops attributes it does not repeat, which would have silently undone the hardening applied in 20260714134923.
1 parent bf6ca18 commit 8bd09cf

2 files changed

Lines changed: 532 additions & 0 deletions

File tree

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
-- Team plans are billed to the workspace, not to whoever happens to own it, so
2+
-- billing survives an ownership transfer and members inherit entitlement from
3+
-- the workspace subscription instead of buying their own.
4+
5+
ALTER TABLE public.workspaces
6+
ADD COLUMN stripe_customer_id text,
7+
ADD COLUMN seat_limit integer,
8+
ADD CONSTRAINT workspaces_seat_limit_check CHECK (
9+
seat_limit IS NULL OR seat_limit > 0
10+
),
11+
ADD CONSTRAINT workspaces_billing_is_shared_check CHECK (
12+
stripe_customer_id IS NULL OR kind = 'shared'
13+
);
14+
15+
CREATE UNIQUE INDEX workspaces_stripe_customer_key
16+
ON public.workspaces(stripe_customer_id)
17+
WHERE stripe_customer_id IS NOT NULL;
18+
19+
-- Seats are consumed by people who hold access and by invitations that can
20+
-- still be accepted, so an admin cannot oversubscribe by queueing invites.
21+
CREATE OR REPLACE FUNCTION private.workspace_seat_usage(
22+
p_workspace_id uuid
23+
)
24+
RETURNS TABLE (
25+
seat_limit integer,
26+
used_seats integer
27+
)
28+
LANGUAGE sql
29+
STABLE
30+
SECURITY DEFINER
31+
SET search_path = ''
32+
AS $$
33+
SELECT
34+
workspace.seat_limit,
35+
(
36+
(
37+
SELECT count(*)
38+
FROM public.workspace_memberships AS membership
39+
WHERE membership.workspace_id = workspace.id
40+
AND membership.deleted_at IS NULL
41+
)
42+
+ (
43+
SELECT count(*)
44+
FROM public.workspace_invitations AS invitation
45+
WHERE invitation.workspace_id = workspace.id
46+
AND invitation.accepted_at IS NULL
47+
AND invitation.revoked_at IS NULL
48+
AND invitation.expires_at > now()
49+
-- Mid-acceptance the membership lands before the invitation is
50+
-- stamped accepted; without this the same person holds two seats.
51+
AND NOT EXISTS (
52+
SELECT 1
53+
FROM public.workspace_memberships AS seated
54+
WHERE seated.workspace_id = invitation.workspace_id
55+
AND seated.user_id = invitation.invitee_user_id
56+
AND seated.deleted_at IS NULL
57+
)
58+
)
59+
)::integer
60+
FROM public.workspaces AS workspace
61+
WHERE workspace.id = p_workspace_id;
62+
$$;
63+
64+
REVOKE ALL ON FUNCTION private.workspace_seat_usage(uuid)
65+
FROM PUBLIC, anon, authenticated;
66+
67+
CREATE OR REPLACE FUNCTION private.enforce_workspace_seat_limit()
68+
RETURNS trigger
69+
LANGUAGE plpgsql
70+
SECURITY DEFINER
71+
SET search_path = ''
72+
AS $$
73+
DECLARE
74+
v_workspace_id uuid := NEW.workspace_id;
75+
v_usage record;
76+
BEGIN
77+
-- Nested rather than one condition: plpgsql resolves OLD/NEW field references
78+
-- even in branches that cannot run, and invitations have no deleted_at.
79+
IF TG_TABLE_NAME = 'workspace_memberships' THEN
80+
IF TG_OP = 'UPDATE' THEN
81+
-- Reactivating a soft-deleted row takes a seat again; nothing else does.
82+
IF NOT (OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL) THEN
83+
RETURN NEW;
84+
END IF;
85+
ELSIF NEW.deleted_at IS NOT NULL THEN
86+
RETURN NEW;
87+
END IF;
88+
END IF;
89+
90+
SELECT * INTO v_usage FROM private.workspace_seat_usage(v_workspace_id);
91+
92+
IF v_usage.seat_limit IS NOT NULL AND v_usage.used_seats > v_usage.seat_limit THEN
93+
RAISE EXCEPTION 'workspace seat limit reached'
94+
USING ERRCODE = '22023';
95+
END IF;
96+
97+
RETURN NEW;
98+
END;
99+
$$;
100+
101+
REVOKE ALL ON FUNCTION private.enforce_workspace_seat_limit()
102+
FROM PUBLIC, anon, authenticated;
103+
104+
CREATE CONSTRAINT TRIGGER on_workspace_membership_seat_limit
105+
AFTER INSERT OR UPDATE OF deleted_at ON public.workspace_memberships
106+
DEFERRABLE INITIALLY IMMEDIATE
107+
FOR EACH ROW EXECUTE FUNCTION private.enforce_workspace_seat_limit();
108+
109+
CREATE CONSTRAINT TRIGGER on_workspace_invitation_seat_limit
110+
AFTER INSERT ON public.workspace_invitations
111+
DEFERRABLE INITIALLY IMMEDIATE
112+
FOR EACH ROW EXECUTE FUNCTION private.enforce_workspace_seat_limit();
113+
114+
CREATE OR REPLACE FUNCTION private.get_workspace_seat_usage(
115+
p_workspace_id uuid
116+
)
117+
RETURNS TABLE (
118+
seat_limit integer,
119+
used_seats integer,
120+
is_billed boolean
121+
)
122+
LANGUAGE plpgsql
123+
STABLE
124+
SECURITY DEFINER
125+
SET search_path = ''
126+
AS $$
127+
BEGIN
128+
IF NOT EXISTS (
129+
SELECT 1
130+
FROM public.workspaces AS workspace
131+
JOIN public.workspace_memberships AS membership
132+
ON membership.workspace_id = workspace.id
133+
WHERE workspace.id = p_workspace_id
134+
AND workspace.deleted_at IS NULL
135+
AND membership.user_id = auth.uid()
136+
AND membership.role IN ('owner', 'admin')
137+
AND membership.deleted_at IS NULL
138+
) THEN
139+
RAISE EXCEPTION 'workspace billing operation not permitted'
140+
USING ERRCODE = '42501';
141+
END IF;
142+
143+
RETURN QUERY
144+
SELECT
145+
usage.seat_limit,
146+
usage.used_seats,
147+
workspace.stripe_customer_id IS NOT NULL
148+
FROM public.workspaces AS workspace
149+
CROSS JOIN LATERAL private.workspace_seat_usage(workspace.id) AS usage
150+
WHERE workspace.id = p_workspace_id;
151+
END;
152+
$$;
153+
154+
REVOKE ALL ON FUNCTION private.get_workspace_seat_usage(uuid)
155+
FROM PUBLIC, anon, authenticated;
156+
GRANT EXECUTE ON FUNCTION private.get_workspace_seat_usage(uuid)
157+
TO authenticated;
158+
159+
CREATE OR REPLACE FUNCTION public.get_workspace_seat_usage(
160+
p_workspace_id uuid
161+
)
162+
RETURNS TABLE (
163+
seat_limit integer,
164+
used_seats integer,
165+
is_billed boolean
166+
)
167+
LANGUAGE sql
168+
STABLE
169+
SECURITY INVOKER
170+
SET search_path = ''
171+
AS $$
172+
SELECT * FROM private.get_workspace_seat_usage(p_workspace_id);
173+
$$;
174+
175+
REVOKE ALL ON FUNCTION public.get_workspace_seat_usage(uuid)
176+
FROM PUBLIC, anon, authenticated;
177+
GRANT EXECUTE ON FUNCTION public.get_workspace_seat_usage(uuid)
178+
TO authenticated;
179+
180+
-- The auth hook now unions two sources of entitlement: what the account bought
181+
-- for itself, and what any workspace it belongs to bought on its behalf. Which
182+
-- features a Team plan carries stays a Stripe product decision, so no gate in
183+
-- the app has to learn about team plans.
184+
GRANT SELECT ON TABLE public.workspaces TO supabase_auth_admin;
185+
GRANT SELECT ON TABLE public.workspace_memberships TO supabase_auth_admin;
186+
187+
CREATE POLICY "Allow auth admin to read workspaces"
188+
ON public.workspaces
189+
AS PERMISSIVE FOR SELECT
190+
TO supabase_auth_admin
191+
USING (true);
192+
193+
CREATE POLICY "Allow auth admin to read workspace memberships"
194+
ON public.workspace_memberships
195+
AS PERMISSIVE FOR SELECT
196+
TO supabase_auth_admin
197+
USING (true);
198+
199+
-- search_path is pinned here rather than left to the ALTER in
200+
-- 20260714134923: CREATE OR REPLACE resets attributes it does not restate.
201+
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
202+
RETURNS jsonb
203+
LANGUAGE plpgsql
204+
STABLE
205+
SET search_path = ''
206+
AS $$
207+
DECLARE
208+
claims jsonb;
209+
entitlements jsonb := '[]'::jsonb;
210+
v_user_id uuid := (event->>'user_id')::uuid;
211+
v_customer_id text;
212+
v_subscription_status text;
213+
v_trial_end bigint;
214+
v_has_payment_method boolean;
215+
BEGIN
216+
SELECT p.stripe_customer_id INTO v_customer_id
217+
FROM public.profiles p
218+
WHERE p.id = v_user_id;
219+
220+
SELECT
221+
COALESCE(
222+
jsonb_agg(DISTINCT granted.lookup_key ORDER BY granted.lookup_key)
223+
FILTER (WHERE granted.lookup_key IS NOT NULL),
224+
'[]'::jsonb
225+
)
226+
INTO entitlements
227+
FROM (
228+
SELECT ae.lookup_key
229+
FROM public.profiles p
230+
JOIN stripe.active_entitlements ae
231+
ON ae.customer = p.stripe_customer_id
232+
WHERE p.id = v_user_id
233+
234+
UNION
235+
236+
SELECT ae.lookup_key
237+
FROM public.workspace_memberships m
238+
JOIN public.workspaces w
239+
ON w.id = m.workspace_id
240+
JOIN stripe.active_entitlements ae
241+
ON ae.customer = w.stripe_customer_id
242+
WHERE m.user_id = v_user_id
243+
AND m.deleted_at IS NULL
244+
AND w.deleted_at IS NULL
245+
AND w.stripe_customer_id IS NOT NULL
246+
) AS granted;
247+
248+
IF v_customer_id IS NOT NULL THEN
249+
SELECT
250+
s.status::text,
251+
(s.trial_end #>> '{}')::bigint,
252+
s.default_payment_method IS NOT NULL
253+
OR c.invoice_settings->>'default_payment_method' IS NOT NULL
254+
OR c.default_source IS NOT NULL
255+
INTO v_subscription_status, v_trial_end, v_has_payment_method
256+
FROM stripe.subscriptions s
257+
JOIN stripe.customers c ON c.id = s.customer
258+
WHERE s.customer = v_customer_id
259+
AND s.status IN ('trialing', 'active')
260+
ORDER BY
261+
CASE s.status WHEN 'active' THEN 1 WHEN 'trialing' THEN 2 END,
262+
s.created DESC
263+
LIMIT 1;
264+
END IF;
265+
266+
-- A member with no subscription of their own still reads as subscribed while
267+
-- a workspace covers them; personal billing state wins when both exist.
268+
IF v_subscription_status IS NULL THEN
269+
SELECT s.status::text
270+
INTO v_subscription_status
271+
FROM public.workspace_memberships m
272+
JOIN public.workspaces w
273+
ON w.id = m.workspace_id
274+
JOIN stripe.subscriptions s
275+
ON s.customer = w.stripe_customer_id
276+
WHERE m.user_id = v_user_id
277+
AND m.deleted_at IS NULL
278+
AND w.deleted_at IS NULL
279+
AND w.stripe_customer_id IS NOT NULL
280+
AND s.status IN ('trialing', 'active')
281+
ORDER BY
282+
CASE s.status WHEN 'active' THEN 1 WHEN 'trialing' THEN 2 END,
283+
s.created DESC
284+
LIMIT 1;
285+
END IF;
286+
287+
claims := event->'claims';
288+
claims := jsonb_set(claims, '{entitlements}', entitlements);
289+
290+
IF v_subscription_status IS NOT NULL THEN
291+
claims := jsonb_set(claims, '{subscription_status}', to_jsonb(v_subscription_status));
292+
END IF;
293+
294+
IF v_trial_end IS NOT NULL THEN
295+
claims := jsonb_set(claims, '{trial_end}', to_jsonb(v_trial_end));
296+
END IF;
297+
298+
IF v_has_payment_method IS NOT NULL THEN
299+
claims := jsonb_set(claims, '{has_payment_method}', to_jsonb(v_has_payment_method));
300+
END IF;
301+
302+
event := jsonb_set(event, '{claims}', claims);
303+
304+
RETURN event;
305+
END;
306+
$$;

0 commit comments

Comments
 (0)