Files
Jyotisha/frontend/supabase/migrations/20260720020000_conversational_rectification_billing.sql
T
2026-07-20 23:11:01 +08:00

663 lines
23 KiB
PL/PgSQL

begin;
create or replace function public.recover_conversational_rectification_orphan_reservations(
p_user_id uuid,
p_excluded_case_id uuid default null
)
returns integer
language plpgsql
security definer
set search_path = ''
as $$
declare
v_orphan public.birth_time_rectification_billing%rowtype;
v_balance integer;
v_recovery_action_id uuid;
v_recovery_fingerprint text;
v_recovery_response jsonb;
begin
if p_user_id is null then
raise exception 'conversational_billing_failed' using errcode = 'P0001';
end if;
perform pg_catalog.pg_advisory_xact_lock(
pg_catalog.hashtextextended(
p_user_id::text || ':conversational-rectification-case',
0
)
);
select profile.credits into v_balance
from public.profiles profile
where profile.id = p_user_id
for update;
if not found then
raise exception 'conversational_billing_failed' using errcode = 'P0001';
end if;
for v_orphan in
select orphan_billing.*
from public.birth_time_rectification_billing orphan_billing
left join public.birth_time_rectification_cases orphan_case
on orphan_case.id = orphan_billing.case_id
where orphan_billing.user_id = p_user_id
and (
p_excluded_case_id is null
or orphan_billing.case_id <> p_excluded_case_id
)
and orphan_billing.state = 'reserved'
and orphan_case.id is null
order by orphan_billing.reserved_at, orphan_billing.case_id
for update of orphan_billing
loop
v_recovery_action_id :=
public.conversational_rectification_billing_receipt_action_id(
v_orphan.reserve_action_id,
'recover_fee'
);
v_recovery_fingerprint := pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
pg_catalog.jsonb_build_object(
'kind', 'recover_fee', 'userId', p_user_id, 'caseId', v_orphan.case_id,
'expectedVersion', 0, 'actionId', v_recovery_action_id,
'reserveActionId', v_orphan.reserve_action_id
)::text,
'UTF8'
)), 'hex');
update public.profiles profile
set credits = profile.credits + v_orphan.price,
updated_at = pg_catalog.now()
where profile.id = p_user_id
returning profile.credits into v_balance;
if not found then
raise exception 'conversational_billing_failed' using errcode = 'P0001';
end if;
insert into public.credit_transactions (
user_id, transaction_type, amount, balance_after, request_id
) values (
p_user_id, 'refund', v_orphan.price, v_balance,
'rectification:' || v_orphan.case_id::text
);
update public.birth_time_rectification_billing orphan_billing
set state = 'released',
release_action_id = v_recovery_action_id,
balance_after = v_balance,
released_at = pg_catalog.now(),
updated_at = pg_catalog.now()
where orphan_billing.case_id = v_orphan.case_id
and orphan_billing.user_id = p_user_id
and orphan_billing.state = 'reserved';
if not found then
raise exception 'conversational_billing_failed' using errcode = 'P0001';
end if;
v_recovery_response := pg_catalog.jsonb_build_object(
'success', true, 'credits', v_balance,
'billing_state', 'released', 'error_code', null
);
insert into public.birth_time_rectification_action_receipts (
case_id, action_id, user_id, action_kind, expected_turn_version,
result_turn_version, request_fingerprint, request, response
) values (
v_orphan.case_id, v_recovery_action_id, p_user_id, 'recover_fee', 0,
0, v_recovery_fingerprint,
public.conversational_rectification_action_request(
'recover_fee', p_user_id, v_orphan.case_id, 0,
v_recovery_action_id, v_recovery_fingerprint
),
v_recovery_response
);
end loop;
return v_balance;
end;
$$;
create or replace function public.reserve_conversational_rectification_fee(
p_user_id uuid,
p_case_id uuid,
p_expected_version bigint,
p_action_id uuid,
p_price integer
)
returns table (
success boolean,
credits integer,
billing_state text,
error_code text
)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_receipt public.birth_time_rectification_action_receipts%rowtype;
v_billing public.birth_time_rectification_billing%rowtype;
v_receipt_action_id uuid :=
public.conversational_rectification_billing_receipt_action_id(
p_action_id,
'reserve_fee'
);
v_balance integer;
v_response jsonb;
v_fingerprint text := pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
pg_catalog.jsonb_build_object(
'kind', 'reserve_fee', 'userId', p_user_id, 'caseId', p_case_id,
'expectedVersion', p_expected_version, 'actionId', p_action_id,
'price', p_price
)::text,
'UTF8'
)), 'hex');
begin
if p_user_id is null or p_case_id is null or p_action_id is null
or p_expected_version is distinct from 0
or p_price is null or not (p_price between 1 and 1000000) then
raise exception 'conversational_billing_failed' using errcode = 'P0001';
end if;
if p_case_id is distinct from p_action_id then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
-- The account lock prevents two different start actions from reserving two
-- fees before either action has created its case. The action lock preserves
-- exact replay for the same request.
perform pg_catalog.pg_advisory_xact_lock(
pg_catalog.hashtextextended(
p_user_id::text || ':conversational-rectification-case',
0
)
);
perform pg_catalog.pg_advisory_xact_lock(
pg_catalog.hashtextextended(p_user_id::text || ':' || p_action_id::text, 0)
);
select r.* into v_receipt
from public.birth_time_rectification_action_receipts r
where r.case_id = p_case_id and r.action_id = v_receipt_action_id
for update;
if found then
if v_receipt.user_id is distinct from p_user_id
or v_receipt.action_kind is distinct from 'reserve_fee'
or v_receipt.expected_turn_version is distinct from p_expected_version
or v_receipt.request_fingerprint is distinct from v_fingerprint then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
return query select
(v_receipt.response ->> 'success')::boolean,
nullif(v_receipt.response ->> 'credits', '')::integer,
nullif(v_receipt.response ->> 'billing_state', ''),
nullif(v_receipt.response ->> 'error_code', '');
return;
end if;
-- A public action identifies one start attempt even if a buggy caller loses
-- its case id. Reusing that action for another case is a conflict, never a
-- second debit.
select r.* into v_receipt
from public.birth_time_rectification_action_receipts r
where r.user_id = p_user_id
and r.action_id = v_receipt_action_id
and r.action_kind = 'reserve_fee'
for update;
if found then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
perform 1
from public.birth_time_rectification_cases active_case
where active_case.user_id = p_user_id
and active_case.id <> p_case_id
and active_case.journey_protocol = 'conversational-evidence-v3'
and active_case.status in ('starting', 'active', 'paused', 'confirming')
for update;
if found then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
-- Reservation intentionally precedes the external first-turn calculation,
-- so it cannot share a transaction with case creation. A process/device
-- loss in that gap leaves no case for the account resume RPC to expose.
-- A fresh account-scoped start deterministically releases every such orphan
-- under the same account/profile locks before it attempts another debit.
v_balance := public.recover_conversational_rectification_orphan_reservations(
p_user_id,
p_case_id
);
select b.* into v_billing
from public.birth_time_rectification_billing b
where b.case_id = p_case_id
for update;
if found then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
if v_balance < p_price then
v_response := pg_catalog.jsonb_build_object(
'success', false, 'credits', v_balance,
'billing_state', null, 'error_code', 'insufficient_credits'
);
insert into public.birth_time_rectification_action_receipts (
case_id, action_id, user_id, action_kind, expected_turn_version,
result_turn_version, request_fingerprint, request, response
) values (
p_case_id, v_receipt_action_id, p_user_id, 'reserve_fee', 0,
0, v_fingerprint,
public.conversational_rectification_action_request(
'reserve_fee', p_user_id, p_case_id, 0, p_action_id, v_fingerprint
),
v_response
);
return query select false, v_balance, null::text, 'insufficient_credits'::text;
return;
end if;
update public.profiles profile
set credits = profile.credits - p_price,
updated_at = pg_catalog.now()
where profile.id = p_user_id and profile.credits >= p_price
returning profile.credits into v_balance;
if not found then
raise exception 'conversational_billing_failed' using errcode = 'P0001';
end if;
insert into public.credit_transactions (
user_id, transaction_type, amount, balance_after, request_id
) values (
p_user_id, 'reserve', -p_price, v_balance,
'rectification:' || p_case_id::text
);
insert into public.birth_time_rectification_billing (
case_id, user_id, price, state, reservation_id, reserve_action_id,
balance_after, reserved_at
) values (
p_case_id, p_user_id, p_price, 'reserved',
pg_catalog.gen_random_uuid(), p_action_id, v_balance, pg_catalog.now()
);
v_response := pg_catalog.jsonb_build_object(
'success', true, 'credits', v_balance,
'billing_state', 'reserved', 'error_code', null
);
insert into public.birth_time_rectification_action_receipts (
case_id, action_id, user_id, action_kind, expected_turn_version,
result_turn_version, request_fingerprint, request, response
) values (
p_case_id, v_receipt_action_id, p_user_id, 'reserve_fee', 0,
0, v_fingerprint,
public.conversational_rectification_action_request(
'reserve_fee', p_user_id, p_case_id, 0, p_action_id, v_fingerprint
),
v_response
);
return query select true, v_balance, 'reserved'::text, null::text;
end;
$$;
create or replace function public.complete_conversational_rectification_fee(
p_user_id uuid,
p_case_id uuid,
p_expected_version bigint,
p_action_id uuid
)
returns table (
success boolean,
credits integer,
billing_state text,
error_code text
)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_case public.birth_time_rectification_cases%rowtype;
v_receipt public.birth_time_rectification_action_receipts%rowtype;
v_billing public.birth_time_rectification_billing%rowtype;
v_receipt_action_id uuid :=
public.conversational_rectification_billing_receipt_action_id(
p_action_id,
'complete_fee'
);
v_balance integer;
v_response jsonb;
v_success boolean;
v_error_code text;
v_state text;
v_fingerprint text := pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
pg_catalog.jsonb_build_object(
'kind', 'complete_fee', 'userId', p_user_id, 'caseId', p_case_id,
'expectedVersion', p_expected_version, 'actionId', p_action_id
)::text,
'UTF8'
)), 'hex');
begin
if p_user_id is null or p_case_id is null or p_action_id is null
or p_expected_version is null or p_expected_version < 0 then
raise exception 'conversational_billing_failed' using errcode = 'P0001';
end if;
if p_case_id is distinct from p_action_id then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
perform pg_catalog.pg_advisory_xact_lock(
pg_catalog.hashtextextended(p_user_id::text || ':' || p_action_id::text, 0)
);
select c.* into v_case
from public.birth_time_rectification_cases c
where c.id = p_case_id and c.user_id = p_user_id
for update;
if not found or v_case.journey_protocol is distinct from 'conversational-evidence-v3' then
raise exception 'conversational_case_not_found' using errcode = 'P0001';
end if;
select r.* into v_receipt
from public.birth_time_rectification_action_receipts r
where r.case_id = p_case_id and r.action_id = v_receipt_action_id
for update;
if found then
if v_receipt.user_id is distinct from p_user_id
or v_receipt.action_kind is distinct from 'complete_fee'
or v_receipt.expected_turn_version is distinct from p_expected_version
or v_receipt.request_fingerprint is distinct from v_fingerprint then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
return query select
(v_receipt.response ->> 'success')::boolean,
nullif(v_receipt.response ->> 'credits', '')::integer,
nullif(v_receipt.response ->> 'billing_state', ''),
nullif(v_receipt.response ->> 'error_code', '');
return;
end if;
if v_case.turn_version is distinct from p_expected_version then
raise exception 'conversational_stale_turn' using errcode = 'P0001';
end if;
select profile.credits into v_balance
from public.profiles profile
where profile.id = p_user_id
for update;
if not found then
raise exception 'conversational_billing_failed' using errcode = 'P0001';
end if;
select b.* into v_billing
from public.birth_time_rectification_billing b
where b.case_id = p_case_id and b.user_id = p_user_id
for update;
if not found then
v_success := false;
v_state := null;
v_error_code := 'billing_missing';
elsif v_billing.state = 'charged' then
v_success := true;
v_state := 'charged';
v_error_code := null;
elsif v_billing.state = 'released' then
v_success := false;
v_state := 'released';
v_error_code := 'reservation_released';
elsif v_billing.state = 'migration_waived' then
v_success := true;
v_state := 'migration_waived';
v_error_code := null;
else
update public.birth_time_rectification_billing
set state = 'charged',
billing_receipt_id = pg_catalog.gen_random_uuid(),
complete_action_id = p_action_id,
charged_at = pg_catalog.now(),
updated_at = pg_catalog.now()
where case_id = p_case_id and user_id = p_user_id and state = 'reserved';
if not found then
raise exception 'conversational_billing_failed' using errcode = 'P0001';
end if;
v_success := true;
v_state := 'charged';
v_error_code := null;
end if;
v_response := pg_catalog.jsonb_build_object(
'success', v_success, 'credits', v_balance,
'billing_state', v_state, 'error_code', v_error_code
);
insert into public.birth_time_rectification_action_receipts (
case_id, action_id, user_id, action_kind, expected_turn_version,
result_turn_version, request_fingerprint, request, response
) values (
p_case_id, v_receipt_action_id, p_user_id, 'complete_fee', p_expected_version,
v_case.turn_version, v_fingerprint,
public.conversational_rectification_action_request(
'complete_fee', p_user_id, p_case_id, p_expected_version,
p_action_id, v_fingerprint
),
v_response
);
return query select v_success, v_balance, v_state, v_error_code;
end;
$$;
create or replace function public.release_conversational_rectification_fee(
p_user_id uuid,
p_case_id uuid,
p_expected_version bigint,
p_action_id uuid,
p_price integer
)
returns table (
success boolean,
credits integer,
billing_state text,
error_code text
)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_case public.birth_time_rectification_cases%rowtype;
v_receipt public.birth_time_rectification_action_receipts%rowtype;
v_billing public.birth_time_rectification_billing%rowtype;
v_receipt_action_id uuid :=
public.conversational_rectification_billing_receipt_action_id(
p_action_id,
'release_fee'
);
v_balance integer;
v_result_version bigint := p_expected_version;
v_response jsonb;
v_success boolean := true;
v_error_code text;
v_state text := 'released';
v_fingerprint text := pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
pg_catalog.jsonb_build_object(
'kind', 'release_fee', 'userId', p_user_id, 'caseId', p_case_id,
'expectedVersion', p_expected_version, 'actionId', p_action_id,
'price', p_price
)::text,
'UTF8'
)), 'hex');
begin
if p_user_id is null or p_case_id is null or p_action_id is null
or p_expected_version is null or p_expected_version < 0
or p_price is null or not (p_price between 1 and 1000000) then
raise exception 'conversational_billing_failed' using errcode = 'P0001';
end if;
if p_case_id is distinct from p_action_id then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
perform pg_catalog.pg_advisory_xact_lock(
pg_catalog.hashtextextended(p_user_id::text || ':' || p_action_id::text, 0)
);
select r.* into v_receipt
from public.birth_time_rectification_action_receipts r
where r.case_id = p_case_id and r.action_id = v_receipt_action_id
for update;
if found then
if v_receipt.user_id is distinct from p_user_id
or v_receipt.action_kind is distinct from 'release_fee'
or v_receipt.expected_turn_version is distinct from p_expected_version
or v_receipt.request_fingerprint is distinct from v_fingerprint then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
return query select
(v_receipt.response ->> 'success')::boolean,
nullif(v_receipt.response ->> 'credits', '')::integer,
nullif(v_receipt.response ->> 'billing_state', ''),
nullif(v_receipt.response ->> 'error_code', '');
return;
end if;
select c.* into v_case
from public.birth_time_rectification_cases c
where c.id = p_case_id
for update;
if found then
if v_case.user_id is distinct from p_user_id
or v_case.journey_protocol is distinct from 'conversational-evidence-v3' then
raise exception 'conversational_case_not_found' using errcode = 'P0001';
end if;
if v_case.turn_version is distinct from p_expected_version then
raise exception 'conversational_stale_turn' using errcode = 'P0001';
end if;
v_result_version := v_case.turn_version;
elsif p_expected_version is distinct from 0 then
raise exception 'conversational_stale_turn' using errcode = 'P0001';
end if;
select profile.credits into v_balance
from public.profiles profile
where profile.id = p_user_id
for update;
if not found then
raise exception 'conversational_billing_failed' using errcode = 'P0001';
end if;
select b.* into v_billing
from public.birth_time_rectification_billing b
where b.case_id = p_case_id
for update;
if not found then
insert into public.birth_time_rectification_billing (
case_id, user_id, price, state, release_action_id,
balance_after, released_at
) values (
p_case_id, p_user_id, p_price, 'released', p_action_id,
v_balance, pg_catalog.now()
);
elsif v_billing.user_id is distinct from p_user_id then
raise exception 'conversational_case_not_found' using errcode = 'P0001';
elsif v_billing.state = 'released' then
v_state := 'released';
elsif v_billing.state = 'charged' then
v_success := false;
v_state := 'charged';
v_error_code := 'already_charged';
elsif v_billing.state = 'migration_waived' then
v_state := 'migration_waived';
else
update public.profiles profile
set credits = profile.credits + v_billing.price,
updated_at = pg_catalog.now()
where profile.id = p_user_id
returning profile.credits into v_balance;
insert into public.credit_transactions (
user_id, transaction_type, amount, balance_after, request_id
) values (
p_user_id, 'refund', v_billing.price, v_balance,
'rectification:' || p_case_id::text
);
update public.birth_time_rectification_billing
set state = 'released',
release_action_id = p_action_id,
balance_after = v_balance,
released_at = pg_catalog.now(),
updated_at = pg_catalog.now()
where case_id = p_case_id and user_id = p_user_id and state = 'reserved';
if not found then
raise exception 'conversational_billing_failed' using errcode = 'P0001';
end if;
-- Creation and fee settlement are separate calls. If settlement fails
-- after the case was created, releasing the reservation must also close
-- that unfinished case or it would block every later account-level start.
if v_case.id is not null
and v_case.status in ('starting', 'active', 'paused', 'confirming') then
update public.birth_time_rectification_cases c
set status = 'abandoned',
turn_state = case
when pg_catalog.jsonb_typeof(c.turn_state) = 'object'
then pg_catalog.jsonb_set(
c.turn_state, '{status}', pg_catalog.to_jsonb('abandoned'::text), true
)
else c.turn_state
end,
journey_snapshot = case
when pg_catalog.jsonb_typeof(c.journey_snapshot) = 'object'
then pg_catalog.jsonb_set(
c.journey_snapshot, '{status}', pg_catalog.to_jsonb('abandoned'::text), true
)
else c.journey_snapshot
end,
updated_at = pg_catalog.now()
where c.id = p_case_id
and c.user_id = p_user_id
and c.journey_protocol = 'conversational-evidence-v3'
and c.turn_version = p_expected_version
and c.status in ('starting', 'active', 'paused', 'confirming');
if not found then
raise exception 'conversational_stale_turn' using errcode = 'P0001';
end if;
end if;
end if;
v_response := pg_catalog.jsonb_build_object(
'success', v_success, 'credits', v_balance,
'billing_state', v_state, 'error_code', v_error_code
);
insert into public.birth_time_rectification_action_receipts (
case_id, action_id, user_id, action_kind, expected_turn_version,
result_turn_version, request_fingerprint, request, response
) values (
p_case_id, v_receipt_action_id, p_user_id, 'release_fee', p_expected_version,
v_result_version, v_fingerprint,
public.conversational_rectification_action_request(
'release_fee', p_user_id, p_case_id, p_expected_version,
p_action_id, v_fingerprint
),
v_response
);
return query select v_success, v_balance, v_state, v_error_code;
end;
$$;
revoke all on function public.reserve_conversational_rectification_fee(
uuid, uuid, bigint, uuid, integer
) from public, anon, authenticated;
revoke all on function public.recover_conversational_rectification_orphan_reservations(
uuid, uuid
) from public, anon, authenticated, service_role;
revoke all on function public.complete_conversational_rectification_fee(
uuid, uuid, bigint, uuid
) from public, anon, authenticated;
revoke all on function public.release_conversational_rectification_fee(
uuid, uuid, bigint, uuid, integer
) from public, anon, authenticated;
grant execute on function public.reserve_conversational_rectification_fee(
uuid, uuid, bigint, uuid, integer
) to service_role;
grant execute on function public.complete_conversational_rectification_fee(
uuid, uuid, bigint, uuid
) to service_role;
grant execute on function public.release_conversational_rectification_fee(
uuid, uuid, bigint, uuid, integer
) to service_role;
commit;