574 lines
23 KiB
PL/PgSQL
574 lines
23 KiB
PL/PgSQL
-- =====================================================================
|
|
-- Migration 0009 — External reconciliation (roadmap Step 10).
|
|
--
|
|
-- The strongest fraud control is an external source of truth. Every
|
|
-- provider (OMT, Alfa, touch, Ogero, the bank, whish, the card terminal)
|
|
-- publishes a settlement statement; we import it line by line and match
|
|
-- each line to a local transaction by `external_ref`. Mismatches go to
|
|
-- `reconciliation_exceptions` and block month-close.
|
|
--
|
|
-- Threat-model rows addressed: 1, 5, 8, 21, 24.
|
|
-- =====================================================================
|
|
|
|
-- =====================================================================
|
|
-- Settlement headers + lines
|
|
-- =====================================================================
|
|
do $$ begin
|
|
create type app.settlement_provider as enum (
|
|
'OMT', 'ALFA', 'TOUCH', 'OGERO', 'WHISH', 'CARD_TERMINAL', 'BANK', 'WU'
|
|
);
|
|
exception when duplicate_object then null; end $$;
|
|
|
|
do $$ begin
|
|
create type app.settlement_status as enum (
|
|
'imported', -- file parsed; matching not started
|
|
'matching', -- run is in progress
|
|
'matched', -- all lines matched, ready for sign-off
|
|
'has_exceptions', -- at least one line still unresolved
|
|
'closed' -- owner-signed off, immutable
|
|
);
|
|
exception when duplicate_object then null; end $$;
|
|
|
|
do $$ begin
|
|
create type app.settlement_line_status as enum (
|
|
'unmatched', -- no candidate found yet
|
|
'matched', -- exactly one candidate, amounts agree
|
|
'amount_mismatch', -- candidate found but money differs
|
|
'duplicate', -- the same external_ref already used elsewhere
|
|
'missing_local', -- provider has it; we don't
|
|
'extra_local' -- we have it; provider doesn't
|
|
);
|
|
exception when duplicate_object then null; end $$;
|
|
|
|
create table if not exists app.settlements (
|
|
id uuid primary key default gen_random_uuid(),
|
|
shop_id uuid not null references app.shops(id) on delete restrict,
|
|
provider app.settlement_provider not null,
|
|
period_start date not null,
|
|
period_end date not null,
|
|
file_url text,
|
|
file_sha256 text,
|
|
total_amount numeric(20,2), -- as reported by provider
|
|
total_currency app.currency_code,
|
|
status app.settlement_status not null default 'imported',
|
|
imported_at timestamptz not null default now(),
|
|
imported_by uuid not null references auth.users(id) default auth.uid(),
|
|
closed_at timestamptz,
|
|
closed_by uuid references auth.users(id),
|
|
notes text,
|
|
constraint settlements_period_ok check (period_end >= period_start)
|
|
);
|
|
create index if not exists idx_settlements_shop_period
|
|
on app.settlements(shop_id, provider, period_start);
|
|
|
|
create table if not exists app.settlement_lines (
|
|
id uuid primary key default gen_random_uuid(),
|
|
settlement_id uuid not null references app.settlements(id) on delete cascade,
|
|
-- Raw fields as parsed from the provider file:
|
|
external_ref text not null, -- provider txn id / receipt no
|
|
occurred_at timestamptz,
|
|
amount numeric(20,2) not null,
|
|
currency app.currency_code not null,
|
|
fee numeric(20,2),
|
|
commission numeric(20,2),
|
|
raw jsonb, -- the original parsed row
|
|
|
|
-- Match output:
|
|
matched_txn_id uuid references app.transactions(id),
|
|
status app.settlement_line_status not null default 'unmatched',
|
|
matched_at timestamptz,
|
|
-- A natural key per provider statement keeps imports idempotent.
|
|
unique (settlement_id, external_ref)
|
|
);
|
|
create index if not exists idx_settle_line_status on app.settlement_lines(settlement_id, status);
|
|
create index if not exists idx_settle_line_ref on app.settlement_lines(external_ref);
|
|
|
|
-- Exceptions queue. Every non-matched line generates a row here so the
|
|
-- owner has a single place to clear before closing the period.
|
|
create table if not exists app.reconciliation_exceptions (
|
|
id uuid primary key default gen_random_uuid(),
|
|
settlement_id uuid not null references app.settlements(id) on delete cascade,
|
|
line_id uuid references app.settlement_lines(id) on delete cascade,
|
|
txn_id uuid references app.transactions(id),
|
|
type app.settlement_line_status not null,
|
|
detail text,
|
|
resolved_at timestamptz,
|
|
resolved_by uuid references auth.users(id),
|
|
resolution_note text,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
create index if not exists idx_recon_exc_open
|
|
on app.reconciliation_exceptions(settlement_id) where resolved_at is null;
|
|
|
|
-- Late FK from float_movements (declared in 0005).
|
|
alter table app.float_movements
|
|
drop constraint if exists float_mov_settlement_fk;
|
|
alter table app.float_movements
|
|
add constraint float_mov_settlement_fk
|
|
foreign key (ref_settlement_id) references app.settlements(id) on delete restrict;
|
|
|
|
-- =====================================================================
|
|
-- Append-only behaviour where it matters
|
|
-- =====================================================================
|
|
-- Settlements: status moves are owner-driven via functions below.
|
|
create or replace function app._settlements_guard()
|
|
returns trigger language plpgsql as $$
|
|
begin
|
|
if tg_op = 'DELETE' then
|
|
raise exception 'settlements cannot be deleted';
|
|
end if;
|
|
if old.status = 'closed' then
|
|
raise exception 'settlement % is closed and immutable', old.id;
|
|
end if;
|
|
if current_setting('app.settle_internal', true) is distinct from 'on' then
|
|
raise exception 'direct UPDATE on settlements is not allowed; use app.* functions';
|
|
end if;
|
|
return new;
|
|
end;
|
|
$$;
|
|
drop trigger if exists trg_settlements_guard on app.settlements;
|
|
create trigger trg_settlements_guard before update or delete on app.settlements
|
|
for each row execute function app._settlements_guard();
|
|
|
|
-- Lines: insert at import time, matched in place by definer functions.
|
|
create or replace function app._settle_lines_guard()
|
|
returns trigger language plpgsql as $$
|
|
declare s app.settlements%rowtype;
|
|
begin
|
|
if tg_op = 'DELETE' then
|
|
raise exception 'settlement_lines cannot be deleted';
|
|
end if;
|
|
select * into s from app.settlements where id = coalesce(new.settlement_id, old.settlement_id);
|
|
if s.status = 'closed' then
|
|
raise exception 'cannot modify lines of a closed settlement';
|
|
end if;
|
|
if current_setting('app.settle_internal', true) is distinct from 'on' then
|
|
raise exception 'direct UPDATE on settlement_lines is not allowed';
|
|
end if;
|
|
return new;
|
|
end;
|
|
$$;
|
|
drop trigger if exists trg_settle_lines_guard on app.settlement_lines;
|
|
create trigger trg_settle_lines_guard before update or delete on app.settlement_lines
|
|
for each row execute function app._settle_lines_guard();
|
|
|
|
-- Exceptions: insert by matcher; resolution via definer.
|
|
create or replace function app._recon_exc_guard()
|
|
returns trigger language plpgsql as $$
|
|
begin
|
|
if tg_op = 'DELETE' then
|
|
raise exception 'reconciliation_exceptions cannot be deleted';
|
|
end if;
|
|
if current_setting('app.settle_internal', true) is distinct from 'on' then
|
|
raise exception 'direct UPDATE on reconciliation_exceptions is not allowed';
|
|
end if;
|
|
return new;
|
|
end;
|
|
$$;
|
|
drop trigger if exists trg_recon_exc_guard on app.reconciliation_exceptions;
|
|
create trigger trg_recon_exc_guard before update or delete on app.reconciliation_exceptions
|
|
for each row execute function app._recon_exc_guard();
|
|
|
|
-- =====================================================================
|
|
-- Provider → service code map. Used by the matcher to know which local
|
|
-- service rows are eligible candidates for a given settlement file.
|
|
-- =====================================================================
|
|
create or replace function app._provider_service_codes(p app.settlement_provider)
|
|
returns text[]
|
|
language sql
|
|
immutable
|
|
as $$
|
|
select case p
|
|
when 'OMT' then array['OMT_SEND','OMT_RECEIVE','OMT_BILL']
|
|
when 'ALFA' then array['ALFA_RECHARGE']
|
|
when 'TOUCH' then array['TOUCH_RECHARGE']
|
|
when 'OGERO' then array['OGERO_RECHARGE','INTERNET_RECHARGE']
|
|
when 'WU' then array['WU_SEND','WU_RECEIVE']
|
|
-- BANK / WHISH / CARD_TERMINAL match by payment_method instead.
|
|
else null
|
|
end::text[];
|
|
$$;
|
|
|
|
-- =====================================================================
|
|
-- Import + match
|
|
-- =====================================================================
|
|
-- Insert one parsed row from the provider file. Idempotent by
|
|
-- (settlement_id, external_ref).
|
|
create or replace function app.add_settlement_line(
|
|
p_settlement uuid,
|
|
p_external_ref text,
|
|
p_occurred_at timestamptz,
|
|
p_amount numeric,
|
|
p_currency app.currency_code,
|
|
p_fee numeric,
|
|
p_commission numeric,
|
|
p_raw jsonb
|
|
) returns uuid
|
|
language plpgsql
|
|
security definer
|
|
set search_path = app, public
|
|
as $$
|
|
declare s app.settlements%rowtype; lid uuid;
|
|
begin
|
|
select * into s from app.settlements where id = p_settlement;
|
|
if s.id is null then raise exception 'settlement not found'; end if;
|
|
if not app.has_any_role_in_shop(s.shop_id, array['owner','manager']::app.business_role[]) then
|
|
raise exception 'owner or manager required';
|
|
end if;
|
|
if s.status = 'closed' then
|
|
raise exception 'settlement is closed';
|
|
end if;
|
|
|
|
insert into app.settlement_lines(settlement_id, external_ref, occurred_at,
|
|
amount, currency, fee, commission, raw)
|
|
values (p_settlement, p_external_ref, p_occurred_at,
|
|
p_amount, p_currency, p_fee, p_commission, p_raw)
|
|
on conflict (settlement_id, external_ref) do nothing
|
|
returning id into lid;
|
|
|
|
return lid;
|
|
end;
|
|
$$;
|
|
revoke all on function app.add_settlement_line(uuid, text, timestamptz, numeric, app.currency_code, numeric, numeric, jsonb) from public;
|
|
grant execute on function app.add_settlement_line(uuid, text, timestamptz, numeric, app.currency_code, numeric, numeric, jsonb) to authenticated;
|
|
|
|
-- Run the matcher across all unmatched lines of a settlement.
|
|
create or replace function app.run_match(p_settlement uuid)
|
|
returns table (matched int, exceptions int)
|
|
language plpgsql
|
|
security definer
|
|
set search_path = app, public
|
|
as $$
|
|
declare
|
|
s app.settlements%rowtype;
|
|
svc_codes text[];
|
|
ln app.settlement_lines%rowtype;
|
|
cand uuid;
|
|
cand_count int;
|
|
cand_amount_usd numeric;
|
|
cand_amount_lbp numeric;
|
|
cand_amount numeric;
|
|
ok_amount boolean;
|
|
m_count int := 0;
|
|
e_count int := 0;
|
|
begin
|
|
select * into s from app.settlements where id = p_settlement;
|
|
if s.id is null then raise exception 'settlement not found'; end if;
|
|
if not app.has_any_role_in_shop(s.shop_id, array['owner','manager']::app.business_role[]) then
|
|
raise exception 'owner or manager required';
|
|
end if;
|
|
if s.status = 'closed' then raise exception 'settlement is closed'; end if;
|
|
|
|
svc_codes := app._provider_service_codes(s.provider);
|
|
|
|
perform set_config('app.settle_internal', 'on', true);
|
|
|
|
update app.settlements set status = 'matching' where id = p_settlement;
|
|
|
|
for ln in
|
|
select * from app.settlement_lines
|
|
where settlement_id = p_settlement and status = 'unmatched'
|
|
loop
|
|
-- Find candidate(s) by external_ref + provider service codes (when
|
|
-- known) within the same shop, in completed state.
|
|
select count(*),
|
|
coalesce(min(t.id), null)
|
|
into cand_count, cand
|
|
from app.transactions t
|
|
where t.shop_id = s.shop_id
|
|
and t.status = 'completed'
|
|
and t.external_ref = ln.external_ref
|
|
and (svc_codes is null or t.service_code = any(svc_codes));
|
|
|
|
if cand_count = 0 then
|
|
update app.settlement_lines
|
|
set status = 'missing_local'
|
|
where id = ln.id;
|
|
insert into app.reconciliation_exceptions(settlement_id, line_id, type, detail)
|
|
values (p_settlement, ln.id, 'missing_local',
|
|
format('provider lists external_ref % but no local txn found', ln.external_ref));
|
|
e_count := e_count + 1;
|
|
|
|
elsif cand_count > 1 then
|
|
update app.settlement_lines
|
|
set status = 'duplicate'
|
|
where id = ln.id;
|
|
insert into app.reconciliation_exceptions(settlement_id, line_id, type, detail)
|
|
values (p_settlement, ln.id, 'duplicate',
|
|
format('% local txns share external_ref %', cand_count, ln.external_ref));
|
|
e_count := e_count + 1;
|
|
|
|
else
|
|
-- Compare amounts within the matching currency. Allow 0.01 USD /
|
|
-- 100 LBP rounding tolerance.
|
|
select t.gross_usd, t.gross_lbp into cand_amount_usd, cand_amount_lbp
|
|
from app.transactions t where t.id = cand;
|
|
|
|
cand_amount := case ln.currency
|
|
when 'USD' then cand_amount_usd
|
|
when 'LBP' then cand_amount_lbp
|
|
end;
|
|
ok_amount := abs(coalesce(cand_amount,0) - coalesce(ln.amount,0))
|
|
<= case ln.currency when 'USD' then 0.01 else 100 end;
|
|
|
|
if ok_amount then
|
|
update app.settlement_lines
|
|
set status = 'matched',
|
|
matched_txn_id = cand,
|
|
matched_at = now()
|
|
where id = ln.id;
|
|
m_count := m_count + 1;
|
|
else
|
|
update app.settlement_lines
|
|
set status = 'amount_mismatch',
|
|
matched_txn_id = cand,
|
|
matched_at = now()
|
|
where id = ln.id;
|
|
insert into app.reconciliation_exceptions(settlement_id, line_id, txn_id, type, detail)
|
|
values (p_settlement, ln.id, cand, 'amount_mismatch',
|
|
format('local % %s vs provider % %s for ref %',
|
|
cand_amount, ln.currency, ln.amount, ln.currency, ln.external_ref));
|
|
e_count := e_count + 1;
|
|
end if;
|
|
end if;
|
|
end loop;
|
|
|
|
-- Now check for `extra_local`: completed local transactions in the
|
|
-- period whose external_ref is not present on the provider statement.
|
|
if svc_codes is not null then
|
|
insert into app.reconciliation_exceptions(settlement_id, txn_id, type, detail)
|
|
select p_settlement, t.id, 'extra_local',
|
|
format('local txn % has external_ref % but provider did not list it',
|
|
t.id, t.external_ref)
|
|
from app.transactions t
|
|
where t.shop_id = s.shop_id
|
|
and t.status = 'completed'
|
|
and t.service_code = any(svc_codes)
|
|
and t.external_ref is not null
|
|
and (t.occurred_at at time zone 'UTC')::date between s.period_start and s.period_end
|
|
and not exists (
|
|
select 1 from app.settlement_lines sl
|
|
where sl.settlement_id = p_settlement
|
|
and sl.external_ref = t.external_ref
|
|
)
|
|
and not exists (
|
|
select 1 from app.reconciliation_exceptions r
|
|
where r.settlement_id = p_settlement
|
|
and r.txn_id = t.id
|
|
and r.type = 'extra_local'
|
|
);
|
|
get diagnostics e_count = row_count; -- approximate increment
|
|
end if;
|
|
|
|
-- Final status
|
|
if exists (
|
|
select 1 from app.reconciliation_exceptions
|
|
where settlement_id = p_settlement and resolved_at is null
|
|
) then
|
|
update app.settlements set status = 'has_exceptions' where id = p_settlement;
|
|
else
|
|
update app.settlements set status = 'matched' where id = p_settlement;
|
|
end if;
|
|
|
|
perform set_config('app.settle_internal', 'off', true);
|
|
|
|
perform app.log_auth_event('settlement_matched', s.shop_id, null,
|
|
jsonb_build_object('settlement_id', p_settlement,
|
|
'matched', m_count, 'exceptions', e_count));
|
|
|
|
matched := m_count; exceptions := e_count; return next;
|
|
end;
|
|
$$;
|
|
revoke all on function app.run_match(uuid) from public;
|
|
grant execute on function app.run_match(uuid) to authenticated;
|
|
|
|
-- Resolve a single exception. Owner-only with mandatory note.
|
|
create or replace function app.resolve_exception(
|
|
p_exception uuid,
|
|
p_note text
|
|
) returns void
|
|
language plpgsql
|
|
security definer
|
|
set search_path = app, public
|
|
as $$
|
|
declare
|
|
e app.reconciliation_exceptions%rowtype;
|
|
s app.settlements%rowtype;
|
|
begin
|
|
if p_note is null or length(btrim(p_note)) < 5 then
|
|
raise exception 'resolution note >= 5 chars required';
|
|
end if;
|
|
select * into e from app.reconciliation_exceptions where id = p_exception;
|
|
if e.id is null then raise exception 'exception not found'; end if;
|
|
select * into s from app.settlements where id = e.settlement_id;
|
|
if not app.has_role_in_shop(s.shop_id, 'owner') then
|
|
raise exception 'owner role required';
|
|
end if;
|
|
if e.resolved_at is not null then
|
|
raise exception 'exception already resolved';
|
|
end if;
|
|
|
|
perform set_config('app.settle_internal', 'on', true);
|
|
update app.reconciliation_exceptions
|
|
set resolved_at = now(),
|
|
resolved_by = auth.uid(),
|
|
resolution_note = p_note
|
|
where id = p_exception;
|
|
|
|
-- If no open exceptions remain on this settlement, flip back to matched.
|
|
if not exists (
|
|
select 1 from app.reconciliation_exceptions
|
|
where settlement_id = s.id and resolved_at is null
|
|
) then
|
|
update app.settlements set status = 'matched' where id = s.id;
|
|
end if;
|
|
perform set_config('app.settle_internal', 'off', true);
|
|
|
|
perform app.log_auth_event('settlement_exception_resolved', s.shop_id, null,
|
|
jsonb_build_object('exception_id', p_exception));
|
|
end;
|
|
$$;
|
|
revoke all on function app.resolve_exception(uuid, text) from public;
|
|
grant execute on function app.resolve_exception(uuid, text) to authenticated;
|
|
|
|
-- Close the settlement once all exceptions are resolved.
|
|
create or replace function app.close_settlement(p_settlement uuid)
|
|
returns void
|
|
language plpgsql
|
|
security definer
|
|
set search_path = app, public
|
|
as $$
|
|
declare s app.settlements%rowtype;
|
|
begin
|
|
select * into s from app.settlements where id = p_settlement;
|
|
if s.id is null then raise exception 'settlement not found'; end if;
|
|
if not app.has_role_in_shop(s.shop_id, 'owner') then
|
|
raise exception 'owner role required';
|
|
end if;
|
|
if s.status not in ('matched') then
|
|
raise exception 'settlement must be in MATCHED state to close (was %)', s.status;
|
|
end if;
|
|
if exists (
|
|
select 1 from app.reconciliation_exceptions
|
|
where settlement_id = p_settlement and resolved_at is null
|
|
) then
|
|
raise exception 'cannot close: open exceptions remain';
|
|
end if;
|
|
|
|
perform set_config('app.settle_internal', 'on', true);
|
|
update app.settlements
|
|
set status = 'closed', closed_at = now(), closed_by = auth.uid()
|
|
where id = p_settlement;
|
|
perform set_config('app.settle_internal', 'off', true);
|
|
|
|
perform app.log_auth_event('settlement_closed', s.shop_id, null,
|
|
jsonb_build_object('settlement_id', p_settlement));
|
|
end;
|
|
$$;
|
|
revoke all on function app.close_settlement(uuid) from public;
|
|
grant execute on function app.close_settlement(uuid) to authenticated;
|
|
|
|
-- =====================================================================
|
|
-- Reporting views
|
|
-- =====================================================================
|
|
create or replace view app.v_unmatched_external as
|
|
select s.shop_id, s.provider, s.period_start, s.period_end,
|
|
sl.external_ref, sl.amount, sl.currency, sl.status
|
|
from app.settlement_lines sl
|
|
join app.settlements s on s.id = sl.settlement_id
|
|
where sl.status <> 'matched';
|
|
|
|
create or replace view app.v_open_exceptions as
|
|
select s.shop_id, s.provider, s.period_start, s.period_end,
|
|
e.id as exception_id, e.type, e.detail, e.created_at
|
|
from app.reconciliation_exceptions e
|
|
join app.settlements s on s.id = e.settlement_id
|
|
where e.resolved_at is null;
|
|
|
|
-- =====================================================================
|
|
-- RLS
|
|
-- =====================================================================
|
|
alter table app.settlements enable row level security;
|
|
alter table app.settlement_lines enable row level security;
|
|
alter table app.reconciliation_exceptions enable row level security;
|
|
alter table app.settlements force row level security;
|
|
alter table app.settlement_lines force row level security;
|
|
alter table app.reconciliation_exceptions force row level security;
|
|
|
|
revoke insert, update, delete on app.settlements from authenticated;
|
|
revoke insert, update, delete on app.settlement_lines from authenticated;
|
|
revoke insert, update, delete on app.reconciliation_exceptions from authenticated;
|
|
|
|
drop policy if exists settle_select on app.settlements;
|
|
create policy settle_select on app.settlements
|
|
for select to authenticated
|
|
using (
|
|
app.has_any_role_in_shop(shop_id,
|
|
array['owner','manager','auditor']::app.business_role[])
|
|
);
|
|
grant select on app.settlements to authenticated;
|
|
|
|
drop policy if exists settle_lines_select on app.settlement_lines;
|
|
create policy settle_lines_select on app.settlement_lines
|
|
for select to authenticated
|
|
using (
|
|
exists (
|
|
select 1 from app.settlements s
|
|
where s.id = settlement_lines.settlement_id
|
|
and app.has_any_role_in_shop(s.shop_id,
|
|
array['owner','manager','auditor']::app.business_role[])
|
|
)
|
|
);
|
|
grant select on app.settlement_lines to authenticated;
|
|
|
|
drop policy if exists recon_exc_select on app.reconciliation_exceptions;
|
|
create policy recon_exc_select on app.reconciliation_exceptions
|
|
for select to authenticated
|
|
using (
|
|
exists (
|
|
select 1 from app.settlements s
|
|
where s.id = reconciliation_exceptions.settlement_id
|
|
and app.has_any_role_in_shop(s.shop_id,
|
|
array['owner','manager','auditor']::app.business_role[])
|
|
)
|
|
);
|
|
grant select on app.reconciliation_exceptions to authenticated;
|
|
|
|
-- A small helper to create a settlement (owner/manager only).
|
|
create or replace function app.create_settlement(
|
|
p_shop uuid,
|
|
p_provider app.settlement_provider,
|
|
p_period_start date,
|
|
p_period_end date,
|
|
p_file_url text,
|
|
p_file_sha256 text,
|
|
p_total_amount numeric,
|
|
p_total_currency app.currency_code
|
|
) returns uuid
|
|
language plpgsql
|
|
security definer
|
|
set search_path = app, public
|
|
as $$
|
|
declare sid uuid;
|
|
begin
|
|
if not app.has_any_role_in_shop(p_shop, array['owner','manager']::app.business_role[]) then
|
|
raise exception 'owner or manager required';
|
|
end if;
|
|
if p_period_end < p_period_start then
|
|
raise exception 'period_end before period_start';
|
|
end if;
|
|
insert into app.settlements(shop_id, provider, period_start, period_end,
|
|
file_url, file_sha256, total_amount, total_currency)
|
|
values (p_shop, p_provider, p_period_start, p_period_end,
|
|
p_file_url, p_file_sha256, p_total_amount, p_total_currency)
|
|
returning id into sid;
|
|
perform app.log_auth_event('settlement_imported', p_shop, null,
|
|
jsonb_build_object('settlement_id', sid, 'provider', p_provider));
|
|
return sid;
|
|
end;
|
|
$$;
|
|
revoke all on function app.create_settlement(uuid, app.settlement_provider, date, date, text, text, numeric, app.currency_code) from public;
|
|
grant execute on function app.create_settlement(uuid, app.settlement_provider, date, date, text, text, numeric, app.currency_code) to authenticated;
|
|
|
|
-- End migration 0009 ----------------------------------------------------
|