-- =====================================================================
-- 0022_atomic_sale_coupling.sql
--
-- Today the cashier UI calls record_recharge() and record_goods_sale()
-- but never separately calls sell_voucher() or posts the e-float debit
-- / sale_out stock movement. The deferred constraint triggers from
-- 0005 (_recharge_require_movement, _goods_sale_require_movement)
-- therefore reject every commit at end-of-transaction… *unless* the
-- trigger never fires because the RLS-protected detail row blocked
-- the INSERT, in which case the txn header silently survives without
-- any inventory or float impact.
--
-- Either way the books are wrong: a "sold" voucher serial keeps
-- showing as in_stock, the e-float balance does not drop, and a phone
-- sold off the shelf does not decrement stock_on_hand.
--
-- This migration folds the inventory/float legs INTO the record_*
-- functions themselves, in the same SECURITY DEFINER transaction:
--
--   record_recharge -> if voucher_serial: mark voucher sold + post
--                       sale_out (-1) for the voucher SKU.
--                     else (e-recharge): post a negative float_movement
--                       for ALFA_ERECHARGE / TOUCH_ERECHARGE / OGERO_ERECHARGE
--                       sized at unit_cost_usd (or gross_usd as fallback).
--
--   record_goods_sale -> post a sale_out stock_movement for the SKU
--                        with -p_qty.
--
-- Both are wrapped in a single transaction so either everything posts
-- or the whole sale rolls back. The deferred coupling triggers from
-- 0005 then pass naturally.
-- =====================================================================

set search_path = app, public;

