Files
Jyotisha/frontend/supabase/migrations/20260715000000_account_credits.sql
T
2026-07-15 22:23:17 +08:00

311 lines
9.6 KiB
PL/PgSQL

create extension if not exists pgcrypto;
create table if not exists public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
email text,
credits integer not null default 0 check (credits >= 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.redemption_codes (
id uuid primary key default gen_random_uuid(),
code_hash text not null unique check (code_hash ~ '^[0-9a-f]{64}$'),
code_mask text not null check (code_mask like 'JYOTISH-****-%'),
credits integer not null check (credits > 0),
expires_at timestamptz,
note text,
created_by uuid references auth.users(id) on delete set null,
created_at timestamptz not null default now(),
redeemed_by uuid,
redeemed_email text,
redeemed_at timestamptz,
check ((redeemed_by is null and redeemed_at is null) or (redeemed_by is not null and redeemed_at is not null))
);
create table if not exists public.credit_transactions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
transaction_type text not null check (transaction_type in ('redeem', 'reserve', 'refund')),
amount integer not null,
check ((transaction_type = 'reserve' and amount < 0)
or (transaction_type in ('redeem', 'refund') and amount > 0)),
balance_after integer not null check (balance_after >= 0),
request_id text not null check (char_length(request_id) between 1 and 200),
redemption_code_id uuid unique references public.redemption_codes(id) on delete set null,
model text,
input_tokens integer check (input_tokens is null or input_tokens >= 0),
output_tokens integer check (output_tokens is null or output_tokens >= 0),
created_at timestamptz not null default now(),
unique (user_id, transaction_type, request_id)
);
create index if not exists redemption_codes_created_at_idx
on public.redemption_codes (created_at desc);
create index if not exists credit_transactions_user_created_at_idx
on public.credit_transactions (user_id, created_at desc);
alter table public.profiles enable row level security;
alter table public.redemption_codes enable row level security;
alter table public.credit_transactions enable row level security;
create policy profiles_select_own
on public.profiles for select to authenticated
using ((select auth.uid()) = id);
create policy credit_transactions_select_own
on public.credit_transactions for select to authenticated
using ((select auth.uid()) = user_id);
revoke all on public.profiles from anon, authenticated;
revoke all on public.redemption_codes from anon, authenticated;
revoke all on public.credit_transactions from anon, authenticated;
grant select on public.profiles to authenticated;
grant select on public.credit_transactions to authenticated;
create or replace function public.handle_new_jyotish_user()
returns trigger
language plpgsql
security definer
set search_path = public, pg_temp
as $$
begin
insert into public.profiles (id, email)
values (new.id, new.email)
on conflict (id) do nothing;
return new;
end;
$$;
revoke all on function public.handle_new_jyotish_user() from public;
drop trigger if exists jyotish_profile_on_auth_user_created on auth.users;
create trigger jyotish_profile_on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_jyotish_user();
insert into public.profiles (id, email)
select id, email from auth.users
on conflict (id) do nothing;
create or replace function public.redeem_code(p_code_hash text)
returns table (success boolean, credits integer, error_code text)
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
v_user_id uuid := auth.uid();
v_email text := auth.jwt() ->> 'email';
v_code public.redemption_codes%rowtype;
v_balance integer;
begin
if v_user_id is null then
return query select false, null::integer, 'unauthorized'::text;
return;
end if;
if p_code_hash is null or p_code_hash !~ '^[0-9a-f]{64}$' then
return query select false, null::integer, 'invalid_code'::text;
return;
end if;
select rc.* into v_code
from public.redemption_codes as rc
where rc.code_hash = p_code_hash
for update;
if not found then
return query select false, null::integer, 'invalid_code'::text;
return;
end if;
if v_code.redeemed_by is not null then
return query select false, null::integer, 'already_redeemed'::text;
return;
end if;
if v_code.expires_at is not null and v_code.expires_at <= now() then
return query select false, null::integer, 'expired_code'::text;
return;
end if;
select p.credits into v_balance
from public.profiles as p
where p.id = v_user_id
for update;
if not found then
return query select false, null::integer, 'profile_missing'::text;
return;
end if;
update public.redemption_codes as rc
set redeemed_by = v_user_id,
redeemed_email = v_email,
redeemed_at = now()
where rc.id = v_code.id;
update public.profiles as p
set credits = p.credits + v_code.credits,
updated_at = now()
where p.id = v_user_id
returning p.credits into v_balance;
insert into public.credit_transactions (
user_id, transaction_type, amount, balance_after, request_id, redemption_code_id
) values (
v_user_id, 'redeem', v_code.credits, v_balance, v_code.id::text, v_code.id
);
return query select true, v_balance, null::text;
end;
$$;
create or replace function public.reserve_credit(p_user_id uuid, p_request_id text, p_credits integer default 1)
returns table (success boolean, credits integer, error_code text)
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
v_user_id uuid := p_user_id;
v_request_id text := btrim(p_request_id);
v_balance integer;
v_existing public.credit_transactions%rowtype;
begin
if v_user_id is null then
return query select false, null::integer, 'unauthorized'::text;
return;
end if;
if v_request_id is null or char_length(v_request_id) not between 1 and 200
or p_credits is null or p_credits <= 0 then
return query select false, null::integer, 'invalid_request'::text;
return;
end if;
select p.credits into v_balance
from public.profiles as p
where p.id = v_user_id
for update;
if not found then
return query select false, null::integer, 'profile_missing'::text;
return;
end if;
select tx.* into v_existing
from public.credit_transactions as tx
where tx.user_id = v_user_id
and tx.transaction_type = 'reserve'
and tx.request_id = v_request_id;
if found then
if v_existing.amount = -p_credits then
return query select true, v_existing.balance_after, null::text;
else
return query select false, v_balance, 'request_conflict'::text;
end if;
return;
end if;
update public.profiles as p
set credits = p.credits - p_credits,
updated_at = now()
where p.id = v_user_id and p.credits >= p_credits
returning p.credits into v_balance;
if not found then
return query select false, v_balance, 'insufficient_credits'::text;
return;
end if;
insert into public.credit_transactions (
user_id, transaction_type, amount, balance_after, request_id
) values (
v_user_id, 'reserve', -p_credits, v_balance, v_request_id
);
return query select true, v_balance, null::text;
end;
$$;
create or replace function public.refund_credit(p_user_id uuid, p_request_id text)
returns table (success boolean, credits integer, error_code text)
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
v_user_id uuid := p_user_id;
v_request_id text := btrim(p_request_id);
v_balance integer;
v_reserve public.credit_transactions%rowtype;
v_refund public.credit_transactions%rowtype;
begin
if v_user_id is null then
return query select false, null::integer, 'unauthorized'::text;
return;
end if;
if v_request_id is null or char_length(v_request_id) not between 1 and 200 then
return query select false, null::integer, 'invalid_request'::text;
return;
end if;
select p.credits into v_balance
from public.profiles as p
where p.id = v_user_id
for update;
if not found then
return query select false, null::integer, 'profile_missing'::text;
return;
end if;
select tx.* into v_refund
from public.credit_transactions as tx
where tx.user_id = v_user_id
and tx.transaction_type = 'refund'
and tx.request_id = v_request_id;
if found then
return query select true, v_refund.balance_after, null::text;
return;
end if;
select tx.* into v_reserve
from public.credit_transactions as tx
where tx.user_id = v_user_id
and tx.transaction_type = 'reserve'
and tx.request_id = v_request_id;
if not found then
return query select false, v_balance, 'reservation_not_found'::text;
return;
end if;
update public.profiles as p
set credits = p.credits - v_reserve.amount,
updated_at = now()
where p.id = v_user_id
returning p.credits into v_balance;
insert into public.credit_transactions (
user_id, transaction_type, amount, balance_after, request_id
) values (
v_user_id, 'refund', -v_reserve.amount, v_balance, v_request_id
);
return query select true, v_balance, null::text;
end;
$$;
revoke all on function public.redeem_code(text) from public, anon;
revoke all on function public.reserve_credit(uuid, text, integer) from public, anon, authenticated;
revoke all on function public.refund_credit(uuid, text) from public, anon, authenticated;
grant execute on function public.redeem_code(text) to authenticated;
grant execute on function public.reserve_credit(uuid, text, integer) to service_role;
grant execute on function public.refund_credit(uuid, text) to service_role;