diff --git a/supabase/migrations/20260613150000_transactions_void_support.sql b/supabase/migrations/20260613150000_transactions_void_support.sql
new file mode 100644
index 0000000..9eb9101
--- /dev/null
+++ b/supabase/migrations/20260613150000_transactions_void_support.sql
@@ -0,0 +1,94 @@
+-- Void support for bank-register transactions. Voided rows stay for audit but
+-- are excluded from the cached account balance and, for gl_managed companies,
+-- reversed out of the GL (post_transaction_gl clears + posts nothing when voided).
+alter table accounting.transactions
+ add column if not exists voided boolean not null default false,
+ add column if not exists voided_at timestamptz,
+ add column if not exists voided_by uuid;
+
+-- post_transaction_gl: a voided txn clears its prior GL entry and posts nothing.
+create or replace function accounting.post_transaction_gl(_txn_id uuid)
+ returns void language plpgsql security definer set search_path to 'public', 'accounting'
+as $function$
+declare t accounting.transactions%rowtype; _counter uuid; _je uuid; _amt numeric;
+begin
+ select * into t from accounting.transactions where id=_txn_id;
+ if not found then return; end if;
+ perform accounting._gl_clear(t.company_id, 'acmacc_txn', t.id::text);
+ if coalesce(t.voided, false) then return; end if;
+ if not accounting.gl_managed(t.company_id) then return; end if;
+ if t.transfer_id is not null or t.deposit_id is not null then return; end if;
+ if t.account_id is null then return; end if;
+ _amt := coalesce(t.amount,0);
+ if _amt = 0 then return; end if;
+ _counter := case
+ when t.customer_id is not null then accounting.coa_ar(t.company_id)
+ when t.coa_account_id is not null then t.coa_account_id
+ when t.vendor_id is not null then accounting.coa_ap(t.company_id)
+ else null end;
+ if _counter is null then return; end if;
+ insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id)
+ values (t.company_id, t.date, coalesce(nullif(t.description,''), 'Bank transaction'), t.reference, 'acmacc_txn', t.id::text)
+ returning id into _je;
+ if t.type = 'credit' then
+ insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values
+ (_je, t.account_id, _amt, 0, t.description), (_je, _counter, 0, _amt, t.description);
+ else
+ insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values
+ (_je, _counter, _amt, 0, t.description), (_je, t.account_id, 0, _amt, t.description);
+ end if;
+end$function$;
+
+-- sync_account_balance: exclude voided transactions from every cached-balance sum.
+-- (Full body re-created so each SUM filters COALESCE(voided,false)=false.)
+create or replace function accounting.sync_account_balance()
+ returns trigger language plpgsql security definer set search_path to 'public'
+as $function$
+DECLARE v_computed numeric;
+BEGIN
+ IF TG_OP IN ('INSERT','UPDATE') THEN
+ IF NEW.account_id IS NOT NULL THEN
+ SELECT COALESCE(SUM(CASE WHEN type='credit' THEN amount ELSE -amount END),0) INTO v_computed
+ FROM accounting.transactions WHERE account_id=NEW.account_id AND COALESCE(voided,false)=false;
+ UPDATE accounting.accounts SET balance=v_computed, updated_at=now() WHERE id=NEW.account_id;
+ END IF;
+ IF NEW.coa_account_id IS NOT NULL THEN
+ SELECT COALESCE(SUM(CASE WHEN a.type IN ('expense','asset') THEN CASE WHEN t.type='debit' THEN t.amount ELSE -t.amount END
+ ELSE CASE WHEN t.type='credit' THEN t.amount ELSE -t.amount END END),0) INTO v_computed
+ FROM accounting.transactions t JOIN accounting.accounts a ON a.id=t.coa_account_id
+ WHERE t.coa_account_id=NEW.coa_account_id AND COALESCE(t.voided,false)=false;
+ UPDATE accounting.accounts SET balance=v_computed, updated_at=now() WHERE id=NEW.coa_account_id;
+ END IF;
+ END IF;
+ IF TG_OP='UPDATE' THEN
+ IF OLD.account_id IS NOT NULL AND OLD.account_id IS DISTINCT FROM NEW.account_id THEN
+ SELECT COALESCE(SUM(CASE WHEN type='credit' THEN amount ELSE -amount END),0) INTO v_computed
+ FROM accounting.transactions WHERE account_id=OLD.account_id AND COALESCE(voided,false)=false;
+ UPDATE accounting.accounts SET balance=v_computed, updated_at=now() WHERE id=OLD.account_id;
+ END IF;
+ IF OLD.coa_account_id IS NOT NULL AND OLD.coa_account_id IS DISTINCT FROM NEW.coa_account_id THEN
+ SELECT COALESCE(SUM(CASE WHEN a.type IN ('expense','asset') THEN CASE WHEN t.type='debit' THEN t.amount ELSE -t.amount END
+ ELSE CASE WHEN t.type='credit' THEN t.amount ELSE -t.amount END END),0) INTO v_computed
+ FROM accounting.transactions t JOIN accounting.accounts a ON a.id=t.coa_account_id
+ WHERE t.coa_account_id=OLD.coa_account_id AND COALESCE(t.voided,false)=false;
+ UPDATE accounting.accounts SET balance=v_computed, updated_at=now() WHERE id=OLD.coa_account_id;
+ END IF;
+ END IF;
+ IF TG_OP='DELETE' THEN
+ IF OLD.account_id IS NOT NULL THEN
+ SELECT COALESCE(SUM(CASE WHEN type='credit' THEN amount ELSE -amount END),0) INTO v_computed
+ FROM accounting.transactions WHERE account_id=OLD.account_id AND COALESCE(voided,false)=false;
+ UPDATE accounting.accounts SET balance=v_computed, updated_at=now() WHERE id=OLD.account_id;
+ END IF;
+ IF OLD.coa_account_id IS NOT NULL THEN
+ SELECT COALESCE(SUM(CASE WHEN a.type IN ('expense','asset') THEN CASE WHEN t.type='debit' THEN t.amount ELSE -t.amount END
+ ELSE CASE WHEN t.type='credit' THEN t.amount ELSE -t.amount END END),0) INTO v_computed
+ FROM accounting.transactions t JOIN accounting.accounts a ON a.id=t.coa_account_id
+ WHERE t.coa_account_id=OLD.coa_account_id AND COALESCE(t.voided,false)=false;
+ UPDATE accounting.accounts SET balance=v_computed, updated_at=now() WHERE id=OLD.coa_account_id;
+ END IF;
+ RETURN OLD;
+ END IF;
+ RETURN NEW;
+END;
+$function$;