-- ---------------------------------------------------------------------
-- Internal helper: mark a voucher sold + post sale_out, callable from
-- inside record_recharge. Mirrors app.sell_voucher() but does not check
-- auth.uid() against the txn owner because record_recharge is itself
-- security definer running as the cashier who created the txn.
-- ---------------------------------------------------------------------
create or replace function app._sell_voucher_internal(
  p_txn_id uuid,
  p_serial text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
  v app.voucher_inventory%rowtype;
  t app.transactions%rowtype;
begin
  select * into t from app.transactions where id = p_txn_id;
  if t.id is null then raise exception 'txn not found'; end if;

  select * into v from app.voucher_inventory
    where serial = p_serial for update;
  if v.serial is null then
    raise exception 'voucher % not found', p_serial;
  end if;
  if v.shop_id <> t.shop_id then
    raise exception 'voucher % belongs to a different shop', p_serial;
  end if;
  if v.status <> 'in_stock' then
    raise exception 'voucher % is not in_stock (status=%)', p_serial, v.status;
  end if;

  update app.voucher_inventory
     set status = 'sold',
         sold_txn_id = p_txn_id,
         sold_at = now(),
         status_changed_by = auth.uid()
   where serial = p_serial;

  insert into app.stock_movements(
    sku, shop_id, shift_id, type, qty_delta, ref_txn_id, reason
  ) values (
    v.sku, v.shop_id, t.shift_id,
    'sale_out'::app.stock_movement_type,
    -1, p_txn_id, 'voucher ' || p_serial
  );
end;
$$;
revoke all on function app._sell_voucher_internal(uuid, text) from public;

-- ---------------------------------------------------------------------
-- Internal helper: post the e-float debit for an e-recharge.
-- ---------------------------------------------------------------------
create or replace function app._erecharge_post_float(
  p_txn_id   uuid,
  p_operator text,
  p_amount   numeric           -- positive cost; the row will be negated
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
  v_provider app.float_provider;
  v_float    uuid;
  t          app.transactions%rowtype;
begin
  if p_amount is null or p_amount <= 0 then
    raise exception 'e-recharge cost must be > 0 (got %)', p_amount;
  end if;

  select * into t from app.transactions where id = p_txn_id;
  if t.id is null then raise exception 'txn not found'; end if;

  v_provider := case upper(p_operator)
    when 'ALFA'  then 'ALFA_ERECHARGE'::app.float_provider
    when 'TOUCH' then 'TOUCH_ERECHARGE'::app.float_provider
    when 'OGERO' then 'OGERO_ERECHARGE'::app.float_provider
    else null
  end;

  if v_provider is null then
    -- Unmapped operator (IDM, CYBERIA, TERRANET): fall back to OMT_DIGITAL
    -- so the recharge_require_movement trigger sees a negative leg.
    v_provider := 'OMT_DIGITAL'::app.float_provider;
  end if;

  v_float := app._get_or_create_float(t.shop_id, v_provider, 'USD'::app.currency_code);

  insert into app.float_movements(
    float_id, shift_id, amount, ref_txn_id, reason
  ) values (
    v_float, t.shift_id, -p_amount, p_txn_id,
    'e-recharge ' || coalesce(p_operator, '?')
  );
end;
$$;
revoke all on function app._erecharge_post_float(uuid, text, numeric) from public;

-- ---------------------------------------------------------------------
-- Re-define record_recharge to fold in voucher / e-float posting, and
-- the cash leg via the helper added in 0018.
-- ---------------------------------------------------------------------
create or replace function app.record_recharge(
  p_shop uuid, p_till uuid, p_service_code text,
  p_payment_method app.payment_method,
  p_gross_usd numeric, p_gross_lbp numeric,
  p_fee_usd numeric,   p_fee_lbp numeric,
  p_fx_rate numeric,
  p_operator text, p_msisdn text, p_product_code text,
  p_voucher_serial text, p_e_recharge_ref text,
  p_unit_face_usd numeric, p_unit_cost_usd numeric,
  p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
  v_txn       uuid;
  v_serial    text := nullif(btrim(p_voucher_serial),'');
  v_eref      text := nullif(btrim(p_e_recharge_ref),'');
  v_cost_usd  numeric;
begin
  if v_serial is null and v_eref is null then
    raise exception 'either voucher_serial or e_recharge_ref is required';
  end if;
  if v_serial is not null and v_eref is not null then
    raise exception 'pass either voucher_serial OR e_recharge_ref, not both';
  end if;

  v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
    p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp, 0, 0, p_fx_rate,
    p_operator, v_serial, null, p_msisdn, null, p_notes);

  insert into app.recharge_details(
    txn_id, operator, msisdn, product_code,
    voucher_serial, e_recharge_provider_ref,
    unit_face_value_usd, unit_cost_usd
  ) values (
    v_txn, p_operator, p_msisdn, p_product_code,
    v_serial, v_eref,
    p_unit_face_usd, p_unit_cost_usd
  );

  -- ---- inventory / float coupling --------------------------------
  if v_serial is not null then
    perform app._sell_voucher_internal(v_txn, v_serial);
  else
    -- e-recharge: prefer recorded unit_cost_usd, fall back to gross_usd.
    v_cost_usd := coalesce(nullif(p_unit_cost_usd,0), p_gross_usd);
    perform app._erecharge_post_float(v_txn, p_operator, v_cost_usd);
  end if;

  -- ---- cash leg (re-uses helper from 0018) -----------------------
  perform app._post_cash_for_txn(
    v_txn, p_payment_method,
    coalesce(p_gross_usd,0) + coalesce(p_fee_usd,0),
    coalesce(p_gross_lbp,0) + coalesce(p_fee_lbp,0)
  );

  return v_txn;
end;
$$;
revoke all on function app.record_recharge(uuid, uuid, text, app.payment_method,
  numeric, numeric, numeric, numeric, numeric,
  text, text, text, text, text, numeric, numeric, text) from public;
grant execute on function app.record_recharge(uuid, uuid, text, app.payment_method,
  numeric, numeric, numeric, numeric, numeric,
  text, text, text, text, text, numeric, numeric, text) to authenticated;

-- ---------------------------------------------------------------------
-- Re-define record_goods_sale to fold in the sale_out stock movement
-- and the cash leg in the same transaction.
-- ---------------------------------------------------------------------
create or replace function app.record_goods_sale(
  p_shop uuid, p_till uuid,
  p_payment_method app.payment_method,
  p_gross_usd numeric, p_gross_lbp numeric,
  p_fx_rate numeric,
  p_sku text, p_qty integer,
  p_unit_cost_usd numeric, p_unit_price_usd numeric,
  p_serial_number text,
  p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
  v_txn uuid;
begin
  if p_qty is null or p_qty <= 0 then
    raise exception 'qty must be > 0';
  end if;
  if p_sku is null or btrim(p_sku) = '' then
    raise exception 'sku required';
  end if;

  v_txn := app._insert_txn(p_shop, p_till, 'GOODS_SALE', p_payment_method,
    p_gross_usd, p_gross_lbp, 0, 0, 0, 0, p_fx_rate,
    null, null, null, null, p_customer_id, p_notes);

  insert into app.goods_sale_details(
    txn_id, sku, qty, unit_cost_usd, unit_price_usd, serial_number
  ) values (
    v_txn, p_sku, p_qty, p_unit_cost_usd, p_unit_price_usd, p_serial_number
  );

  -- Stock leg. The 0005 _stock_mov_before_insert trigger validates the
  -- ref_txn_id points at a completed sale in the same shop, and the
  -- _stock_on_hand_apply trigger refuses to go negative.
  insert into app.stock_movements(
    sku, shop_id, shift_id, type, qty_delta, ref_txn_id, reason
  ) values (
    p_sku, p_shop, (select shift_id from app.transactions where id = v_txn),
    'sale_out'::app.stock_movement_type,
    -p_qty,
    v_txn,
    case when p_serial_number is not null
         then 'goods sale serial=' || p_serial_number
         else 'goods sale' end
  );

  -- Cash leg.
  perform app._post_cash_for_txn(
    v_txn, p_payment_method,
    coalesce(p_gross_usd,0),
    coalesce(p_gross_lbp,0)
  );

  return v_txn;
end;
$$;
revoke all on function app.record_goods_sale(uuid, uuid, app.payment_method,
  numeric, numeric, numeric, text, integer, numeric, numeric, text, uuid, text) from public;
grant execute on function app.record_goods_sale(uuid, uuid, app.payment_method,
  numeric, numeric, numeric, text, integer, numeric, numeric, text, uuid, text) to authenticated;

-- ---------------------------------------------------------------------
-- Repair sales also put cash in the till (parts + labour). The original
-- record_repair from 0013 inserts only the txn header + repair detail
-- and never posts cash, so REPAIR variances were silently absorbed by
-- the next cashier's drop. Wrap the existing function so it posts cash.
-- ---------------------------------------------------------------------
create or replace function app.record_repair(
  p_shop uuid, p_till uuid,
  p_payment_method app.payment_method,
  p_gross_usd numeric, p_gross_lbp numeric,
  p_fx_rate numeric,
  p_device_type text, p_device_imei text,
  p_issue_summary text, p_warranty_days integer,
  p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
  v_txn := app._insert_txn(p_shop, p_till, 'REPAIR', p_payment_method,
    p_gross_usd, p_gross_lbp, 0, 0, 0, 0, p_fx_rate,
    null, null, null, null, p_customer_id, p_notes);

  insert into app.repair_details(
    txn_id, device_type, device_imei, issue_summary, warranty_days
  ) values (
    v_txn, p_device_type, p_device_imei, p_issue_summary, p_warranty_days
  );

  perform app._post_cash_for_txn(
    v_txn, p_payment_method,
    coalesce(p_gross_usd,0),
    coalesce(p_gross_lbp,0)
  );
  return v_txn;
end;
$$;
revoke all on function app.record_repair(uuid, uuid, app.payment_method,
  numeric, numeric, numeric, text, text, text, integer, uuid, text) from public;
grant execute on function app.record_repair(uuid, uuid, app.payment_method,
  numeric, numeric, numeric, text, text, text, integer, uuid, text) to authenticated;
