Add cash management schema and immediate variance alerts
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user