Skip to content

Commit ab9eb47

Browse files
Fix legacy Stripe customer linking (#7312)
* Fix legacy Stripe customer linking Match billing identities case-insensitively, prefer customers with subscription history, and backfill unlinked profiles. * Harden legacy Stripe reconciliation Exclude deleted customers, serialize normalized-email claims, and keep ambiguous or deleting profiles out of the backfill. * Protect current Stripe customer links Preserve paused and workspace-owned customers during legacy matching, with regression coverage.
1 parent 0f0baae commit ab9eb47

4 files changed

Lines changed: 649 additions & 1 deletion
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
CREATE OR REPLACE FUNCTION public.handle_new_user()
2+
RETURNS trigger
3+
LANGUAGE plpgsql
4+
SECURITY DEFINER
5+
SET search_path = ''
6+
AS $$
7+
DECLARE
8+
matched_customer_id text;
9+
BEGIN
10+
IF NEW.email IS NOT NULL THEN
11+
PERFORM pg_catalog.pg_advisory_xact_lock(
12+
pg_catalog.hashtextextended(
13+
pg_catalog.lower(pg_catalog.btrim(NEW.email)),
14+
4030317
15+
)
16+
);
17+
END IF;
18+
19+
SELECT customer.id
20+
INTO matched_customer_id
21+
FROM stripe.customers AS customer
22+
WHERE NEW.email IS NOT NULL
23+
AND customer.email IS NOT NULL
24+
AND NOT customer.deleted
25+
AND pg_catalog.lower(pg_catalog.btrim(customer.email))
26+
= pg_catalog.lower(pg_catalog.btrim(NEW.email))
27+
AND NOT EXISTS (
28+
SELECT 1
29+
FROM public.profiles AS linked_profile
30+
WHERE linked_profile.stripe_customer_id = customer.id
31+
)
32+
AND NOT EXISTS (
33+
SELECT 1
34+
FROM public.workspaces AS linked_workspace
35+
WHERE linked_workspace.stripe_customer_id = customer.id
36+
)
37+
ORDER BY
38+
CASE
39+
WHEN EXISTS (
40+
SELECT 1
41+
FROM stripe.subscriptions AS subscription
42+
WHERE subscription.customer = customer.id
43+
AND subscription.status IN ('active', 'past_due', 'unpaid')
44+
) THEN 0
45+
WHEN EXISTS (
46+
SELECT 1
47+
FROM stripe.subscriptions AS subscription
48+
WHERE subscription.customer = customer.id
49+
AND subscription.status IN ('trialing', 'paused')
50+
) THEN 1
51+
WHEN EXISTS (
52+
SELECT 1
53+
FROM stripe.subscriptions AS subscription
54+
WHERE subscription.customer = customer.id
55+
) THEN 2
56+
ELSE 3
57+
END,
58+
customer.created ASC NULLS LAST,
59+
customer.id
60+
LIMIT 1;
61+
62+
INSERT INTO public.profiles (id, stripe_customer_id)
63+
VALUES (NEW.id, matched_customer_id);
64+
65+
RETURN NEW;
66+
END;
67+
$$;
68+
69+
CREATE OR REPLACE FUNCTION public.handle_user_email_update()
70+
RETURNS trigger
71+
LANGUAGE plpgsql
72+
SECURITY DEFINER
73+
SET search_path = ''
74+
AS $$
75+
DECLARE
76+
matched_customer_id text;
77+
BEGIN
78+
IF OLD.email IS DISTINCT FROM NEW.email THEN
79+
IF NEW.email IS NOT NULL THEN
80+
PERFORM pg_catalog.pg_advisory_xact_lock(
81+
pg_catalog.hashtextextended(
82+
pg_catalog.lower(pg_catalog.btrim(NEW.email)),
83+
4030317
84+
)
85+
);
86+
END IF;
87+
88+
SELECT profile.stripe_customer_id
89+
INTO matched_customer_id
90+
FROM public.profiles AS profile
91+
WHERE profile.id = NEW.id;
92+
93+
IF matched_customer_id IS NULL THEN
94+
SELECT customer.id
95+
INTO matched_customer_id
96+
FROM stripe.customers AS customer
97+
WHERE NEW.email IS NOT NULL
98+
AND customer.email IS NOT NULL
99+
AND NOT customer.deleted
100+
AND pg_catalog.lower(pg_catalog.btrim(customer.email))
101+
= pg_catalog.lower(pg_catalog.btrim(NEW.email))
102+
AND NOT EXISTS (
103+
SELECT 1
104+
FROM public.profiles AS linked_profile
105+
WHERE linked_profile.stripe_customer_id = customer.id
106+
)
107+
AND NOT EXISTS (
108+
SELECT 1
109+
FROM public.workspaces AS linked_workspace
110+
WHERE linked_workspace.stripe_customer_id = customer.id
111+
)
112+
ORDER BY
113+
CASE
114+
WHEN EXISTS (
115+
SELECT 1
116+
FROM stripe.subscriptions AS subscription
117+
WHERE subscription.customer = customer.id
118+
AND subscription.status IN ('active', 'past_due', 'unpaid')
119+
) THEN 0
120+
WHEN EXISTS (
121+
SELECT 1
122+
FROM stripe.subscriptions AS subscription
123+
WHERE subscription.customer = customer.id
124+
AND subscription.status IN ('trialing', 'paused')
125+
) THEN 1
126+
WHEN EXISTS (
127+
SELECT 1
128+
FROM stripe.subscriptions AS subscription
129+
WHERE subscription.customer = customer.id
130+
) THEN 2
131+
ELSE 3
132+
END,
133+
customer.created ASC NULLS LAST,
134+
customer.id
135+
LIMIT 1;
136+
137+
IF matched_customer_id IS NOT NULL THEN
138+
UPDATE public.profiles AS profile
139+
SET stripe_customer_id = matched_customer_id
140+
WHERE profile.id = NEW.id;
141+
END IF;
142+
END IF;
143+
END IF;
144+
145+
RETURN NEW;
146+
END;
147+
$$;
148+
149+
REVOKE ALL ON FUNCTION public.handle_new_user()
150+
FROM PUBLIC, anon, authenticated;
151+
GRANT EXECUTE ON FUNCTION public.handle_new_user()
152+
TO supabase_auth_admin;
153+
154+
REVOKE ALL ON FUNCTION public.handle_user_email_update()
155+
FROM PUBLIC, anon, authenticated;
156+
GRANT EXECUTE ON FUNCTION public.handle_user_email_update()
157+
TO supabase_auth_admin;
158+
159+
WITH account_emails AS (
160+
SELECT
161+
normalized_account.id,
162+
normalized_account.email,
163+
pg_catalog.count(*) OVER (
164+
PARTITION BY normalized_account.email
165+
) AS account_count
166+
FROM (
167+
SELECT
168+
account.id,
169+
pg_catalog.lower(pg_catalog.btrim(account.email)) AS email
170+
FROM auth.users AS account
171+
WHERE account.email IS NOT NULL
172+
) AS normalized_account
173+
),
174+
ranked_candidates AS (
175+
SELECT
176+
profile.id AS profile_id,
177+
customer.id AS customer_id,
178+
pg_catalog.row_number() OVER (
179+
PARTITION BY profile.id
180+
ORDER BY
181+
CASE
182+
WHEN EXISTS (
183+
SELECT 1
184+
FROM stripe.subscriptions AS subscription
185+
WHERE subscription.customer = customer.id
186+
AND subscription.status IN ('active', 'past_due', 'unpaid')
187+
) THEN 0
188+
WHEN EXISTS (
189+
SELECT 1
190+
FROM stripe.subscriptions AS subscription
191+
WHERE subscription.customer = customer.id
192+
AND subscription.status IN ('trialing', 'paused')
193+
) THEN 1
194+
WHEN EXISTS (
195+
SELECT 1
196+
FROM stripe.subscriptions AS subscription
197+
WHERE subscription.customer = customer.id
198+
) THEN 2
199+
ELSE 3
200+
END,
201+
customer.created ASC NULLS LAST,
202+
customer.id
203+
) AS rank
204+
FROM public.profiles AS profile
205+
JOIN account_emails AS account
206+
ON account.id = profile.id
207+
JOIN stripe.customers AS customer
208+
ON customer.email IS NOT NULL
209+
AND NOT customer.deleted
210+
AND pg_catalog.lower(pg_catalog.btrim(customer.email))
211+
= account.email
212+
WHERE profile.stripe_customer_id IS NULL
213+
AND account.account_count = 1
214+
AND NOT EXISTS (
215+
SELECT 1
216+
FROM private.account_deletion_jobs AS deletion
217+
WHERE deletion.owner_user_id = profile.id
218+
)
219+
AND NOT EXISTS (
220+
SELECT 1
221+
FROM public.profiles AS linked_profile
222+
WHERE linked_profile.stripe_customer_id = customer.id
223+
)
224+
AND NOT EXISTS (
225+
SELECT 1
226+
FROM public.workspaces AS linked_workspace
227+
WHERE linked_workspace.stripe_customer_id = customer.id
228+
)
229+
)
230+
UPDATE public.profiles AS profile
231+
SET stripe_customer_id = candidate.customer_id
232+
FROM ranked_candidates AS candidate
233+
WHERE candidate.profile_id = profile.id
234+
AND candidate.rank = 1
235+
AND profile.stripe_customer_id IS NULL;
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
CREATE OR REPLACE FUNCTION public.handle_new_user()
2+
RETURNS trigger
3+
LANGUAGE plpgsql
4+
SECURITY DEFINER
5+
SET search_path = ''
6+
AS $$
7+
DECLARE
8+
matched_customer_id text;
9+
BEGIN
10+
IF NEW.email IS NOT NULL THEN
11+
PERFORM pg_catalog.pg_advisory_xact_lock(
12+
pg_catalog.hashtextextended(
13+
pg_catalog.lower(pg_catalog.btrim(NEW.email)),
14+
4030317
15+
)
16+
);
17+
END IF;
18+
19+
SELECT customer.id
20+
INTO matched_customer_id
21+
FROM stripe.customers AS customer
22+
WHERE NEW.email IS NOT NULL
23+
AND customer.email IS NOT NULL
24+
AND NOT customer.deleted
25+
AND pg_catalog.lower(pg_catalog.btrim(customer.email))
26+
= pg_catalog.lower(pg_catalog.btrim(NEW.email))
27+
AND NOT EXISTS (
28+
SELECT 1
29+
FROM public.profiles AS linked_profile
30+
WHERE linked_profile.stripe_customer_id = customer.id
31+
)
32+
AND NOT EXISTS (
33+
SELECT 1
34+
FROM public.workspaces AS linked_workspace
35+
WHERE linked_workspace.stripe_customer_id = customer.id
36+
)
37+
ORDER BY
38+
CASE
39+
WHEN EXISTS (
40+
SELECT 1
41+
FROM stripe.subscriptions AS subscription
42+
WHERE subscription.customer = customer.id
43+
AND subscription.status IN ('active', 'past_due', 'unpaid')
44+
) THEN 0
45+
WHEN EXISTS (
46+
SELECT 1
47+
FROM stripe.subscriptions AS subscription
48+
WHERE subscription.customer = customer.id
49+
AND subscription.status IN ('trialing', 'paused')
50+
) THEN 1
51+
WHEN EXISTS (
52+
SELECT 1
53+
FROM stripe.subscriptions AS subscription
54+
WHERE subscription.customer = customer.id
55+
) THEN 2
56+
ELSE 3
57+
END,
58+
customer.created ASC NULLS LAST,
59+
customer.id
60+
LIMIT 1;
61+
62+
INSERT INTO public.profiles (id, stripe_customer_id)
63+
VALUES (NEW.id, matched_customer_id);
64+
65+
RETURN NEW;
66+
END;
67+
$$;
68+
69+
CREATE OR REPLACE FUNCTION public.handle_user_email_update()
70+
RETURNS trigger
71+
LANGUAGE plpgsql
72+
SECURITY DEFINER
73+
SET search_path = ''
74+
AS $$
75+
DECLARE
76+
matched_customer_id text;
77+
BEGIN
78+
IF OLD.email IS DISTINCT FROM NEW.email THEN
79+
IF NEW.email IS NOT NULL THEN
80+
PERFORM pg_catalog.pg_advisory_xact_lock(
81+
pg_catalog.hashtextextended(
82+
pg_catalog.lower(pg_catalog.btrim(NEW.email)),
83+
4030317
84+
)
85+
);
86+
END IF;
87+
88+
SELECT profile.stripe_customer_id
89+
INTO matched_customer_id
90+
FROM public.profiles AS profile
91+
WHERE profile.id = NEW.id;
92+
93+
IF matched_customer_id IS NULL THEN
94+
SELECT customer.id
95+
INTO matched_customer_id
96+
FROM stripe.customers AS customer
97+
WHERE NEW.email IS NOT NULL
98+
AND customer.email IS NOT NULL
99+
AND NOT customer.deleted
100+
AND pg_catalog.lower(pg_catalog.btrim(customer.email))
101+
= pg_catalog.lower(pg_catalog.btrim(NEW.email))
102+
AND NOT EXISTS (
103+
SELECT 1
104+
FROM public.profiles AS linked_profile
105+
WHERE linked_profile.stripe_customer_id = customer.id
106+
)
107+
AND NOT EXISTS (
108+
SELECT 1
109+
FROM public.workspaces AS linked_workspace
110+
WHERE linked_workspace.stripe_customer_id = customer.id
111+
)
112+
ORDER BY
113+
CASE
114+
WHEN EXISTS (
115+
SELECT 1
116+
FROM stripe.subscriptions AS subscription
117+
WHERE subscription.customer = customer.id
118+
AND subscription.status IN ('active', 'past_due', 'unpaid')
119+
) THEN 0
120+
WHEN EXISTS (
121+
SELECT 1
122+
FROM stripe.subscriptions AS subscription
123+
WHERE subscription.customer = customer.id
124+
AND subscription.status IN ('trialing', 'paused')
125+
) THEN 1
126+
WHEN EXISTS (
127+
SELECT 1
128+
FROM stripe.subscriptions AS subscription
129+
WHERE subscription.customer = customer.id
130+
) THEN 2
131+
ELSE 3
132+
END,
133+
customer.created ASC NULLS LAST,
134+
customer.id
135+
LIMIT 1;
136+
137+
IF matched_customer_id IS NOT NULL THEN
138+
UPDATE public.profiles AS profile
139+
SET stripe_customer_id = matched_customer_id
140+
WHERE profile.id = NEW.id;
141+
END IF;
142+
END IF;
143+
END IF;
144+
145+
RETURN NEW;
146+
END;
147+
$$;
148+
149+
REVOKE ALL ON FUNCTION public.handle_new_user()
150+
FROM PUBLIC, anon, authenticated;
151+
GRANT EXECUTE ON FUNCTION public.handle_new_user()
152+
TO supabase_auth_admin;
153+
154+
REVOKE ALL ON FUNCTION public.handle_user_email_update()
155+
FROM PUBLIC, anon, authenticated;
156+
GRANT EXECUTE ON FUNCTION public.handle_user_email_update()
157+
TO supabase_auth_admin;

0 commit comments

Comments
 (0)