-- ===================================================================== -- 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;