* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances
Two related fixes to bank reconciliation correctness:
1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
an existing voucher previously advanced only the invoice — the bank
transaction that paid it kept sitting in the Transactions inbox with a null
journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
links the bank transaction to the same verifikat when exactly one unbooked
line matches it. Best-effort and post-commit: a failure here never fails the
link. The result surfaces reconciledTransactionId; the inbox row leaves the
list and the UI shows link_success_tx_reconciled.
2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
matching RPCs identify a cash account's ingående balans solely by
journal_entries.source_type='opening_balance'. Companies migrated from other
systems often booked the bank IB as an ordinary voucher (source_type
'import' or 'manual'), so it was never excluded and surfaced as a phantom
reconciliation difference equal to the opening balance. Adds:
- migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
immutability trigger plus a SECURITY DEFINER RPC that validates the entry
(balance-sheet lines only, dated on a fiscal-period boundary), flips the
source_type, and writes an audit row — no blanket data sweep.
- POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
- BankReconciliationView action to trigger it from the IB diff.
The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.
Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: rebrand gnubok → Accounted and prune swarm agent skills
Product rebrand and skills housekeeping. No runtime behaviour change.
Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).
Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
247 lines
11 KiB
PL/PgSQL
247 lines
11 KiB
PL/PgSQL
-- Re-tag a mis-typed bank-account opening balance (source_type 'manual'/'import')
|
|
-- as 'opening_balance' so bank reconciliation excludes it from period movement.
|
|
--
|
|
-- Bug context: getReconciliationStatus (lib/reconciliation/bank-reconciliation.ts)
|
|
-- and the get_unlinked_gl_lines / get_account_gl_lines_for_matching RPCs identify
|
|
-- the ingående balans (IB) on a cash account SOLELY by journal_entries.source_type
|
|
-- = 'opening_balance'. Companies migrated from another system frequently booked
|
|
-- the bank-account IB as an ordinary voucher, so it lands as source_type 'import'
|
|
-- (SIE #VER) or 'manual'. Such an IB is never excluded, so it surfaces as a phantom
|
|
-- reconciliation difference equal to the opening balance — reported by users as
|
|
-- "1940 räknar med IB som en diff" while 1930 (whose IB happened to be typed
|
|
-- opening_balance) reconciles cleanly. There is no mid-period opening_balance: a
|
|
-- genuine IB always sits on a fiscal-period boundary on a balance-sheet account.
|
|
--
|
|
-- This migration adds a controlled, audited way to fix such an entry WITHOUT a
|
|
-- blanket data sweep:
|
|
-- 1. A GUC-gated carve-out in enforce_journal_entry_immutability that permits a
|
|
-- source_type-only change manual/import -> opening_balance on a posted entry.
|
|
-- 2. A SECURITY DEFINER RPC mark_entry_as_opening_balance() that validates the
|
|
-- entry, sets the bypass flag, performs the UPDATE, and writes an audit row.
|
|
--
|
|
-- Legal basis for the carve-out (mirrors the `notes` carve-out from
|
|
-- 20260608120000_allow_journal_entry_notes_edit.sql): source_type is an INTERNAL
|
|
-- classification tag, NOT verifikation content under BFL 5 kap. / BFNAR 2013:2.
|
|
-- The re-tag leaves every bookkeeping field untouched — entry_date, description,
|
|
-- accounts and amounts (the lines), voucher_series, voucher_number, status,
|
|
-- source_id — so it does not alter the bokföringspost. The change is verified with
|
|
-- a whole-row to_jsonb() diff: any other field delta still raises. The immutability
|
|
-- guarantee for the verifikation itself is fully preserved; migration 017's
|
|
-- protections are EXTENDED, never weakened.
|
|
--
|
|
-- Scope / safety notes:
|
|
-- * Period lock is unchanged. The RPC explicitly refuses on a closed/locked
|
|
-- period (mirroring delete_last_voucher); enforce_period_lock remains the
|
|
-- backstop. IBs in a closed year must have the period reopened first.
|
|
-- * opening_balance_entry_id is deliberately NOT repointed here. SIE export keys
|
|
-- #IB off that column and does not exclude the OB entry from #VER, so repointing
|
|
-- could double-count. We only change source_type.
|
|
-- * Downstream effect to be aware of: undo_sie_import hard-deletes source_type
|
|
-- IN ('import','opening_balance') in a period. A 'manual' entry re-tagged here
|
|
-- therefore becomes deletable by a later Undo SIE Import — acceptable, since a
|
|
-- genuine IB belongs with the import-era opening position.
|
|
|
|
-- =============================================================================
|
|
-- 1. Extend enforce_journal_entry_immutability with a source_type retag carve-out
|
|
-- (CREATE OR REPLACE — reproduces the current body from
|
|
-- 20260608120000_allow_journal_entry_notes_edit.sql verbatim, then adds the
|
|
-- new GUC-gated branch before the final RAISE).
|
|
-- =============================================================================
|
|
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SET search_path = public
|
|
AS $function$
|
|
BEGIN
|
|
IF TG_OP = 'DELETE' THEN
|
|
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
|
|
RETURN OLD;
|
|
END IF;
|
|
RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.',
|
|
OLD.id, OLD.status;
|
|
END IF;
|
|
|
|
IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
|
|
IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN
|
|
IF NEW.status = 'reversed' THEN
|
|
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
|
|
OR NEW.fiscal_period_id != OLD.fiscal_period_id
|
|
OR NEW.voucher_number != OLD.voucher_number
|
|
OR NEW.commit_method IS DISTINCT FROM OLD.commit_method
|
|
OR NEW.rubric_version IS DISTINCT FROM OLD.rubric_version
|
|
OR NEW.source_voucher_series IS DISTINCT FROM OLD.source_voucher_series
|
|
OR NEW.source_voucher_number IS DISTINCT FROM OLD.source_voucher_number THEN
|
|
RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
|
|
END IF;
|
|
END IF;
|
|
RETURN NEW;
|
|
END IF;
|
|
|
|
-- Narrow un-reversal path: when delete_last_voucher removes a storno entry,
|
|
-- it flips the original from 'reversed' back to 'posted'. No other fields
|
|
-- may change, and the bypass flag must be set.
|
|
IF OLD.status = 'reversed' AND NEW.status = 'posted'
|
|
AND current_setting('gnubok.allow_delete', true) = 'true' THEN
|
|
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
|
|
OR NEW.fiscal_period_id != OLD.fiscal_period_id
|
|
OR NEW.voucher_number != OLD.voucher_number THEN
|
|
RAISE EXCEPTION 'Cannot modify fields during un-reversal (id: %)', OLD.id;
|
|
END IF;
|
|
RETURN NEW;
|
|
END IF;
|
|
|
|
-- Notes-only annotation on a committed entry (posted/reversed/cancelled).
|
|
-- `notes` is internal metadata, not verifikation content, so editing it does
|
|
-- not violate immutability. Allowed ONLY when the status is unchanged and the
|
|
-- sole difference between OLD and NEW is `notes` (updated_at is exempt because
|
|
-- the journal_entries_updated_at trigger bumps it). The to_jsonb() diff covers
|
|
-- every other column automatically, so any real bookkeeping change still raises.
|
|
IF OLD.status = NEW.status
|
|
AND OLD.status IN ('posted', 'reversed', 'cancelled')
|
|
AND (to_jsonb(NEW) - 'notes' - 'updated_at')
|
|
= (to_jsonb(OLD) - 'notes' - 'updated_at') THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
|
|
-- Source-type re-tag of a mis-typed opening balance. source_type is internal
|
|
-- classification metadata, not verifikation content (see header), so moving a
|
|
-- bank-account IB from manual/import to opening_balance does not alter the
|
|
-- bokföringspost. Allowed ONLY when: the transaction-local bypass flag set by
|
|
-- mark_entry_as_opening_balance() is present; status is unchanged 'posted'; the
|
|
-- value moves manual/import -> opening_balance; and source_type is the SOLE
|
|
-- changed column (whole-row to_jsonb diff, updated_at exempt as above). Any other
|
|
-- field delta, status change, or missing flag still raises below.
|
|
IF OLD.status = NEW.status
|
|
AND OLD.status = 'posted'
|
|
AND current_setting('gnubok.allow_source_type_retag', true) = 'true'
|
|
AND OLD.source_type IN ('manual', 'import')
|
|
AND NEW.source_type = 'opening_balance'
|
|
AND (to_jsonb(NEW) - 'source_type' - 'updated_at')
|
|
= (to_jsonb(OLD) - 'source_type' - 'updated_at') THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
|
|
RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.',
|
|
OLD.status, OLD.id;
|
|
END;
|
|
$function$;
|
|
|
|
-- =============================================================================
|
|
-- 2. mark_entry_as_opening_balance() — controlled, audited re-tag RPC
|
|
-- =============================================================================
|
|
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;
|
|
BEGIN
|
|
-- 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$;
|
|
|
|
-- Company-scoped write RPC: never executable by anon/PUBLIC. Authenticated callers
|
|
-- pass the tenancy + role gate inside the function. (Mirrors the gl_lines and
|
|
-- bulk_book RPC lockdown pattern.)
|
|
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;
|
|
|
|
NOTIFY pgrst, 'reload schema';
|