-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Expand file tree
/
Copy path20260509090000_membership_billing.sql
More file actions
379 lines (333 loc) · 12.1 KB
/
Copy path20260509090000_membership_billing.sql
File metadata and controls
379 lines (333 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
alter table public.profiles
add column if not exists stripe_customer_id text;
create unique index if not exists profiles_stripe_customer_id_idx
on public.profiles (stripe_customer_id)
where stripe_customer_id is not null;
alter table public.generation_reservations
add column if not exists usage_source text not null default 'legacy',
add column if not exists generation_cost integer not null default 1 check (generation_cost >= 0);
alter table public.credit_transactions
drop constraint if exists credit_transactions_type_check;
alter table public.credit_transactions
add constraint credit_transactions_type_check
check (type in ('grant', 'purchase', 'membership_grant', 'generation', 'refund', 'adjustment'));
create table if not exists public.membership_plans (
id text primary key,
name_en text not null,
name_zh text not null,
description_en text not null,
description_zh text not null,
monthly_credits integer not null check (monthly_credits >= 0),
amount_cents integer not null check (amount_cents >= 0),
currency text not null default 'usd',
interval text not null default 'month' check (interval in ('month', 'year')),
active boolean not null default true,
sort_order integer not null default 0,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.credit_packs (
id text primary key,
name_en text not null,
name_zh text not null,
description_en text not null,
description_zh text not null,
credits integer not null check (credits > 0),
amount_cents integer not null check (amount_cents >= 0),
currency text not null default 'usd',
active boolean not null default true,
sort_order integer not null default 0,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.user_memberships (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
plan_id text references public.membership_plans(id),
status text not null default 'inactive' check (status in ('inactive', 'trialing', 'active', 'past_due', 'canceled', 'unpaid')),
stripe_customer_id text,
stripe_subscription_id text,
current_period_start timestamptz,
current_period_end timestamptz,
cancel_at_period_end boolean not null default false,
monthly_credits_granted_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (user_id),
unique (stripe_subscription_id)
);
create table if not exists public.payment_orders (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
product_type text not null check (product_type in ('credit_pack', 'membership')),
product_id text not null,
status text not null default 'created' check (status in ('created', 'checkout_created', 'completed', 'failed', 'canceled')),
stripe_session_id text,
stripe_customer_id text,
stripe_subscription_id text,
amount_cents integer not null default 0 check (amount_cents >= 0),
currency text not null default 'usd',
credits integer not null default 0 check (credits >= 0),
metadata jsonb not null default '{}'::jsonb,
completed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index if not exists payment_orders_stripe_session_id_idx
on public.payment_orders (stripe_session_id)
where stripe_session_id is not null;
create index if not exists user_memberships_user_id_idx
on public.user_memberships (user_id);
create index if not exists user_memberships_status_idx
on public.user_memberships (status);
create index if not exists payment_orders_user_id_idx
on public.payment_orders (user_id, created_at desc);
create index if not exists payment_orders_status_idx
on public.payment_orders (status);
drop trigger if exists membership_plans_set_updated_at on public.membership_plans;
create trigger membership_plans_set_updated_at
before update on public.membership_plans
for each row
execute function public.set_updated_at();
drop trigger if exists credit_packs_set_updated_at on public.credit_packs;
create trigger credit_packs_set_updated_at
before update on public.credit_packs
for each row
execute function public.set_updated_at();
drop trigger if exists user_memberships_set_updated_at on public.user_memberships;
create trigger user_memberships_set_updated_at
before update on public.user_memberships
for each row
execute function public.set_updated_at();
drop trigger if exists payment_orders_set_updated_at on public.payment_orders;
create trigger payment_orders_set_updated_at
before update on public.payment_orders
for each row
execute function public.set_updated_at();
alter table public.membership_plans enable row level security;
alter table public.credit_packs enable row level security;
alter table public.user_memberships enable row level security;
alter table public.payment_orders enable row level security;
drop policy if exists "Anyone can read active membership plans" on public.membership_plans;
create policy "Anyone can read active membership plans"
on public.membership_plans for select
using (active);
drop policy if exists "Anyone can read active credit packs" on public.credit_packs;
create policy "Anyone can read active credit packs"
on public.credit_packs for select
using (active);
drop policy if exists "Users can read own membership" on public.user_memberships;
create policy "Users can read own membership"
on public.user_memberships for select
using ((select auth.uid()) = user_id);
drop policy if exists "Users can read own payment orders" on public.payment_orders;
create policy "Users can read own payment orders"
on public.payment_orders for select
using ((select auth.uid()) = user_id);
grant select on public.membership_plans to anon, authenticated;
grant select on public.credit_packs to anon, authenticated;
grant select on public.user_memberships to authenticated;
grant select on public.payment_orders to authenticated;
insert into public.membership_plans (
id,
name_en,
name_zh,
description_en,
description_zh,
monthly_credits,
amount_cents,
sort_order
)
values
('starter', 'Starter', '入门会员', 'For light prompt testing and daily inspiration.', '适合轻量测试提示词和日常找灵感。', 80, 900, 10),
('creator', 'Creator', '创作者会员', 'For frequent case remixing and content production.', '适合高频复用案例并做内容生产。', 220, 1900, 20),
('studio', 'Studio', '工作室会员', 'For teams and high-volume GPT-Image2 experiments.', '适合团队和高频 GPT-Image2 实验。', 700, 4900, 30)
on conflict (id) do update
set name_en = excluded.name_en,
name_zh = excluded.name_zh,
description_en = excluded.description_en,
description_zh = excluded.description_zh,
monthly_credits = excluded.monthly_credits,
amount_cents = excluded.amount_cents,
sort_order = excluded.sort_order,
active = true;
insert into public.credit_packs (
id,
name_en,
name_zh,
description_en,
description_zh,
credits,
amount_cents,
sort_order
)
values
('pack_30', '30 Credits', '30 积分包', 'A small pack for trying more cases.', '适合继续尝试更多案例。', 30, 500, 10),
('pack_120', '120 Credits', '120 积分包', 'A balanced pack for regular prompt testing.', '适合稳定进行提示词测试。', 120, 1500, 20),
('pack_360', '360 Credits', '360 积分包', 'A larger pack for content batches and teams.', '适合批量内容生产和小团队使用。', 360, 3900, 30)
on conflict (id) do update
set name_en = excluded.name_en,
name_zh = excluded.name_zh,
description_en = excluded.description_en,
description_zh = excluded.description_zh,
credits = excluded.credits,
amount_cents = excluded.amount_cents,
sort_order = excluded.sort_order,
active = true;
create or replace function public.grant_user_credits(
p_user_id uuid,
p_amount integer,
p_type text,
p_source text default null,
p_reference_id uuid default null,
p_metadata jsonb default '{}'::jsonb
)
returns table (
credit_balance integer
)
language plpgsql
security definer
set search_path = public
as $$
declare
v_profile public.profiles%rowtype;
v_next_balance integer;
begin
if p_amount = 0 then
select p.*
into v_profile
from public.profiles as p
where p.id = p_user_id
for update;
if not found then
raise exception 'PROFILE_NOT_FOUND' using errcode = 'P0001';
end if;
return query select v_profile.credit_balance;
return;
end if;
select p.*
into v_profile
from public.profiles as p
where p.id = p_user_id
for update;
if not found then
raise exception 'PROFILE_NOT_FOUND' using errcode = 'P0001';
end if;
v_next_balance := v_profile.credit_balance + p_amount;
if v_next_balance < 0 then
raise exception 'CREDITS_INSUFFICIENT' using errcode = 'P0001';
end if;
update public.profiles as p
set credit_balance = v_next_balance
where p.id = p_user_id
returning p.* into v_profile;
insert into public.credit_transactions (
user_id,
amount,
type,
source,
reference_id,
metadata
)
values (
p_user_id,
p_amount,
p_type,
p_source,
p_reference_id,
coalesce(p_metadata, '{}'::jsonb)
);
return query select v_profile.credit_balance;
end;
$$;
create or replace function public.reserve_generation_usage(
p_user_id uuid,
p_case_id integer,
p_prompt text
)
returns table (
reservation_id uuid,
used_free_generation boolean,
credit_amount integer,
free_generations_used integer,
credit_balance integer
)
language plpgsql
security definer
set search_path = public
as $$
declare
v_profile public.profiles%rowtype;
v_reservation_id uuid;
begin
select p.*
into v_profile
from public.profiles as p
where p.id = p_user_id
for update;
if not found then
raise exception 'PROFILE_NOT_FOUND' using errcode = 'P0001';
end if;
if v_profile.free_generations_used < 1 then
update public.profiles as p
set free_generations_used = p.free_generations_used + 1
where p.id = p_user_id
returning p.* into v_profile;
insert into public.generation_reservations (
user_id,
case_id,
prompt,
used_free_generation,
credit_amount,
usage_source
)
values (p_user_id, p_case_id, p_prompt, true, 0, 'free_generation')
returning id into v_reservation_id;
return query
select v_reservation_id, true, 0, v_profile.free_generations_used, v_profile.credit_balance;
return;
end if;
if v_profile.credit_balance >= 1 then
update public.profiles as p
set credit_balance = p.credit_balance - 1
where p.id = p_user_id
returning p.* into v_profile;
insert into public.generation_reservations (
user_id,
case_id,
prompt,
used_free_generation,
credit_amount,
usage_source
)
values (p_user_id, p_case_id, p_prompt, false, 1, 'credit')
returning id into v_reservation_id;
insert into public.credit_transactions (
user_id,
amount,
type,
source,
reference_id,
metadata
)
values (
p_user_id,
-1,
'generation',
'case_generation_test',
v_reservation_id,
jsonb_build_object('caseId', p_case_id, 'usageSource', 'credit')
);
return query
select v_reservation_id, false, 1, v_profile.free_generations_used, v_profile.credit_balance;
return;
end if;
raise exception 'CREDITS_REQUIRED' using errcode = 'P0001';
end;
$$;
revoke execute on function public.grant_user_credits(uuid, integer, text, text, uuid, jsonb) from public, anon, authenticated;
revoke execute on function public.reserve_generation_usage(uuid, integer, text) from public, anon, authenticated;
grant execute on function public.grant_user_credits(uuid, integer, text, text, uuid, jsonb) to service_role;
grant execute on function public.reserve_generation_usage(uuid, integer, text) to service_role;