-- =====================================================================
-- Migration 0007 — Receipts, signatures, evidence (roadmap Step 8).
--
-- Goals:
--   * Every receipt carries a server-signed token (HMAC-SHA256) so a
--     scanner / owner spot-check can verify it really came from this
--     system and was not printed by a side-printer or hand-edited
--     (vector #10).
--   * Customer notifications (SMS / email) are logged so the owner can
--     confirm that beneficiaries actually got their reference number,
--     exposing pocketed transactions (vector #1).
--   * Evidence (signature pad image, ID photo, voided-paper photo, OMT
--     POS slip scan) is attached append-only to a transaction.
--
-- Threat-model rows addressed: 1, 10, 11, 15.
-- =====================================================================

-- =====================================================================
-- HMAC secret
-- The signing key lives in app.system_secrets and is never returned to
-- clients (the `select` policy denies all non-DBA access). Functions
-- below are SECURITY DEFINER so they can read it.
-- =====================================================================
create table if not exists app.system_secrets (
  key        text primary key,
  value      text not null,
  rotated_at timestamptz not null default now()
);

-- Generate an initial random key on first install. Owners should rotate
-- it via app.rotate_receipt_key() (added below) on a schedule.
insert into app.system_secrets(key, value)
  values ('receipt_hmac_key', encode(gen_random_bytes(32), 'hex'))
  on conflict (key) do nothing;

alter table app.system_secrets enable row level security;
alter table app.system_secrets force row level security;
revoke all on app.system_secrets from authenticated;

-- =====================================================================
-- Receipt token: HMAC over (txn_id || reference_no || shop_id || row_hash)
-- Embedded in the printed QR. Anyone holding a receipt + the public
-- verifier function can prove (or disprove) authenticity.
-- =====================================================================
create or replace function app._receipt_hmac_key()
returns bytea
language sql
security definer
set search_path = app, public
stable
as $$
  select decode(value, 'hex') from app.system_secrets where key = 'receipt_hmac_key';
$$;
revoke all on function app._receipt_hmac_key() from public;
-- Not granted to anyone; only callable from inside other SECURITY DEFINER
-- functions in this schema.

create or replace function app.receipt_token(p_txn uuid)
returns text
language plpgsql
security definer
set search_path = app, public
stable
as $$
declare
  t   app.transactions%rowtype;
  msg bytea;
  sig bytea;
begin
  select * into t from app.transactions where id = p_txn;
  if t.id is null then raise exception 'txn not found'; end if;
  -- Visibility check: caller must be allowed to see the txn.
  if not app._can_see_txn(p_txn) then
    raise exception 'not authorized';
  end if;
  msg := convert_to(
    t.id::text || '|' || t.shop_id::text || '|' || t.reference_no::text
                || '|' || encode(t.row_hash, 'hex'),
    'UTF8');
  sig := hmac(msg, app._receipt_hmac_key(), 'sha256');
  -- Token format: v1.<txn_id>.<reference_no>.<sig_b64>
  return 'v1.' || t.id::text || '.' || t.reference_no::text || '.' ||
         translate(encode(sig, 'base64'), E'+/=\n', '-_');
end;
$$;
revoke all on function app.receipt_token(uuid) from public;
grant execute on function app.receipt_token(uuid) to authenticated;

-- Public verifier: takes a token, returns the txn row + ok flag.
-- Anyone authenticated may call (so an owner can scan any receipt) but
-- the row is only returned if the signature checks out AND the caller
-- is allowed to see the txn under RLS.
create or replace function app.verify_receipt(p_token text)
returns table (
  ok            boolean,
  txn_id        uuid,
  shop_id       uuid,
  reference_no  bigint,
  service_code  text,
  occurred_at   timestamptz,
  status        app.txn_status
)
language plpgsql
security definer
set search_path = app, public
stable
as $$
declare
  parts        text[];
  v_txn        uuid;
  v_ref        bigint;
  v_sig_b64    text;
  expected     text;
  t            app.transactions%rowtype;
begin
  parts := string_to_array(p_token, '.');
  if array_length(parts, 1) <> 4 or parts[1] <> 'v1' then
    ok := false; return next; return;
  end if;
  v_txn := parts[2]::uuid;
  v_ref := parts[3]::bigint;
  v_sig_b64 := parts[4];

  select * into t from app.transactions where id = v_txn and reference_no = v_ref;
  if t.id is null then
    ok := false; return next; return;
  end if;

  expected := translate(
    encode(
      hmac(
        convert_to(t.id::text || '|' || t.shop_id::text || '|' ||
                   t.reference_no::text || '|' || encode(t.row_hash, 'hex'), 'UTF8'),
        app._receipt_hmac_key(), 'sha256'),
      'base64'),
    E'+/=\n', '-_');

  if expected <> v_sig_b64 then
    ok := false; return next; return;
  end if;

  if not app._can_see_txn(t.id) then
    ok := false; return next; return;
  end if;

  ok           := true;
  txn_id       := t.id;
  shop_id      := t.shop_id;
  reference_no := t.reference_no;
  service_code := t.service_code;
  occurred_at  := t.occurred_at;
  status       := t.status;
  return next;
end;
$$;
revoke all on function app.verify_receipt(text) from public;
grant execute on function app.verify_receipt(text) to authenticated;

-- Key rotation (owner-only).
create or replace function app.rotate_receipt_key()
returns void
language plpgsql
security definer
set search_path = app, public
as $$
begin
  if not app.is_owner_anywhere() then
    raise exception 'only an owner may rotate the receipt key';
  end if;
  update app.system_secrets
    set value = encode(gen_random_bytes(32), 'hex'),
        rotated_at = now()
  where key = 'receipt_hmac_key';
  perform app.log_auth_event('receipt_key_rotated', null, null, '{}'::jsonb);
end;
$$;
revoke all on function app.rotate_receipt_key() from public;
grant execute on function app.rotate_receipt_key() to authenticated;

-- =====================================================================
-- Receipts table: one row per print of a receipt (originals + reprints).
-- Append-only.
-- =====================================================================
do $$ begin
  create type app.receipt_kind as enum ('original', 'reprint', 'duplicate');
exception when duplicate_object then null; end $$;

create table if not exists app.receipts (
  id            uuid primary key default gen_random_uuid(),
  txn_id        uuid not null references app.transactions(id) on delete restrict,
  kind          app.receipt_kind not null default 'original',
  pdf_url       text,                          -- server-rendered PDF
  qr_token      text not null,                 -- embedded HMAC token
  printed_at    timestamptz not null default now(),
  printed_by    uuid not null references auth.users(id) default auth.uid(),
  device_fingerprint text
);
create index if not exists idx_receipts_txn on app.receipts(txn_id);

create or replace function app._receipts_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'receipts is append-only'; end;
$$;
drop trigger if exists trg_receipts_freeze on app.receipts;
create trigger trg_receipts_freeze before update or delete on app.receipts
  for each row execute function app._receipts_no_update_delete();

-- =====================================================================
-- Customer notifications (SMS/email). Logged so an owner can confirm
-- the customer actually heard about the transaction.
-- =====================================================================
do $$ begin
  create type app.notification_channel as enum ('sms','email','push');
exception when duplicate_object then null; end $$;

do $$ begin
  create type app.notification_status as enum
    ('queued','sent','delivered','failed');
exception when duplicate_object then null; end $$;

create table if not exists app.customer_notifications (
  id            uuid primary key default gen_random_uuid(),
  txn_id        uuid not null references app.transactions(id) on delete restrict,
  channel       app.notification_channel not null,
  recipient     text not null,                 -- phone or email
  body_template text not null,                 -- 'omt_send_v1', 'recharge_v1', ...
  status        app.notification_status not null default 'queued',
  provider_ref  text,                          -- gateway message id
  queued_at     timestamptz not null default now(),
  sent_at       timestamptz,
  delivered_at  timestamptz,
  failed_reason text,
  created_by    uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_notif_txn        on app.customer_notifications(txn_id);
create index if not exists idx_notif_recipient  on app.customer_notifications(recipient, queued_at desc);
create index if not exists idx_notif_status     on app.customer_notifications(status);

-- Append-only except for status transitions, which only the gateway
-- (running as a dedicated DB role outside `authenticated`) may apply.
create or replace function app._notif_guard()
returns trigger language plpgsql as $$
begin
  if tg_op = 'DELETE' then
    raise exception 'notifications cannot be deleted';
  end if;
  -- Allow status / timestamps / provider_ref / failed_reason updates.
  if (new.id            <> old.id
   or new.txn_id        <> old.txn_id
   or new.channel       <> old.channel
   or new.recipient     <> old.recipient
   or new.body_template <> old.body_template
   or new.queued_at     <> old.queued_at
   or new.created_by    <> old.created_by) then
    raise exception 'only delivery fields may change on a notification row';
  end if;
  return new;
end;
$$;
drop trigger if exists trg_notif_guard on app.customer_notifications;
create trigger trg_notif_guard before update or delete on app.customer_notifications
  for each row execute function app._notif_guard();

-- =====================================================================
-- Evidence attachments (signatures, photos, ID scans).
-- Append-only. Visible to anyone who can see the parent txn.
-- =====================================================================
do $$ begin
  create type app.evidence_kind as enum (
    'customer_signature',
    'id_photo',
    'voided_paper_photo',
    'omt_pos_slip',
    'cancellation_photo',
    'other'
  );
exception when duplicate_object then null; end $$;

create table if not exists app.transaction_evidence (
  id           uuid primary key default gen_random_uuid(),
  txn_id       uuid not null references app.transactions(id) on delete restrict,
  kind         app.evidence_kind not null,
  file_url     text not null,
  file_sha256  text,                           -- hex digest of stored bytes
  note         text,
  uploaded_at  timestamptz not null default now(),
  uploaded_by  uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_evidence_txn on app.transaction_evidence(txn_id, uploaded_at);

create or replace function app._evidence_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'transaction_evidence is append-only'; end;
$$;
drop trigger if exists trg_evidence_freeze on app.transaction_evidence;
create trigger trg_evidence_freeze before update or delete on app.transaction_evidence
  for each row execute function app._evidence_no_update_delete();

-- =====================================================================
-- High-value evidence policy (vector #15 — fake cancellations,
-- vector #11 — manager-approved void of a printed receipt):
-- a deferred constraint trigger enforces, at COMMIT, that:
--   * any voided txn whose original status was 'completed' has at
--     least one evidence row of kind 'voided_paper_photo'.
--   * any large OMT_SEND / OMT_RECEIVE has a 'customer_signature' or
--     'id_photo' evidence row.
-- =====================================================================
create or replace function app._txn_require_evidence()
returns trigger language plpgsql as $$
declare
  th_warn_usd numeric;
  th_warn_lbp numeric;
  has_sig     boolean;
  has_void    boolean;
begin
  -- Only check on UPDATE-to-voided or on relevant high-value services.
  if tg_op = 'UPDATE' and new.status = 'voided' and old.status = 'completed' then
    select exists(
      select 1 from app.transaction_evidence
      where txn_id = new.id and kind = 'voided_paper_photo'
    ) into has_void;
    if not has_void then
      raise exception 'void of txn % requires a voided_paper_photo evidence row', new.id;
    end if;
  end if;

  if (tg_op = 'INSERT')
     and new.service_code in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE') then
    select daily_amount_warn into th_warn_usd from app.kyc_thresholds
      where service_code = new.service_code and currency = 'USD';
    select daily_amount_warn into th_warn_lbp from app.kyc_thresholds
      where service_code = new.service_code and currency = 'LBP';
    if (new.gross_usd >= coalesce(th_warn_usd, 1e18))
       or (new.gross_lbp >= coalesce(th_warn_lbp, 1e18)) then
      select exists(
        select 1 from app.transaction_evidence
        where txn_id = new.id
          and kind in ('customer_signature','id_photo','omt_pos_slip')
      ) into has_sig;
      if not has_sig then
        raise exception
          'high-value % txn % requires customer_signature or id_photo evidence',
          new.service_code, new.id;
      end if;
    end if;
  end if;
  return null;
end;
$$;

drop trigger if exists trg_txn_require_evidence on app.transactions;
create constraint trigger trg_txn_require_evidence
  after insert or update on app.transactions
  deferrable initially deferred
  for each row execute function app._txn_require_evidence();

-- =====================================================================
-- Convenience: a SECURITY DEFINER `record_receipt_print` so the
-- printing service inside the app issues a fresh QR token and logs the
-- print in one go.
-- =====================================================================
create or replace function app.record_receipt_print(
  p_txn_id uuid,
  p_kind   app.receipt_kind default 'original',
  p_device text default null
)
returns table (receipt_id uuid, qr_token text, pdf_url text)
language plpgsql
security definer
set search_path = app, public
as $$
declare
  tok text;
  rid uuid;
begin
  if not app._can_see_txn(p_txn_id) then
    raise exception 'not authorized';
  end if;
  tok := app.receipt_token(p_txn_id);
  insert into app.receipts(txn_id, kind, qr_token, device_fingerprint)
    values (p_txn_id, p_kind, tok, p_device)
    returning id into rid;
  receipt_id := rid;
  qr_token   := tok;
  pdf_url    := null;     -- the PDF rendering service will patch this
                          -- via record_receipt_pdf below.
  return next;
end;
$$;
revoke all on function app.record_receipt_print(uuid, app.receipt_kind, text) from public;
grant execute on function app.record_receipt_print(uuid, app.receipt_kind, text) to authenticated;

-- The PDF renderer fills in pdf_url after upload to storage. The
-- `receipts` table is append-only via trigger, so we expose a tiny
-- definer function that allows just this one column update.
create or replace function app.record_receipt_pdf(
  p_receipt_id uuid,
  p_pdf_url    text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
begin
  -- Allow direct UPDATE only via this function.
  perform set_config('app.receipts_internal', 'on', true);
  update app.receipts set pdf_url = p_pdf_url where id = p_receipt_id and pdf_url is null;
  perform set_config('app.receipts_internal', 'off', true);
end;
$$;
revoke all on function app.record_receipt_pdf(uuid, text) from public;
grant execute on function app.record_receipt_pdf(uuid, text) to authenticated;

-- Adjust the receipts-freeze trigger to allow the definer path through.
create or replace function app._receipts_no_update_delete()
returns trigger language plpgsql as $$
begin
  if tg_op = 'DELETE' then
    raise exception 'receipts cannot be deleted';
  end if;
  if current_setting('app.receipts_internal', true) is distinct from 'on' then
    raise exception 'direct UPDATE on receipts is not allowed';
  end if;
  if (new.id <> old.id or new.txn_id <> old.txn_id or new.kind <> old.kind
      or new.qr_token <> old.qr_token or new.printed_at <> old.printed_at
      or new.printed_by <> old.printed_by) then
    raise exception 'only pdf_url may change on a receipt row';
  end if;
  return new;
end;
$$;

-- =====================================================================
-- Customer-facing notification queue helper. The actual SMS gateway
-- (a worker process running as a dedicated role) will pick up rows
-- where status='queued' and update status to sent/delivered/failed.
-- =====================================================================
create or replace function app.queue_customer_notification(
  p_txn       uuid,
  p_channel   app.notification_channel,
  p_recipient text,
  p_template  text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare nid uuid;
begin
  if not app._can_see_txn(p_txn) then
    raise exception 'not authorized';
  end if;
  insert into app.customer_notifications(txn_id, channel, recipient, body_template)
    values (p_txn, p_channel, p_recipient, p_template)
    returning id into nid;
  return nid;
end;
$$;
revoke all on function app.queue_customer_notification(uuid, app.notification_channel, text, text) from public;
grant execute on function app.queue_customer_notification(uuid, app.notification_channel, text, text) to authenticated;

-- =====================================================================
-- RLS
-- =====================================================================
alter table app.receipts                enable row level security;
alter table app.customer_notifications  enable row level security;
alter table app.transaction_evidence    enable row level security;
alter table app.receipts                force row level security;
alter table app.customer_notifications  force row level security;
alter table app.transaction_evidence    force row level security;

revoke update, delete on app.receipts                from authenticated;
revoke delete         on app.customer_notifications  from authenticated;
revoke update, delete on app.transaction_evidence    from authenticated;

drop policy if exists receipts_select on app.receipts;
create policy receipts_select on app.receipts
  for select to authenticated using (app._can_see_txn(txn_id));

drop policy if exists receipts_insert on app.receipts;
create policy receipts_insert on app.receipts
  for insert to authenticated
  with check (app._can_see_txn(txn_id));
grant select, insert on app.receipts to authenticated;

drop policy if exists notif_select on app.customer_notifications;
create policy notif_select on app.customer_notifications
  for select to authenticated using (app._can_see_txn(txn_id));
drop policy if exists notif_insert on app.customer_notifications;
create policy notif_insert on app.customer_notifications
  for insert to authenticated with check (app._can_see_txn(txn_id));
grant select, insert on app.customer_notifications to authenticated;
-- The gateway worker role gets UPDATE separately; not here.

drop policy if exists evidence_select on app.transaction_evidence;
create policy evidence_select on app.transaction_evidence
  for select to authenticated using (app._can_see_txn(txn_id));

-- Evidence insert allowed for: shift owner during open shift OR any
-- manager/owner of the shop (so a manager can attach voided-paper
-- photos when approving a void after the cashier has closed shift).
drop policy if exists evidence_insert on app.transaction_evidence;
create policy evidence_insert on app.transaction_evidence
  for insert to authenticated
  with check (
    exists (
      select 1 from app.transactions t
      join   app.shifts s on s.id = t.shift_id
      where  t.id = transaction_evidence.txn_id
      and (
        (t.user_id = auth.uid() and s.status = 'open')
        or app.has_any_role_in_shop(t.shop_id,
              array['manager','owner']::app.business_role[])
      )
    )
  );
grant select, insert on app.transaction_evidence to authenticated;

-- End migration 0007 ----------------------------------------------------
