Add cash management schema and immediate variance alerts
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
-- =====================================================================
|
||||
-- 0020_void_reverses_movements.sql
|
||||
--
|
||||
-- Up to and including 0019, app.void_transaction() only flipped the
|
||||
-- transactions.status flag to 'voided'. The original cash_movements,
|
||||
-- float_movements, stock_movements and voucher_inventory rows that the
|
||||
-- record_* functions had posted stayed in place, so the till expected
|
||||
-- balance, OMT/Whish float balance and stock-on-hand were never
|
||||
-- corrected. A cashier could record a $500 OMT_SEND, pocket the $500,
|
||||
-- then void the txn five minutes later and the books would still show
|
||||
-- $500 received in the till.
|
||||
--
|
||||
-- This migration makes void a true accounting reversal:
|
||||
-- * for every cash_movements row tied to the txn we post an opposite-
|
||||
-- signed `adjustment` row (sign guard from 0019 allows either sign
|
||||
-- for adjustment),
|
||||
-- * for every float_movements row we post an opposite-signed row,
|
||||
-- * for every stock_movements row we post an opposite type
|
||||
-- (sale_out -> adjustment_in, return_in -> adjustment_out, etc.)
|
||||
-- with the void approver as approved_by,
|
||||
-- * any voucher_inventory marked sold by the txn is returned to
|
||||
-- `in_stock` so the serial can be re-sold.
|
||||
--
|
||||
-- The reversals reference the same ref_txn_id so reconciliation views
|
||||
-- and the receipts trail keep them paired with the original posting.
|
||||
-- =====================================================================
|
||||
|
||||
set search_path = app, public;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Helper: post compensating cash + float + stock + voucher rows for a
|
||||
-- transaction that is being voided. Returns nothing; the caller is
|
||||
-- responsible for flipping the txn status itself.
|
||||
-- ---------------------------------------------------------------------
|
||||
create or replace function app._reverse_movements_for_txn(
|
||||
p_txn_id uuid,
|
||||
p_approver uuid,
|
||||
p_reason text
|
||||
) returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
as $$
|
||||
declare
|
||||
t app.transactions%rowtype;
|
||||
r_cash record;
|
||||
r_flt record;
|
||||
r_stk record;
|
||||
v_reverse_type app.stock_movement_type;
|
||||
v_note text;
|
||||
begin
|
||||
select * into t from app.transactions where id = p_txn_id;
|
||||
if t.id is null then
|
||||
raise exception 'reverse: txn % not found', p_txn_id;
|
||||
end if;
|
||||
|
||||
v_note := 'void reversal: ' || coalesce(p_reason, '');
|
||||
|
||||
-- ----- Cash legs --------------------------------------------------
|
||||
-- Re-post each existing cash_movements row with opposite sign as an
|
||||
-- 'adjustment' (the only cash_movement_type that the 0019 sign guard
|
||||
-- lets carry either sign).
|
||||
for r_cash in
|
||||
select id, shift_id, currency, amount
|
||||
from app.cash_movements
|
||||
where ref_txn_id = p_txn_id
|
||||
and type <> 'adjustment' -- don't reverse prior reversals
|
||||
loop
|
||||
insert into app.cash_movements(
|
||||
shift_id, type, currency, amount, ref_txn_id, note, created_by
|
||||
) values (
|
||||
r_cash.shift_id,
|
||||
'adjustment'::app.cash_movement_type,
|
||||
r_cash.currency,
|
||||
-r_cash.amount,
|
||||
p_txn_id,
|
||||
v_note,
|
||||
p_approver
|
||||
);
|
||||
end loop;
|
||||
|
||||
-- ----- Float legs -------------------------------------------------
|
||||
for r_flt in
|
||||
select id, float_id, shift_id, amount
|
||||
from app.float_movements
|
||||
where ref_txn_id = p_txn_id
|
||||
and coalesce(reason,'') not like 'void reversal%'
|
||||
loop
|
||||
insert into app.float_movements(
|
||||
float_id, shift_id, amount, ref_txn_id, reason, created_by
|
||||
) values (
|
||||
r_flt.float_id,
|
||||
r_flt.shift_id,
|
||||
-r_flt.amount,
|
||||
p_txn_id,
|
||||
v_note,
|
||||
p_approver
|
||||
);
|
||||
end loop;
|
||||
|
||||
-- ----- Stock legs -------------------------------------------------
|
||||
-- For physical goods sold via record_goods_sale, reverse the
|
||||
-- sale_out by posting an adjustment_in of equal magnitude (positive),
|
||||
-- and vice versa for any in-bound rows tied to this txn.
|
||||
for r_stk in
|
||||
select id, sku, shop_id, shift_id, type, qty_delta, ref_lot_id
|
||||
from app.stock_movements
|
||||
where ref_txn_id = p_txn_id
|
||||
and type not in ('adjustment_in','adjustment_out')
|
||||
loop
|
||||
if r_stk.qty_delta < 0 then
|
||||
v_reverse_type := 'adjustment_in'::app.stock_movement_type;
|
||||
else
|
||||
v_reverse_type := 'adjustment_out'::app.stock_movement_type;
|
||||
end if;
|
||||
|
||||
insert into app.stock_movements(
|
||||
sku, shop_id, shift_id, type, qty_delta,
|
||||
ref_txn_id, ref_lot_id, approved_by, reason, created_by
|
||||
) values (
|
||||
r_stk.sku, r_stk.shop_id, r_stk.shift_id,
|
||||
v_reverse_type,
|
||||
-r_stk.qty_delta,
|
||||
p_txn_id, r_stk.ref_lot_id,
|
||||
p_approver,
|
||||
v_note,
|
||||
p_approver
|
||||
);
|
||||
end loop;
|
||||
|
||||
-- ----- Voucher serials -------------------------------------------
|
||||
-- Any voucher marked sold by this txn returns to in_stock so it can
|
||||
-- be sold again. (The voucher_status_consistency CHECK clears
|
||||
-- sold_txn_id / sold_at when status becomes 'in_stock'.)
|
||||
update app.voucher_inventory
|
||||
set status = 'in_stock',
|
||||
sold_txn_id = null,
|
||||
sold_at = null,
|
||||
status_changed_by = p_approver,
|
||||
status_change_reason = v_note
|
||||
where sold_txn_id = p_txn_id
|
||||
and status = 'sold';
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function app._reverse_movements_for_txn(uuid, uuid, text) from public;
|
||||
-- Internal helper only — callable from void_transaction (security definer).
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Re-define void_transaction to reverse movements before flipping
|
||||
-- status. We keep the same signature as 0003 so the existing UI calls
|
||||
-- continue to work.
|
||||
-- ---------------------------------------------------------------------
|
||||
create or replace function app.void_transaction(
|
||||
p_txn_id uuid,
|
||||
p_reason text,
|
||||
p_approver_pin text default null
|
||||
) returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
as $$
|
||||
declare
|
||||
t app.transactions%rowtype;
|
||||
s app.shifts%rowtype;
|
||||
window_min int;
|
||||
needs_manager boolean;
|
||||
v_approver uuid;
|
||||
begin
|
||||
select * into t from app.transactions where id = p_txn_id;
|
||||
if t.id is null then raise exception 'transaction not found'; end if;
|
||||
if t.status = 'voided' then raise exception 'transaction already voided'; end if;
|
||||
if p_reason is null or length(btrim(p_reason)) < 5 then
|
||||
raise exception 'a reason of at least 5 characters is required';
|
||||
end if;
|
||||
|
||||
select * into s from app.shifts where id = t.shift_id;
|
||||
if s.status <> 'open' then
|
||||
raise exception 'cannot void a transaction whose shift is no longer open';
|
||||
end if;
|
||||
|
||||
select coalesce(value::int, 10) into window_min
|
||||
from app.system_settings where key = 'void_self_window_minutes';
|
||||
|
||||
needs_manager := (auth.uid() <> t.user_id)
|
||||
or (now() - t.created_at > make_interval(mins => window_min));
|
||||
|
||||
if needs_manager then
|
||||
if not app.has_role_in_shop(t.shop_id, 'manager') then
|
||||
raise exception 'manager approval required to void this transaction';
|
||||
end if;
|
||||
if p_approver_pin is null or not app.verify_my_pin(p_approver_pin) then
|
||||
raise exception 'manager PIN required and must be valid';
|
||||
end if;
|
||||
end if;
|
||||
|
||||
v_approver := auth.uid();
|
||||
|
||||
-- Reverse the money / stock / voucher legs FIRST. If any of these
|
||||
-- inserts fails (e.g. stock would go negative because more vouchers
|
||||
-- have been sold from the lot since), the whole void is rolled back
|
||||
-- and the books stay consistent.
|
||||
if t.status = 'completed' then
|
||||
perform app._reverse_movements_for_txn(p_txn_id, v_approver, p_reason);
|
||||
end if;
|
||||
|
||||
-- Flip the status (only this function may UPDATE app.transactions).
|
||||
perform set_config('app.txn_internal', 'on', true);
|
||||
update app.transactions
|
||||
set status = 'voided',
|
||||
voided_at = now(),
|
||||
voided_by = v_approver,
|
||||
void_reason = p_reason,
|
||||
void_approved_by = case when needs_manager then v_approver else null end
|
||||
where id = p_txn_id;
|
||||
perform set_config('app.txn_internal', 'off', true);
|
||||
|
||||
-- Recompute the row's hash so the chain reflects the new state.
|
||||
perform set_config('app.txn_internal', 'on', true);
|
||||
update app.transactions tt
|
||||
set row_hash = app.txn_compute_hash(tt, tt.prev_row_hash)
|
||||
where id = p_txn_id;
|
||||
perform set_config('app.txn_internal', 'off', true);
|
||||
|
||||
perform app.log_auth_event('txn_voided', t.shop_id, null,
|
||||
jsonb_build_object('txn_id', p_txn_id,
|
||||
'manager_path', needs_manager,
|
||||
'reversed', t.status = 'completed'));
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function app.void_transaction(uuid, text, text) from public;
|
||||
grant execute on function app.void_transaction(uuid, text, text) to authenticated;
|
||||
Reference in New Issue
Block a user