319 lines
12 KiB
PL/PgSQL
319 lines
12 KiB
PL/PgSQL
-- =====================================================================
|
|
-- 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;
|