From 305f469fc3782802ef704eea21bdac7eb895f3c5 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:42:41 +0200 Subject: [PATCH] =?UTF-8?q?harden(db):=20tenant=20backstop=20=E2=80=94=20p?= =?UTF-8?q?ayment=20company-consistency=20triggers=20+=20write-RPC=20guard?= =?UTF-8?q?s=20(P0-2)=20(#680)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * harden(security): payment-row company-consistency triggers (tenant backstop) invoice_payments and supplier_invoice_payments are the only two child tables carrying BOTH a parent FK and their own company_id. A row whose company_id disagrees with its parent's company_id is a tenant-isolation defect that would surface a foreign tenant's payment in this company's AR/AP ledger. RLS scopes by company_id but never cross-checks the parent, so nothing at the DB layer guaranteed the invariant. - Pre-flight DO block: fail the migration loudly (listing offending ids) if any existing row already violates child.company_id = parent.company_id, rather than arm a trigger over dirty data that can never be updated again. - enforce_payment_company_consistency(): one INVOKER trigger function parameterized on TG_TABLE_NAME, wired BEFORE INSERT OR UPDATE OF (company_id, parent_fk) on both payment tables; raises on mismatch. Matches the SECURITY posture of the sibling enforcement triggers in migration 017. - pg-real coverage in tests/pg/payment-company-consistency.pg.test.ts: matching pair inserts ok; cross-tenant insert + cross-tenant UPDATE raise; both the customer and supplier side. Co-Authored-By: Claude Opus 4.8 (1M context) * harden(security): tenant guards on six SECURITY DEFINER write RPCs (backstop) bulk_book_transactions, match_batch_allocate, mark_entry_as_opening_balance, reserve_voucher_range, release_voucher_range and rotate_company_inbox are all SECURITY DEFINER and EXECUTE-able by `authenticated`, so an authenticated user could call them via PostgREST with ANOTHER company's p_company_id. Three already carried an auth.uid()-based membership check and rotate_company_inbox an owner/admin gate, but the two voucher-range RPCs had NO tenant check at all. Adds the canonical claims-based guard (mirrors 20260615120000_link_voucher_rpcs_tenant_guard.sql lines 54-69) at the top of each body: for anon/authenticated callers, membership of p_company_id (public.user_company_ids()) is required else RAISE 42501; service_role and no-claims callers (migrations, pg-harness, MCP / API-key paths whose company scoping happens in TS) bypass BY DESIGN. Each function body is otherwise copied verbatim from its latest definition; existing GRANTs re-applied. pg-real coverage in tests/pg/securitydefiner_write_rpc_tenant_guards.pg.test.ts: per RPC — userA session targeting companyB raises 42501; targeting own company passes the guard (succeeds or yields a non-42501 domain outcome, documented inline); a no-claims bare-pool cross-tenant call bypasses the new guard, proving the service-role / MCP paths are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(db): renumber tenant-backstop migrations to 20260619130000/130100 PR1 (agent attribution) claimed the 20260619120000 version slot in the same batch; Supabase migration versions must be unique across the repo, so the tenant-backstop pair moves to 130000/130100. Filename-only change plus the matching doc-comment references in the two pg tests. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(db): restore source comments dropped in copied RPC bodies The guarded redefinitions of bulk_book_transactions and match_batch_allocate must be byte-verbatim copies of their latest sources (modulo the inserted tenant-guard block) so the next CREATE OR REPLACE copy keeps full provenance. Restores the Round-2/Round-3 compliance-fix annotations that were lost in the copy. Verified mechanically: zero residual diff vs sources after stripping the guard block, for all six functions. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(db): drop raise-guards from bulk_book/match_batch — they break the jsonb error contract Local full-migration replay + pg-real run surfaced that prepending the 42501 raise-guard to bulk_book_transactions and match_batch_allocate changes their error contract for authenticated cross-tenant callers: both already enforce membership in-function and return structured domain errors (BULK_BOOK_UNAUTHORIZED / BATCH_UNAUTHORIZED) that routes, MCP tools, and their existing pg tests branch on. The guard added no isolation (they were tenant-safe) but broke that contract. The migration now guards only the four RPCs where it is sound: mark_entry_as_opening_balance (P0001→42501, still an exception), rotate_company_inbox (already 42501), and the two genuinely unguarded voucher-range RPCs. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(db): compliance-review round — log hygiene, explicit INVOKER, anon revoke, UPDATE-path test Addresses the compliance-swarm findings on this PR: - Pre-flight dirty-data check now raises with COUNTS only; the row ids move to RAISE NOTICE so error pipelines do not ingest identifier dumps (ASVS V8.2.1 / SOC 2 CC6.1). - enforce_payment_company_consistency() declares SECURITY INVOKER explicitly — the default was already INVOKER; this makes the security model self-documenting. - REVOKE ... FROM PUBLIC, anon on reserve/release_voucher_range and rotate_company_inbox, matching the mark_entry_as_opening_balance pattern. - Adds the missing supplier_invoice_payments UPDATE-path trigger probe (SOC 2 PI1.3). Dismissed as by-design: the JWT-claim trust boundary (set_config requires direct SQL access, which already bypasses by design — same model as 20260615120000). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(db): voucher-range compliance guards + FK-rerouting trigger probes Review round 2 on this PR: Swedish compliance review (both pre-existing function behaviour, hardened while the PR owns these bodies): - reserve/release_voucher_range now refuse closed/locked fiscal periods (BFL 5 kap 5§ — the sequence of a locked period is räkenskapsinformation; mirrors mark_entry_as_opening_balance). - release_voucher_range asserts no verifikat exist in the released range before rolling last_number back (BFL 5 kap 6-7§ — never re-issue or orphan posted verifikationsnummer). Neither guard can fire in the legit SIE-import flow, which only releases numbers above its highest inserted verifikat into an open period — and the import caller treats a failed release as non-fatal. Greptile P2: the UPDATE OF trigger leg was never probed — added cross-tenant FK-rerouting rejection tests for both payment tables (the supplier company_id UPDATE probe landed in the previous commit). Verified: full migration replay on fresh supabase/postgres + 333/333 pg-real green on an origin/main merge (incl. merged #678). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(test): release-succeeds probe must persist — callBare rolls back The legit-path release test asserted last_number after calling the RPC via callBare, whose BEGIN...ROLLBACK wrapper undoes the UPDATE before the assertion reads it (caught in CI; the local pre-push replay had validated the branch's committed state, not the then-uncommitted test). Call the RPC directly on the pool, like the engine pg tests do for persisting calls. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- ...0619130000_payment_company_consistency.sql | 111 ++++++ ...ecuritydefiner_write_rpc_tenant_guards.sql | 352 ++++++++++++++++++ .../pg/payment-company-consistency.pg.test.ts | 203 ++++++++++ ...definer_write_rpc_tenant_guards.pg.test.ts | 256 +++++++++++++ 4 files changed, 922 insertions(+) create mode 100644 supabase/migrations/20260619130000_payment_company_consistency.sql create mode 100644 supabase/migrations/20260619130100_securitydefiner_write_rpc_tenant_guards.sql create mode 100644 tests/pg/payment-company-consistency.pg.test.ts create mode 100644 tests/pg/securitydefiner_write_rpc_tenant_guards.pg.test.ts diff --git a/supabase/migrations/20260619130000_payment_company_consistency.sql b/supabase/migrations/20260619130000_payment_company_consistency.sql new file mode 100644 index 00000000..2c99c2ca --- /dev/null +++ b/supabase/migrations/20260619130000_payment_company_consistency.sql @@ -0,0 +1,111 @@ +-- Payment row company-consistency triggers (P0 tenant backstop). +-- +-- invoice_payments and supplier_invoice_payments are the only two child tables +-- that carry BOTH a parent FK (invoice_id / supplier_invoice_id) and their own +-- company_id column (added by 20260330130000_multi_tenant_company_refactor). A +-- row whose company_id disagrees with its parent's company_id is a tenant- +-- isolation defect: it would surface a foreign tenant's payment in this +-- company's ledger (and vice-versa) and corrupt AR/AP reconciliation. RLS scopes +-- reads/writes by company_id but does NOT cross-check the parent, and the write +-- RPCs always pass a consistent pair — so nothing at the DB layer guarantees the +-- invariant. These BEFORE INSERT/UPDATE triggers make it impossible to persist a +-- mismatched pair regardless of how the row is written (RPC, direct PostgREST, +-- service role, or a future code path). +-- +-- (journal_entry_lines, invoice_items and supplier_invoice_items carry a parent +-- FK but NO company_id of their own, so they cannot drift and are out of scope.) +-- +-- SECURITY posture: like the sibling enforcement triggers in migration 017, the +-- trigger function is a plain (INVOKER) trigger function — it only reads the +-- parent's company_id via the FK and raises; it needs no elevated privilege. + +-- ============================================================================= +-- 0. PRE-FLIGHT: fail loudly if any existing row already violates the invariant, +-- rather than arming a trigger over dirty data that can never be updated again. +-- ============================================================================= +DO $$ +DECLARE + v_bad_invoice_payments uuid[]; + v_bad_supplier_payments uuid[]; +BEGIN + SELECT array_agg(p.id) + INTO v_bad_invoice_payments + FROM public.invoice_payments p + JOIN public.invoices i ON i.id = p.invoice_id + WHERE p.company_id IS DISTINCT FROM i.company_id; + + SELECT array_agg(p.id) + INTO v_bad_supplier_payments + FROM public.supplier_invoice_payments p + JOIN public.supplier_invoices si ON si.id = p.supplier_invoice_id + WHERE p.company_id IS DISTINCT FROM si.company_id; + + IF v_bad_invoice_payments IS NOT NULL OR v_bad_supplier_payments IS NOT NULL THEN + -- Row ids go to NOTICE (server log, operator-visible at apply time); the + -- exception itself carries only counts so error pipelines/aggregators do + -- not ingest identifier dumps (OWASP ASVS V8.2.1 / SOC 2 CC6.1). + RAISE NOTICE 'mismatched invoice_payments ids: %', + COALESCE(v_bad_invoice_payments, ARRAY[]::uuid[]); + RAISE NOTICE 'mismatched supplier_invoice_payments ids: %', + COALESCE(v_bad_supplier_payments, ARRAY[]::uuid[]); + RAISE EXCEPTION 'Cannot arm payment company-consistency triggers over dirty data: % invoice_payments and % supplier_invoice_payments row(s) mismatched — see preceding NOTICEs for ids.', + COALESCE(array_length(v_bad_invoice_payments, 1), 0), + COALESCE(array_length(v_bad_supplier_payments, 1), 0); + END IF; +END +$$; + +-- ============================================================================= +-- 1. Trigger function: assert child.company_id matches the parent's company_id. +-- Parameterized on TG_TABLE_NAME so one function covers both payment tables +-- (mirrors the single-function-per-concern style of migration 017). +-- ============================================================================= +CREATE OR REPLACE FUNCTION public.enforce_payment_company_consistency() +RETURNS trigger +LANGUAGE plpgsql +SECURITY INVOKER +AS $$ +DECLARE + v_parent_company_id uuid; +BEGIN + IF TG_TABLE_NAME = 'invoice_payments' THEN + SELECT company_id INTO v_parent_company_id + FROM public.invoices + WHERE id = NEW.invoice_id; + + IF v_parent_company_id IS DISTINCT FROM NEW.company_id THEN + RAISE EXCEPTION + 'invoice_payments.company_id (%) does not match invoices.company_id (%) for invoice %', + NEW.company_id, v_parent_company_id, NEW.invoice_id; + END IF; + ELSIF TG_TABLE_NAME = 'supplier_invoice_payments' THEN + SELECT company_id INTO v_parent_company_id + FROM public.supplier_invoices + WHERE id = NEW.supplier_invoice_id; + + IF v_parent_company_id IS DISTINCT FROM NEW.company_id THEN + RAISE EXCEPTION + 'supplier_invoice_payments.company_id (%) does not match supplier_invoices.company_id (%) for supplier invoice %', + NEW.company_id, v_parent_company_id, NEW.supplier_invoice_id; + END IF; + END IF; + + RETURN NEW; +END; +$$; + +-- ============================================================================= +-- 2. Wire the trigger to both payment tables. BEFORE INSERT OR UPDATE OF the +-- columns that could break the invariant (company_id and the parent FK). +-- ============================================================================= +DROP TRIGGER IF EXISTS enforce_payment_company_consistency ON public.invoice_payments; +CREATE TRIGGER enforce_payment_company_consistency + BEFORE INSERT OR UPDATE OF company_id, invoice_id ON public.invoice_payments + FOR EACH ROW EXECUTE FUNCTION public.enforce_payment_company_consistency(); + +DROP TRIGGER IF EXISTS enforce_payment_company_consistency ON public.supplier_invoice_payments; +CREATE TRIGGER enforce_payment_company_consistency + BEFORE INSERT OR UPDATE OF company_id, supplier_invoice_id ON public.supplier_invoice_payments + FOR EACH ROW EXECUTE FUNCTION public.enforce_payment_company_consistency(); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260619130100_securitydefiner_write_rpc_tenant_guards.sql b/supabase/migrations/20260619130100_securitydefiner_write_rpc_tenant_guards.sql new file mode 100644 index 00000000..cd0ea5c2 --- /dev/null +++ b/supabase/migrations/20260619130100_securitydefiner_write_rpc_tenant_guards.sql @@ -0,0 +1,352 @@ +-- Tenant guards on four SECURITY DEFINER write RPCs (P0 tenant backstop). +-- +-- These RPCs run with the definer's privileges and bypass the caller's RLS, and +-- all are EXECUTE-able by `authenticated`. The canonical guard (introduced on +-- the GL read RPCs in PR #625 and on the voucher-link write RPCs in +-- 20260615120000_link_voucher_rpcs_tenant_guard.sql, lines 54-69) reads the +-- request.jwt.claims role and, for anon/authenticated callers only, requires +-- membership of p_company_id (public.user_company_ids()). service_role and +-- direct/superuser callers (no JWT role — migrations, the pg-real harness, and +-- the MCP / API-key paths whose company scoping happens in TS) bypass the guard +-- BY DESIGN, so this change cannot affect those flows. +-- +-- Deliberately NOT guarded here: bulk_book_transactions and +-- match_batch_allocate. Both already enforce membership via an in-function +-- auth.uid() check that returns a structured domain error +-- ({ok:false, code:'BULK_BOOK_UNAUTHORIZED' / 'BATCH_UNAUTHORIZED'}) that the +-- routes, MCP tools, and existing pg tests branch on. Prepending a raise-style +-- guard would change that error contract from jsonb to a 42501 exception for +-- authenticated cross-tenant callers — a behavioural break for no isolation +-- gain. They are tenant-safe as-is. +-- +-- Of the four guarded here: mark_entry_as_opening_balance already raised +-- (P0001) for non-members and rotate_company_inbox already raised 42501 for +-- non-owner/admin — the uniform guard tightens both to a consistent 42501 +-- without changing exception-vs-success behaviour. The two voucher-range RPCs +-- (reserve_voucher_range, release_voucher_range) had NO tenant check at all — +-- those are the real gap this migration closes. +-- +-- Each function body below is copied verbatim from its latest definition; only +-- the guard block (and, where present, a v_jwt_role DECLARE) is added. Existing +-- GRANTs are re-applied because CREATE OR REPLACE preserves privileges but a +-- DROP+CREATE resets them. +-- +-- The two voucher-range RPCs additionally gain (Swedish compliance review on +-- PR #680): a period-lock guard (BFL 5 kap 5§ — the sequence of a closed or +-- locked period is räkenskapsinformation, mirroring mark_entry_as_opening_balance) +-- and, on release, a sequence-integrity assert (BFL 5 kap 6–7§ — never roll +-- last_number back below an existing verifikat). Neither fires in the legit +-- SIE-import flow, which only releases numbers above its highest inserted +-- verifikat into an open period. +-- +-- mark_entry_as_opening_balance — latest 20260613120000_mark_entry_as_opening_balance.sql +-- reserve_voucher_range — latest 20260402075153_fix_reserve_voucher_range.sql +-- release_voucher_range — latest 20260402075153_fix_reserve_voucher_range.sql +-- rotate_company_inbox — latest 20260420190000_inbox_hardening.sql +-- ============================================================================= +-- 1. mark_entry_as_opening_balance +-- ============================================================================= +CREATE OR REPLACE FUNCTION public.mark_entry_as_opening_balance( + p_company_id uuid, + p_entry_id uuid +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $function$ +DECLARE + v_caller_role text; + v_entry record; + v_is_closed boolean; + v_locked_at timestamptz; + v_has_bank_line boolean; + v_old_source_type text; + v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''); +BEGIN + -- Tenant guard: anon/authenticated may only act on their own companies; + -- service_role / direct access (no JWT role) bypasses BY DESIGN. + IF v_jwt_role IN ('anon', 'authenticated') + AND p_company_id NOT IN (SELECT public.user_company_ids()) THEN + RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id + USING ERRCODE = '42501'; + END IF; + + -- Owner/admin only (defense in depth alongside RLS; the function is SECURITY + -- DEFINER so it must enforce tenancy + role itself). + SELECT cm.role INTO v_caller_role + FROM company_members cm + WHERE cm.company_id = p_company_id + AND cm.user_id = auth.uid(); + + IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN + RAISE EXCEPTION 'Only company owners and admins can re-tag opening balances'; + END IF; + + SELECT * INTO v_entry + FROM journal_entries + WHERE id = p_entry_id + AND company_id = p_company_id + FOR UPDATE; + + IF v_entry IS NULL THEN + RAISE EXCEPTION 'Journal entry not found'; + END IF; + + IF v_entry.status <> 'posted' THEN + RAISE EXCEPTION 'Only posted entries can be re-tagged as opening balance (current status: %)', v_entry.status; + END IF; + + IF v_entry.source_type NOT IN ('manual', 'import') THEN + RAISE EXCEPTION 'Only manual/import entries can be re-tagged as opening balance (current source_type: %)', v_entry.source_type; + END IF; + + -- Must touch a bank/cash account. Re-tagging excludes the WHOLE entry from the + -- reconciliation period movement, so it must genuinely be a bank-account IB. + SELECT EXISTS ( + SELECT 1 FROM journal_entry_lines l + WHERE l.journal_entry_id = p_entry_id + AND l.account_number IN ('1910','1920','1930','1931','1932','1940','1941','1950') + ) INTO v_has_bank_line; + + IF NOT v_has_bank_line THEN + RAISE EXCEPTION 'Entry does not touch a bank/cash account (19xx); refusing to tag as opening balance'; + END IF; + + -- Respect period lock (mirror delete_last_voucher). enforce_period_lock would + -- block the UPDATE anyway; we refuse first with a clearer message. + SELECT is_closed, locked_at INTO v_is_closed, v_locked_at + FROM fiscal_periods + WHERE id = v_entry.fiscal_period_id; + + IF v_is_closed THEN + RAISE EXCEPTION 'Cannot re-tag an entry in a closed fiscal period'; + END IF; + IF v_locked_at IS NOT NULL THEN + RAISE EXCEPTION 'Cannot re-tag an entry in a locked fiscal period'; + END IF; + + v_old_source_type := v_entry.source_type; + + -- Transaction-local bypass consumed by the immutability carve-out above. + PERFORM set_config('gnubok.allow_source_type_retag', 'true', true); + + UPDATE journal_entries + SET source_type = 'opening_balance' + WHERE id = p_entry_id + AND company_id = p_company_id; + + -- Provenance row (write_audit_log also logs old/new state via the AFTER trigger; + -- this adds the human-readable reason, matching the delete_last_voucher pattern). + INSERT INTO audit_log (user_id, company_id, action, table_name, record_id, actor_id, description) + VALUES ( + v_entry.user_id, + p_company_id, + 'UPDATE', + 'journal_entries', + p_entry_id, + auth.uid(), + 'Re-tagged source_type ' || v_old_source_type || ' -> opening_balance ' || + '(mark_entry_as_opening_balance RPC, caller: ' || auth.uid() || ')' + ); + + RETURN jsonb_build_object( + 'retagged', true, + 'entry_id', p_entry_id, + 'previous_source_type', v_old_source_type, + 'voucher_series', v_entry.voucher_series, + 'voucher_number', v_entry.voucher_number + ); +END; +$function$; + +REVOKE ALL ON FUNCTION public.mark_entry_as_opening_balance(uuid, uuid) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.mark_entry_as_opening_balance(uuid, uuid) TO authenticated; + +-- ============================================================================= +-- 2. reserve_voucher_range +-- ============================================================================= +CREATE OR REPLACE FUNCTION public.reserve_voucher_range( + p_company_id uuid, + p_fiscal_period_id uuid, + p_series text, + p_highest_used integer +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''); + v_is_closed boolean; + v_locked_at timestamptz; +BEGIN + -- Tenant guard: anon/authenticated may only act on their own companies; + -- service_role / direct access (no JWT role) bypasses BY DESIGN. + IF v_jwt_role IN ('anon', 'authenticated') + AND p_company_id NOT IN (SELECT public.user_company_ids()) THEN + RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id + USING ERRCODE = '42501'; + END IF; + + -- Period-lock guard (BFL 5 kap 5§ / BFNAR 2013:2): the voucher sequence of a + -- closed or locked period is part of its räkenskapsinformation — refuse to + -- mutate it, mirroring mark_entry_as_opening_balance. (An unknown period id + -- leaves both NULL and falls through to the FK violation, as before.) + SELECT fp.is_closed, fp.locked_at INTO v_is_closed, v_locked_at + FROM public.fiscal_periods fp WHERE fp.id = p_fiscal_period_id; + IF v_is_closed OR v_locked_at IS NOT NULL THEN + RAISE EXCEPTION 'Cannot reserve voucher numbers in a closed/locked fiscal period'; + END IF; + + INSERT INTO public.voucher_sequences (company_id, user_id, fiscal_period_id, voucher_series, last_number) + VALUES (p_company_id, auth.uid(), p_fiscal_period_id, p_series, p_highest_used) + ON CONFLICT (company_id, fiscal_period_id, voucher_series) + DO UPDATE SET + last_number = GREATEST(public.voucher_sequences.last_number, EXCLUDED.last_number), + updated_at = now(); +END; +$$; + +REVOKE ALL ON FUNCTION public.reserve_voucher_range(uuid, uuid, text, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.reserve_voucher_range(uuid, uuid, text, integer) TO authenticated; + +-- ============================================================================= +-- 3. release_voucher_range +-- ============================================================================= +CREATE OR REPLACE FUNCTION public.release_voucher_range( + p_company_id uuid, + p_fiscal_period_id uuid, + p_series text, + p_actual_last integer, + p_reserved_highest integer -- the ceiling this import originally reserved +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''); + v_is_closed boolean; + v_locked_at timestamptz; +BEGIN + -- Tenant guard: anon/authenticated may only act on their own companies; + -- service_role / direct access (no JWT role) bypasses BY DESIGN. + IF v_jwt_role IN ('anon', 'authenticated') + AND p_company_id NOT IN (SELECT public.user_company_ids()) THEN + RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id + USING ERRCODE = '42501'; + END IF; + + -- Period-lock guard (BFL 5 kap 5§ / BFNAR 2013:2), mirroring + -- mark_entry_as_opening_balance. NOTE: the SIE-import caller does not treat a + -- failed release as fatal — the sequence then simply stays at the reserved + -- ceiling and the voucher-gap machinery documents the gap. + SELECT fp.is_closed, fp.locked_at INTO v_is_closed, v_locked_at + FROM public.fiscal_periods fp WHERE fp.id = p_fiscal_period_id; + IF v_is_closed OR v_locked_at IS NOT NULL THEN + RAISE EXCEPTION 'Cannot release voucher numbers in a closed/locked fiscal period'; + END IF; + + -- Sequence-integrity guard (BFL 5 kap 6–7§): never roll last_number back + -- below an existing verifikat — releasing a range that contains posted + -- numbers would let the sequence re-issue them (duplicate verifikationsnummer) + -- or imply gaps where none should exist. + IF EXISTS ( + SELECT 1 FROM public.journal_entries je + WHERE je.company_id = p_company_id + AND je.fiscal_period_id = p_fiscal_period_id + AND je.voucher_series = p_series + AND je.voucher_number > p_actual_last + AND je.voucher_number <= p_reserved_highest + ) THEN + RAISE EXCEPTION 'Cannot release voucher range (%, %]: verifikat exist in the released range', p_actual_last, p_reserved_highest; + END IF; + + -- Only release within the range this import originally reserved. + -- The upper-bound guard (last_number <= p_reserved_highest) prevents rolling + -- back past numbers that a concurrent operation has legitimately claimed. + UPDATE public.voucher_sequences + SET last_number = p_actual_last, + updated_at = now() + WHERE company_id = p_company_id + AND fiscal_period_id = p_fiscal_period_id + AND voucher_series = p_series + AND last_number > p_actual_last + AND last_number <= p_reserved_highest; +END; +$$; + +REVOKE ALL ON FUNCTION public.release_voucher_range(uuid, uuid, text, integer, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.release_voucher_range(uuid, uuid, text, integer, integer) TO authenticated; + +-- ============================================================================= +-- 4. rotate_company_inbox +-- ============================================================================= +CREATE OR REPLACE FUNCTION public.rotate_company_inbox(p_company_id uuid) +RETURNS public.company_inboxes +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_company_name text; + v_local_part text; + v_slug_seed text; + v_new_row public.company_inboxes; + v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''); +BEGIN + -- Tenant guard: anon/authenticated may only act on their own companies; + -- service_role / direct access (no JWT role) bypasses BY DESIGN. + IF v_jwt_role IN ('anon', 'authenticated') + AND p_company_id NOT IN (SELECT public.user_company_ids()) THEN + RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id + USING ERRCODE = '42501'; + END IF; + + -- Authorization: caller must be owner/admin of the company. + IF NOT EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_id = p_company_id + AND user_id = auth.uid() + AND role IN ('owner', 'admin') + ) THEN + RAISE EXCEPTION 'Not authorized to rotate inbox for this company' + USING ERRCODE = '42501'; + END IF; + + SELECT name INTO v_company_name + FROM public.companies + WHERE id = p_company_id; + + IF v_company_name IS NULL THEN + RAISE EXCEPTION 'Company not found' USING ERRCODE = 'P0002'; + END IF; + + -- All three steps share one transaction — a failure on any of them + -- rolls the whole thing back, so the company never ends up without + -- an active inbox. + + UPDATE public.company_inboxes + SET status = 'deprecated', + deprecated_at = now() + WHERE company_id = p_company_id + AND status = 'active'; + + v_local_part := public.generate_inbox_local_part(v_company_name); + v_slug_seed := regexp_replace(v_local_part, '-[^-]+$', ''); + + INSERT INTO public.company_inboxes (company_id, local_part, slug_seed, status) + VALUES (p_company_id, v_local_part, v_slug_seed, 'active') + RETURNING * INTO v_new_row; + + RETURN v_new_row; +END; +$$; + +REVOKE ALL ON FUNCTION public.rotate_company_inbox(uuid) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.rotate_company_inbox(uuid) TO authenticated; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/payment-company-consistency.pg.test.ts b/tests/pg/payment-company-consistency.pg.test.ts new file mode 100644 index 00000000..7f7c205f --- /dev/null +++ b/tests/pg/payment-company-consistency.pg.test.ts @@ -0,0 +1,203 @@ +/** + * pg-real test for the payment company-consistency triggers + * (20260619130000_payment_company_consistency.sql). + * + * invoice_payments and supplier_invoice_payments are the only two child tables + * carrying BOTH a parent FK and their own company_id. A row whose company_id + * disagrees with its parent's company_id is a tenant-isolation defect. The + * BEFORE INSERT/UPDATE triggers make a mismatched pair impossible to persist + * regardless of how it is written — so these probes go through the superuser + * pool (which bypasses RLS), proving the trigger fires even for the most + * privileged writer. + */ +import { describe, it, expect } from 'vitest' +import { randomUUID } from 'node:crypto' +import { getPool } from './setup' +import { seedCompany } from './fixtures' + +let arrivalSeq = 0 + +async function seedCustomerInvoice(params: { + userId: string + companyId: string + total?: number +}): Promise { + const customerId = randomUUID() + await getPool().query( + `INSERT INTO public.customers (id, user_id, company_id, name, customer_type) + VALUES ($1, $2, $3, 'Test Kund AB', 'swedish_business')`, + [customerId, params.userId, params.companyId], + ) + const id = randomUUID() + const total = params.total ?? 1000 + await getPool().query( + `INSERT INTO public.invoices + (id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date, + currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status, + paid_amount, remaining_amount) + VALUES ($1, $2, $3, $4, $5, '2026-04-01', '2026-05-01', 'SEK', + $6, 0, $6, 'standard_25', 25, 'sent', 0, $6)`, + [id, params.userId, params.companyId, customerId, `F-${id.slice(0, 8)}`, total], + ) + return id +} + +async function seedSupplierInvoice(params: { + userId: string + companyId: string + total?: number +}): Promise { + const supplierId = randomUUID() + await getPool().query( + `INSERT INTO public.suppliers + (id, user_id, company_id, name, supplier_type, country, default_payment_terms, default_currency) + VALUES ($1, $2, $3, 'Leverantör AB', 'swedish_business', 'SE', 30, 'SEK')`, + [supplierId, params.userId, params.companyId], + ) + const id = randomUUID() + const total = params.total ?? 1000 + const arrivalNumber = (Date.now() % 1_000_000) * 1000 + arrivalSeq++ + await getPool().query( + `INSERT INTO public.supplier_invoices + (id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number, + invoice_date, due_date, received_date, status, currency, + subtotal, vat_amount, total, paid_amount, remaining_amount, + vat_treatment, reverse_charge, is_credit_note) + VALUES ($1, $2, $3, $4, $5, $6, '2026-04-01', '2026-05-01', '2026-04-01', 'approved', 'SEK', + $7, 0, $7, 0, $7, 'standard_25', false, false)`, + [id, params.userId, params.companyId, supplierId, arrivalNumber, `LF-${arrivalNumber}`, total], + ) + return id +} + +const INSERT_INVOICE_PAYMENT = ` + INSERT INTO public.invoice_payments + (user_id, company_id, invoice_id, payment_date, amount, currency) + VALUES ($1, $2, $3, '2026-05-05', 100, 'SEK') + RETURNING id` + +const INSERT_SUPPLIER_PAYMENT = ` + INSERT INTO public.supplier_invoice_payments + (user_id, company_id, supplier_invoice_id, payment_date, amount, currency) + VALUES ($1, $2, $3, '2026-05-05', 100, 'SEK') + RETURNING id` + +describe('invoice_payments — company-consistency trigger', () => { + it('accepts a payment whose company_id matches its invoice', async () => { + const a = await seedCompany() + const invoiceId = await seedCustomerInvoice({ userId: a.userId, companyId: a.companyId }) + + const res = await getPool().query(INSERT_INVOICE_PAYMENT, [a.userId, a.companyId, invoiceId]) + expect(res.rows).toHaveLength(1) + expect(res.rows[0].id).toBeTruthy() + }) + + it('rejects a payment whose company_id is a different tenant than its invoice', async () => { + const a = await seedCompany() + const b = await seedCompany() + const invoiceId = await seedCustomerInvoice({ userId: a.userId, companyId: a.companyId }) + + // company_id = B but the invoice belongs to A → trigger must raise. + await expect( + getPool().query(INSERT_INVOICE_PAYMENT, [b.userId, b.companyId, invoiceId]), + ).rejects.toThrow(/does not match invoices\.company_id/i) + + // Nothing persisted. + const rows = await getPool().query( + `SELECT id FROM public.invoice_payments WHERE invoice_id = $1`, + [invoiceId], + ) + expect(rows.rows).toHaveLength(0) + }) + + it('rejects an UPDATE that points company_id at a foreign tenant', async () => { + const a = await seedCompany() + const b = await seedCompany() + const invoiceId = await seedCustomerInvoice({ userId: a.userId, companyId: a.companyId }) + const ins = await getPool().query(INSERT_INVOICE_PAYMENT, [a.userId, a.companyId, invoiceId]) + const paymentId = ins.rows[0].id as string + + await expect( + getPool().query(`UPDATE public.invoice_payments SET company_id = $1 WHERE id = $2`, [ + b.companyId, + paymentId, + ]), + ).rejects.toThrow(/does not match invoices\.company_id/i) + }) + + it('rejects rerouting invoice_id to a foreign tenant invoice (UPDATE OF invoice_id path)', async () => { + const a = await seedCompany() + const b = await seedCompany() + const invoiceA = await seedCustomerInvoice({ userId: a.userId, companyId: a.companyId }) + const invoiceB = await seedCustomerInvoice({ userId: b.userId, companyId: b.companyId }) + const ins = await getPool().query(INSERT_INVOICE_PAYMENT, [a.userId, a.companyId, invoiceA]) + const paymentId = ins.rows[0].id as string + + // company_id stays A; only the parent FK is rerouted to B's invoice — + // exercises the UPDATE OF invoice_id leg of the trigger column filter. + await expect( + getPool().query(`UPDATE public.invoice_payments SET invoice_id = $1 WHERE id = $2`, [ + invoiceB, + paymentId, + ]), + ).rejects.toThrow(/does not match invoices\.company_id/i) + }) +}) + +describe('supplier_invoice_payments — company-consistency trigger', () => { + it('accepts a payment whose company_id matches its supplier invoice', async () => { + const a = await seedCompany() + const supplierInvoiceId = await seedSupplierInvoice({ userId: a.userId, companyId: a.companyId }) + + const res = await getPool().query(INSERT_SUPPLIER_PAYMENT, [a.userId, a.companyId, supplierInvoiceId]) + expect(res.rows).toHaveLength(1) + expect(res.rows[0].id).toBeTruthy() + }) + + it('rejects a payment whose company_id is a different tenant than its supplier invoice', async () => { + const a = await seedCompany() + const b = await seedCompany() + const supplierInvoiceId = await seedSupplierInvoice({ userId: a.userId, companyId: a.companyId }) + + await expect( + getPool().query(INSERT_SUPPLIER_PAYMENT, [b.userId, b.companyId, supplierInvoiceId]), + ).rejects.toThrow(/does not match supplier_invoices\.company_id/i) + + const rows = await getPool().query( + `SELECT id FROM public.supplier_invoice_payments WHERE supplier_invoice_id = $1`, + [supplierInvoiceId], + ) + expect(rows.rows).toHaveLength(0) + }) + + it('rejects an UPDATE that points company_id at a foreign tenant', async () => { + const a = await seedCompany() + const b = await seedCompany() + const supplierInvoiceId = await seedSupplierInvoice({ userId: a.userId, companyId: a.companyId }) + const ins = await getPool().query(INSERT_SUPPLIER_PAYMENT, [a.userId, a.companyId, supplierInvoiceId]) + const paymentId = ins.rows[0].id as string + + await expect( + getPool().query(`UPDATE public.supplier_invoice_payments SET company_id = $1 WHERE id = $2`, [ + b.companyId, + paymentId, + ]), + ).rejects.toThrow(/does not match supplier_invoices\.company_id/i) + }) + + it('rejects rerouting supplier_invoice_id to a foreign tenant invoice (UPDATE OF supplier_invoice_id path)', async () => { + const a = await seedCompany() + const b = await seedCompany() + const siA = await seedSupplierInvoice({ userId: a.userId, companyId: a.companyId }) + const siB = await seedSupplierInvoice({ userId: b.userId, companyId: b.companyId }) + const ins = await getPool().query(INSERT_SUPPLIER_PAYMENT, [a.userId, a.companyId, siA]) + const paymentId = ins.rows[0].id as string + + await expect( + getPool().query( + `UPDATE public.supplier_invoice_payments SET supplier_invoice_id = $1 WHERE id = $2`, + [siB, paymentId], + ), + ).rejects.toThrow(/does not match supplier_invoices\.company_id/i) + }) +}) diff --git a/tests/pg/securitydefiner_write_rpc_tenant_guards.pg.test.ts b/tests/pg/securitydefiner_write_rpc_tenant_guards.pg.test.ts new file mode 100644 index 00000000..537b21b9 --- /dev/null +++ b/tests/pg/securitydefiner_write_rpc_tenant_guards.pg.test.ts @@ -0,0 +1,256 @@ +/** + * pg-real test for the SECURITY DEFINER write-RPC tenant guards + * (20260619130100_securitydefiner_write_rpc_tenant_guards.sql). + * + * Four SECURITY DEFINER write RPCs are EXECUTE-able by `authenticated` and so, + * without an in-function tenant guard, an authenticated user could call them via + * PostgREST with ANOTHER company's p_company_id. The migration adds the canonical + * claims-based guard (mirrors 20260615120000_link_voucher_rpcs_tenant_guard.sql): + * for anon/authenticated callers, membership of p_company_id is required, else + * RAISE 42501; service_role / no-claims callers bypass BY DESIGN (MCP / API-key / + * migration / pg-harness paths whose company scoping happens elsewhere). + * + * bulk_book_transactions and match_batch_allocate are deliberately NOT guarded: + * they already enforce membership in-function and return structured domain + * errors (BULK_BOOK_UNAUTHORIZED / BATCH_UNAUTHORIZED) that routes, MCP tools, + * and their existing pg tests branch on — see the migration header. + * + * What each case asserts: + * - cross-tenant (userA's session, companyB's id) → RAISE with SQLSTATE 42501. + * - own company (userA's session, companyA's id) → the guard does NOT fire; + * the call either succeeds or fails with a NON-42501 domain error. For the + * two RPCs with no other gate (reserve/release_voucher_range) and for + * rotate_company_inbox the own-company call fully succeeds; for the others a + * non-guard outcome is sufficient and is documented inline. + * - no-claims bare-pool cross-tenant → guard bypassed (no 42501), proving the + * MCP / service-role paths are unaffected. + * + * The role-claim simulation technique (set request.jwt.claims + SET LOCAL ROLE) + * follows tests/pg/gl_lines_rpc_tenant_guard.pg.test.ts. + */ +import { describe, it, expect } from 'vitest' +import { randomUUID } from 'node:crypto' +import { getPool } from './setup' +import { insertDraftJournalEntry, seedCompany } from './fixtures' + +interface PgError extends Error { + code?: string +} + +/** + * Run `sql` as an authenticated user session (request.jwt.claims role = + * authenticated + SET LOCAL ROLE authenticated) in its own transaction, always + * rolling back. Returns the thrown PgError (or null if it succeeded). A 42501 + * guard rejection aborts the transaction, so each probe gets a fresh one. + */ +async function callAsUser( + userId: string, + sql: string, + params: unknown[], +): Promise { + const client = await getPool().connect() + try { + await client.query('BEGIN') + await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [ + JSON.stringify({ sub: userId, role: 'authenticated' }), + ]) + await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId]) + await client.query('SET LOCAL ROLE authenticated') + await client.query(sql, params) + return null + } catch (err) { + return err as PgError + } finally { + await client.query('ROLLBACK').catch(() => {}) + client.release() + } +} + +/** + * Run `sql` on the bare superuser pool with NO request.jwt.claims — the trusted + * bypass that migrations, this harness and the service-role / MCP API paths rely + * on. Wrapped in a rolled-back transaction so writes don't persist. Returns the + * thrown PgError or null. + */ +async function callBare(sql: string, params: unknown[]): Promise { + const client = await getPool().connect() + try { + await client.query('BEGIN') + await client.query(sql, params) + return null + } catch (err) { + return err as PgError + } finally { + await client.query('ROLLBACK').catch(() => {}) + client.release() + } +} + +// Posted bank-account IB usable by mark_entry_as_opening_balance. +async function insertPostedManualIb(params: { + userId: string + companyId: string + fiscalPeriodId: string +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES ($1, $2, $3, $4, $5, 'A', '2026-01-01', 'Ingående balanser 2026', 'manual', 'draft')`, + [id, params.userId, params.companyId, params.fiscalPeriodId, Math.floor(Math.random() * 100000) + 1], + ) + await getPool().query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', 5000, 0), + ($1, '2099', 0, 5000)`, + [id], + ) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [id]) + return id +} + +const MARK_OB = `SELECT public.mark_entry_as_opening_balance($1, $2)` +const RESERVE = `SELECT public.reserve_voucher_range($1, $2, $3, $4)` +const RELEASE = `SELECT public.release_voucher_range($1, $2, $3, $4, $5)` +const ROTATE = `SELECT public.rotate_company_inbox($1)` + +describe('SECURITY DEFINER write RPCs — tenant-isolation guard', () => { + it('mark_entry_as_opening_balance: blocks cross-company, passes own, bypasses for no-claims', async () => { + const a = await seedCompany() + const b = await seedCompany() + const entryA = await insertPostedManualIb({ + userId: a.userId, + companyId: a.companyId, + fiscalPeriodId: a.fiscalPeriodId, + }) + + // userA (member of A only) targeting companyB → 42501 before any work. + const cross = await callAsUser(a.userId, MARK_OB, [b.companyId, entryA]) + expect(cross?.code).toBe('42501') + + // Own company, owner of A, valid posted manual IB → full success (no raise). + const own = await callAsUser(a.userId, MARK_OB, [a.companyId, entryA]) + expect(own).toBeNull() + + // No-claims bare pool cross-referencing companyB with A's entry: guard + // bypassed. It then raises a NON-guard domain error ("Journal entry not + // found" — the entry is not in companyB), proving the bypass is real. + const bare = await callBare(MARK_OB, [b.companyId, entryA]) + expect(bare?.code).not.toBe('42501') + }) + + it('reserve_voucher_range: blocks cross-company, passes own, bypasses for no-claims', async () => { + const a = await seedCompany() + const b = await seedCompany() + + const cross = await callAsUser(a.userId, RESERVE, [b.companyId, b.fiscalPeriodId, 'A', 10]) + expect(cross?.code).toBe('42501') + + // Own company → succeeds (void). No other gate exists on this RPC, so this + // is the cleanest proof the guard does not break the legitimate path. + const own = await callAsUser(a.userId, RESERVE, [a.companyId, a.fiscalPeriodId, 'A', 10]) + expect(own).toBeNull() + + // No-claims bare pool cross-tenant → the new tenant guard is bypassed. (The + // INSERT then writes auth.uid()=NULL into voucher_sequences.user_id, which is + // NOT NULL, so a 23502 surfaces — pre-existing behaviour for a true no-session + // caller; the point here is only that it is NOT the 42501 tenant guard.) + const bare = await callBare(RESERVE, [b.companyId, b.fiscalPeriodId, 'A', 10]) + expect(bare?.code).not.toBe('42501') + }) + + it('release_voucher_range: blocks cross-company, passes own, bypasses for no-claims', async () => { + const a = await seedCompany() + const b = await seedCompany() + + const cross = await callAsUser(a.userId, RELEASE, [b.companyId, b.fiscalPeriodId, 'A', 5, 10]) + expect(cross?.code).toBe('42501') + + // Own company → succeeds (void no-op against an empty sequence). + const own = await callAsUser(a.userId, RELEASE, [a.companyId, a.fiscalPeriodId, 'A', 5, 10]) + expect(own).toBeNull() + + const bare = await callBare(RELEASE, [b.companyId, b.fiscalPeriodId, 'A', 5, 10]) + expect(bare).toBeNull() + }) + + it('rotate_company_inbox: blocks cross-company, passes own, bypasses for no-claims', async () => { + const a = await seedCompany() + const b = await seedCompany() + + const cross = await callAsUser(a.userId, ROTATE, [b.companyId]) + expect(cross?.code).toBe('42501') + + // Own company, owner of A → succeeds (creates an active inbox row). + const own = await callAsUser(a.userId, ROTATE, [a.companyId]) + expect(own).toBeNull() + + // No-claims bare pool cross-tenant → the NEW claims-based tenant guard is + // bypassed (role is not anon/authenticated). rotate_company_inbox is only + // ever called from a user session (auth.uid() present), so unlike the other + // five it has no service-role caller; the pre-existing owner/admin check + // (auth.uid() NULL → no membership) still raises 42501 here. Disambiguate by + // message: the bypass is proven by the new guard's message NOT appearing. + const bare = await callBare(ROTATE, [b.companyId]) + expect(bare?.message ?? '').not.toMatch(/caller is not a member of company/i) + }) +}) + +describe('voucher-range RPCs — period-lock + sequence-integrity guards (BFL 5 kap)', () => { + it('reserve_voucher_range refuses a closed fiscal period', async () => { + const a = await seedCompany({ isClosed: true }) + const err = await callBare(RESERVE, [a.companyId, a.fiscalPeriodId, 'A', 10]) + expect(err?.message).toMatch(/closed\/locked fiscal period/i) + }) + + it('reserve_voucher_range refuses a locked fiscal period', async () => { + const a = await seedCompany() + await getPool().query(`UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`, [ + a.fiscalPeriodId, + ]) + const err = await callBare(RESERVE, [a.companyId, a.fiscalPeriodId, 'A', 10]) + expect(err?.message).toMatch(/closed\/locked fiscal period/i) + }) + + it('release_voucher_range refuses when verifikat exist in the released range', async () => { + const a = await seedCompany() + await insertDraftJournalEntry({ + userId: a.userId, + companyId: a.companyId, + fiscalPeriodId: a.fiscalPeriodId, + status: 'posted', + voucherNumber: 5, // inside (3, 10] — rolling back to 3 would orphan it + }) + const err = await callBare(RELEASE, [a.companyId, a.fiscalPeriodId, 'A', 3, 10]) + expect(err?.message).toMatch(/verifikat exist in the released range/i) + }) + + it('release_voucher_range succeeds when the released range is empty (legit SIE-import path)', async () => { + const a = await seedCompany() + await getPool().query( + `INSERT INTO public.voucher_sequences (company_id, user_id, fiscal_period_id, voucher_series, last_number) + VALUES ($1, $2, $3, 'A', 10)`, + [a.companyId, a.userId, a.fiscalPeriodId], + ) + // Highest inserted verifikat is 3 — numbers (3, 10] were reserved but unused. + await insertDraftJournalEntry({ + userId: a.userId, + companyId: a.companyId, + fiscalPeriodId: a.fiscalPeriodId, + status: 'posted', + voucherNumber: 3, + }) + // Direct pool call (NOT callBare, which wraps in BEGIN…ROLLBACK and would + // undo the release before the assertion below reads the sequence). + await getPool().query(RELEASE, [a.companyId, a.fiscalPeriodId, 'A', 3, 10]) + + const { rows } = await getPool().query( + `SELECT last_number FROM public.voucher_sequences + WHERE company_id = $1 AND fiscal_period_id = $2 AND voucher_series = 'A'`, + [a.companyId, a.fiscalPeriodId], + ) + expect(rows[0]?.last_number).toBe(3) + }) +})