Add cash management schema and immediate variance alerts

This commit is contained in:
Krikorios
2026-05-06 10:51:55 +03:00
parent 1a3de58de6
commit 1896cbdd11
106 changed files with 16800 additions and 4604 deletions
@@ -0,0 +1,318 @@
-- =====================================================================
-- 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;