docs(bookkeeping): audit + runbook for template-caused mis-bookings (#1398)

* docs(bookkeeping): audit + runbook for template-caused mis-bookings

Two of the template defects fixed this week produced postings that SUCCEEDED
and are still sitting in customers' huvudbocker: travel_hotel debited 5820
Hyrbilskostnader instead of 5830 Kost och logi (#1397), and the representation
template deducted 25% input VAT on a 12% restaurang supply (#1396). Fixing a
template only changes future postings.

Follows the pattern already established by SETTLEMENT_ACCOUNT_REMEDIATION.md
for the same class of problem: read-only detection, per-entry evidence review,
staged storno with explicit approval, no automated bulk mutation.

Deliberately excludes vehicle_parking (5614) and it_cloud_hosting (5421). Those
named accounts that never existed in BAS, so account-backfill could not seed
them and every booking failed. Nothing was posted, nothing to remediate.

Detection is by account signature and is diagnostic only, because there is no
provenance link from a posted entry back to the template that produced it:
template_id lives on mapping_rules, not on journal entries. Both signatures
have legitimate shapes (5820 IS correct for real car hire; representation at
25% IS lawful when the supplier charged 25%), so a row is a question and never
a verdict.

The classifier is verified against seeded probes rather than assumed: a hotel
booked to 5820 with a hotel counterparty ranks high, a genuine car hire on 5820
falls to manual review, a 25% representation ranks high, and a correct 12%
representation does not appear at all. Query confirmed to run against the real
schema (the lock date lives on company_settings, not companies).

The runbook records what BFL 5 kap 5 § actually requires: both tracks, that
storno is the only one available once a period is locked or the bookkeeping has
been relied upon, and that there is NO numeric materiality threshold in BFL.
Materiality decides whether a historical correction is worth making, never
whether a silent one is allowed. For the VAT defect it also flags that a filed
momsdeklaration makes this an omprovning question, not just a ledger one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(bookkeeping): harden template misbooking audit

* fix(bookkeeping): retain mixed voucher audit candidates

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
Jakob Wennberg
2026-08-13 15:30:20 +02:00
committed by GitHub
co-authored by Claude Opus 5 Jakob Wennberg Emil
parent 7ae3477b36
commit a82f126031
4 changed files with 483 additions and 0 deletions
+2
View File
@@ -910,3 +910,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-13] Booking duplicate guard widened (sibling window +-3 days with exact-date-first total order, is_transaction_booked semantics, ledger twin de-exclusion) and the dialog offers Matcha/Ignorera for sibling candidates; renaming the "Att bokfora" tab is deferred to a founder call.
[2026-08-13] SIE mojibake: prevention only in code (dead gateway SIE path deleted, CP1252-artifact tripwire that warns and never blocks); repairing affected companies' posted entries is a founder decision (rattelse RPC vs re-import with replace), not automated.
[2026-08-13] Agent float panel: contain-on-open/resize via containFloatRect; clampFloatRect and the mid-session hang-off-the-edge allowance deliberately untouched.
[2026-08-13] Issue #1408 audit aggregates 5820, 6072, and 2641 lines per journal entry and returns line-id arrays: individual line pairing creates false 25 percent matches on multi-line vouchers, while journal lines have no vat_rate field and tax_code is only non-operative metadata.
[2026-08-13] Issue #1408 audit aggregates 5820, 6072, and 2641 lines per journal entry and returns line-id arrays: individual line pairing creates false 25 percent matches on multi-line vouchers, while dropping non-exact 6072 plus 2641 ratios misses defective representation hidden in mixed vouchers, so those remain as lower-priority manual review. Journal lines have no vat_rate field and tax_code is only non-operative metadata.
+148
View File
@@ -0,0 +1,148 @@
# Template mis-booking remediation
Use this runbook to review posted entries that a defective booking template may
have mis-classified, and to correct the confirmed ones lawfully.
Companion to [`SETTLEMENT_ACCOUNT_REMEDIATION.md`](./SETTLEMENT_ACCOUNT_REMEDIATION.md),
which covers the same class of problem for settlement legs. The invariants and
the correction mechanics are identical; only the detection differs.
## Which defects this covers
Two templates produced postings that **succeeded** and are therefore sitting in
customers' huvudböcker today:
| Defect | Was | Should be | Fixed in |
|---|---|---|---|
| `travel_hotel` debited car hire | `5820` Hyrbilskostnader | `5830` Kost och logi | #1397 |
| Representation over-deducted VAT | 25% input VAT | 12% (restaurang/servering) | #1396 |
**Deliberately not covered:** `vehicle_parking` (`5614`) and `it_cloud_hosting`
(`5421`) named accounts that never existed in BAS 2026. `account-backfill.ts`
only seeds accounts present in `BAS_REFERENCE`, so those templates failed with
`AccountsNotInChartError` every time and **nothing was ever posted through
them**. There is nothing to remediate.
## What the law requires
BFL 5 kap 5 §: a rättelse must leave both the original and the correction
visible, and record when it was made and by whom. Silent overwriting is never
permitted. A manual book may use a readable strike-and-replace correction while
the bookkeeping is still open. This runbook is for Accounted's computerized
bookkeeping and supports only a **särskild rättelsepost**: storno plus a
correcting verifikation linked to the original through `gnubok_correct_entry`.
There is **no numeric materiality threshold in BFL**. Materiality governs
whether a historical correction is worth making, not whether the law permits a
silent one: it never does.
For the VAT defect specifically, if the affected period's momsdeklaration has
already been filed, an over-deducted input VAT means an **omprövning** to
Skatteverket, not merely a ledger correction. Establish that before touching
anything.
## Invariants
- Never edit or delete a posted journal entry.
- Correct a confirmed error with storno plus a replacement through
`gnubok_correct_entry`. Never `gnubok_reverse_journal_entry` alone: the
business event remains valid, only its classification is wrong.
- Do not write directly to `journal_entries` or `journal_entry_lines`.
- Any `effective_lock_status` other than `open` is a hard stop. The correction
path never bypasses a lock. Unlocking or reopening requires a separately
reviewed and explicitly approved workflow for the exact company and period.
If a declaration or closing has relied on the period, establish the required
omprövning or closing consequences before requesting that approval.
- Never run a production correction without explicit approval for the exact
company, vouchers and replacement lines.
- No automated bulk mutation. Ever.
## Detect candidates
Run [`scripts/audit-template-misbookings.sql`](../scripts/audit-template-misbookings.sql)
against the intended database. It is read-only, creates nothing, and returns
only posted entries.
**Output is diagnostic, never a verdict.** There is no provenance link from a
posted entry back to the template that produced it (`template_id` lives on
`mapping_rules`, not on journal entries), so candidates are matched by account
signature, and both signatures have legitimate shapes:
- `5820` is the **correct** account for actual car hire.
- Representation at 25% is lawful only when the supply is actually subject to
25% VAT and the supplier invoice is correct.
Read `review_priority` as an ordering aid only:
| Priority | Meaning |
|---|---|
| `high_hotel_counterparty_on_car_hire_account` | A `5820` line with a hotel-shaped counterparty. The strongest signal, still needs evidence. |
| `medium_12pct_vat_on_car_hire_account` | `5820` with 12% VAT beside it. Car hire is 25%, so this is suggestive. |
| `high_vat_is_25pct_of_6072_cost` | Representation whose input VAT is 25% of the cost leg. |
| `manual_review_5820_no_corroborating_signal` | A `5820` entry with no hotel-shaped text or 12% VAT ratio. Most likely legitimate car hire. |
| `manual_review_6072_with_vat` | A `6072` entry with `2641` VAT whose aggregate ratio is not exactly 25%. It may be correct or may be a mixed voucher masking the defective representation leg. |
The classifier is verified against read-only probes: a hotel booked to `5820`
ranks high, a genuine car hire on `5820` falls to manual review, and an exact
25% representation signature ranks high. Correct 12% representation and mixed
vouchers whose aggregate ratio is not 25% remain visible for manual review,
because unrelated `2641` lines can otherwise hide a defective representation
component. The representation probes include multiple `6072` and `2641` lines;
the audit aggregates them per entry and returns one candidate instead of a
many-to-many set of line pairs.
## Review each candidate
1. Open the underlag (kvitto/faktura) and confirm what was actually purchased.
A hotel night and a rental car are both plausible on `5820`.
2. For the VAT defect, classify the actual supply and determine the legally
applicable rate; do not accept the invoice rate as proof. Restaurant and
catering supplies are normally 12%, while alcohol and some mixed supplies
can include 25%. If the supplier charged an inapplicable rate, request a
corrected invoice before treating any input VAT as deductible or clearing
the candidate.
3. Fetch the entry and all lines. Stop if it was already reversed or corrected.
4. Check `effective_lock_status`. Anything other than `open` is a hard stop.
5. For the VAT defect, establish whether the period's momsdeklaration has been
filed. If so, an omprövning is in scope and the ledger correction alone is
not sufficient.
6. Decide materiality. A 40 kr VAT difference on one lunch two years ago in a
closed period is unlikely to warrant reopening anything; a systematic error
across a year is different. Record the decision either way.
Keep the reviewed set, the evidence, the proposed replacement lines and the
reviewer identity together as the correction record.
## Stage the correction
Follow the identical procedure in
[`SETTLEMENT_ACCOUNT_REMEDIATION.md`](./SETTLEMENT_ACCOUNT_REMEDIATION.md#stage-the-correction):
retain `original_lines`, copy every original line, change only what is wrong,
verify the replacement balances, stage
`gnubok_correct_entry`, and approve only with explicit authorisation.
For the hotel defect, the only change is `account_number` on the cost line,
`5820` to `5830`.
For the VAT defect, the cost and VAT amounts change while accounts `6072` and
`2641`, the gross total, and the settlement leg remain unchanged. Calculate the
deductible VAT from the corrected invoice and the representation rules,
including the 300 SEK base per person and occasion. Do not blindly divide the
gross by 1.12 when the receipt mixes rates or exceeds the deduction cap;
non-deductible VAT remains on `6072`.
Journal lines have no `vat_rate` field. `tax_code` is a free-text tag and does
not drive the VAT return, but if it explicitly encodes the obsolete 25% rate,
update or clear it so the corrected entry is not misleading. Preserve currency,
`amount_in_currency`, `exchange_rate`, descriptions, dimensions, cost centers,
projects, and unrelated tax metadata exactly as recorded.
## Verify after approval
1. The original is retained with status `reversed`.
2. A posted storno and a posted corrected entry exist in the intended period.
3. The corrected leg uses the expected account and amount.
4. Re-run the audit query: the corrected entry no longer appears.
5. For the VAT defect, re-run the momsdeklaration for the period and confirm
ruta 48 moves by the expected amount.
6. Record the new voucher references with the correction record.
@@ -0,0 +1,68 @@
-- Read-only classifier probes for scripts/audit-template-misbookings.sql.
-- Expected result: four rows, each with actual_result = expected_result.
with probe_lines(probe, account_number, debit_amount) as (
values
('hotel_on_5820', '5820', 1000.00::numeric),
('hotel_on_5820', '2641', 120.00::numeric),
('car_hire_on_5820', '5820', 1000.00::numeric),
('car_hire_on_5820', '2641', 250.00::numeric),
('representation_25_multi_line', '6072', 60.00::numeric),
('representation_25_multi_line', '6072', 40.00::numeric),
('representation_25_multi_line', '2641', 10.00::numeric),
('representation_25_multi_line', '2641', 15.00::numeric),
('representation_25_with_extra_vat', '6072', 100.00::numeric),
('representation_25_with_extra_vat', '2641', 25.00::numeric),
('representation_25_with_extra_vat', '2641', 20.00::numeric),
('representation_12', '6072', 100.00::numeric),
('representation_12', '2641', 12.00::numeric)
),
probe_context(probe, transaction_description, expected_result) as (
values
('hotel_on_5820', 'Scandic Stockholm', 'high_hotel_counterparty_on_car_hire_account'),
('car_hire_on_5820', 'Hertz rental car', 'manual_review_5820_no_corroborating_signal'),
('representation_25_multi_line', 'Customer dinner', 'high_vat_is_25pct_of_6072_cost'),
('representation_25_with_extra_vat', 'Mixed expense voucher', 'manual_review_6072_with_vat'),
('representation_12', 'Customer dinner', 'manual_review_6072_with_vat')
),
line_groups as (
select probe, account_number, sum(debit_amount) as debit_amount
from probe_lines
group by probe, account_number
),
classified as (
select
context.probe,
context.expected_result,
case
when hotel.debit_amount is not null
and context.transaction_description ~* '(hotel|hotell|scandic|elite|best western|nordic choice|clarion|quality inn|radisson|booking\.com|airbnb|logi|övernattning)'
then 'high_hotel_counterparty_on_car_hire_account'
when hotel.debit_amount is not null
and abs(coalesce(vat.debit_amount, 0) - hotel.debit_amount * 0.12) < 0.02
then 'medium_12pct_vat_on_car_hire_account'
when hotel.debit_amount is not null
then 'manual_review_5820_no_corroborating_signal'
when representation.debit_amount is not null
and abs(coalesce(vat.debit_amount, 0) - representation.debit_amount * 0.25) < 0.02
then 'high_vat_is_25pct_of_6072_cost'
when representation.debit_amount is not null and vat.debit_amount is not null
then 'manual_review_6072_with_vat'
else 'absent'
end as actual_result
from probe_context context
left join line_groups hotel
on hotel.probe = context.probe and hotel.account_number = '5820'
left join line_groups representation
on representation.probe = context.probe and representation.account_number = '6072'
left join line_groups vat
on vat.probe = context.probe and vat.account_number = '2641'
)
select
probe,
expected_result,
actual_result,
actual_result = expected_result as passed
from classified
order by probe;
+265
View File
@@ -0,0 +1,265 @@
-- Read-only audit for posted entries that a defective booking template may have
-- mis-classified. Covers the two defects fixed in #1396 and #1397 whose bad
-- postings succeeded and therefore may remain in customers' ledgers.
--
-- Not covered, deliberately: vehicle_parking (5614) and it_cloud_hosting (5421)
-- named accounts that never existed in BAS, so account-backfill could not seed
-- them and every booking through those templates failed. Nothing was posted.
--
-- This query is diagnostic, not a list of confirmed errors. There is no
-- provenance link from a posted entry back to the template that produced it.
-- Both signatures also have legitimate shapes: 5820 is correct for actual car
-- hire, and representation can lawfully carry 25% VAT when the supply itself is
-- subject to 25% and the invoice is correct. Review the underlag before acting.
--
-- The query performs no writes, creates no objects, and returns one row per
-- posted journal entry and defect. Companion procedure:
-- docs/TEMPLATE_MISBOOKING_REMEDIATION.md.
with debit_line_groups as (
-- Aggregate before classification so entries with several 6072 or 2641
-- lines are evaluated once. This avoids the previous many-to-many pairing.
select
jel.journal_entry_id,
jel.account_number,
array_agg(jel.id order by jel.sort_order, jel.id) as line_ids,
sum(jel.debit_amount) as debit_amount
from public.journal_entry_lines jel
where jel.account_number in ('5820', '6072', '2641')
and jel.debit_amount > 0
group by jel.journal_entry_id, jel.account_number
),
candidate_entry_ids as (
select distinct journal_entry_id
from debit_line_groups
where account_number in ('5820', '6072')
),
transaction_refs as (
select
t.journal_entry_id,
concat_ws(' ', nullif(t.merchant_name, ''), nullif(t.description, '')) as transaction_description
from public.transactions t
join candidate_entry_ids candidate on candidate.journal_entry_id = t.journal_entry_id
where t.journal_entry_id is not null
union all
select
tvl.journal_entry_id,
concat_ws(' ', nullif(t.merchant_name, ''), nullif(t.description, '')) as transaction_description
from public.transaction_voucher_links tvl
join candidate_entry_ids candidate on candidate.journal_entry_id = tvl.journal_entry_id
join public.transactions t on t.id = tvl.transaction_id
),
transaction_context as (
select
journal_entry_id,
string_agg(
distinct transaction_description,
' | ' order by transaction_description
) as transaction_description
from transaction_refs
group by journal_entry_id
),
hotel_candidates as (
-- The Hotell template debited 5820 (Hyrbilskostnader) instead of 5830
-- (Kost och logi) until #1397.
select
'travel_hotel_5820'::text as defect,
je.company_id,
je.id as journal_entry_id,
je.voucher_series,
je.voucher_number,
je.entry_date,
je.committed_at,
je.fiscal_period_id,
cost.line_ids as cost_line_ids,
coalesce(vat.line_ids, '{}'::uuid[]) as vat_line_ids,
'5820'::text as observed_cost_account,
'5830'::text as expected_cost_account,
case when vat.line_ids is null then null else '2641'::text end as observed_vat_account,
case when vat.line_ids is null then null else '2641'::text end as expected_vat_account,
cost.debit_amount as cost_debit_amount,
vat.debit_amount as vat_debit_amount,
round(vat.debit_amount / nullif(cost.debit_amount, 0), 6) as observed_vat_rate,
0.12::numeric as expected_vat_rate,
tx.transaction_description,
case
when tx.transaction_description ~* '(hotel|hotell|scandic|elite|best western|nordic choice|clarion|quality inn|radisson|booking\.com|airbnb|logi|övernattning)'
then 'high_hotel_counterparty_on_car_hire_account'
when abs(vat.debit_amount - cost.debit_amount * 0.12) < 0.02
then 'medium_12pct_vat_on_car_hire_account'
else 'manual_review_5820_no_corroborating_signal'
end::text as review_priority
from public.journal_entries je
join debit_line_groups cost
on cost.journal_entry_id = je.id and cost.account_number = '5820'
left join debit_line_groups vat
on vat.journal_entry_id = je.id and vat.account_number = '2641'
left join transaction_context tx on tx.journal_entry_id = je.id
where je.status = 'posted'
),
representation_candidates as (
-- The representation template deducted 25% input VAT on what was intended
-- to be a 12% restaurant supply until #1396. Totals are evaluated per entry,
-- never by pairing individual lines.
select
'representation_25pct_vat'::text as defect,
je.company_id,
je.id as journal_entry_id,
je.voucher_series,
je.voucher_number,
je.entry_date,
je.committed_at,
je.fiscal_period_id,
cost.line_ids as cost_line_ids,
vat.line_ids as vat_line_ids,
'6072'::text as observed_cost_account,
'6072'::text as expected_cost_account,
'2641'::text as observed_vat_account,
'2641'::text as expected_vat_account,
cost.debit_amount as cost_debit_amount,
vat.debit_amount as vat_debit_amount,
round(vat.debit_amount / nullif(cost.debit_amount, 0), 6) as observed_vat_rate,
0.12::numeric as expected_vat_rate,
tx.transaction_description,
case
when abs(vat.debit_amount - cost.debit_amount * 0.25) < 0.02
then 'high_vat_is_25pct_of_6072_cost'
else 'manual_review_6072_with_vat'
end::text as review_priority
from public.journal_entries je
join debit_line_groups cost
on cost.journal_entry_id = je.id and cost.account_number = '6072'
join debit_line_groups vat
on vat.journal_entry_id = je.id and vat.account_number = '2641'
left join transaction_context tx on tx.journal_entry_id = je.id
where je.status = 'posted'
),
all_candidates as (
select
defect,
company_id,
journal_entry_id,
voucher_series,
voucher_number,
entry_date,
committed_at,
fiscal_period_id,
cost_line_ids,
vat_line_ids,
observed_cost_account,
expected_cost_account,
observed_vat_account,
expected_vat_account,
cost_debit_amount,
vat_debit_amount,
observed_vat_rate,
expected_vat_rate,
transaction_description,
review_priority
from hotel_candidates
union all
select
defect,
company_id,
journal_entry_id,
voucher_series,
voucher_number,
entry_date,
committed_at,
fiscal_period_id,
cost_line_ids,
vat_line_ids,
observed_cost_account,
expected_cost_account,
observed_vat_account,
expected_vat_account,
cost_debit_amount,
vat_debit_amount,
observed_vat_rate,
expected_vat_rate,
transaction_description,
review_priority
from representation_candidates
),
entry_line_snapshots as (
-- Retain every field accepted by gnubok_correct_entry for candidate entries
-- only, without scanning and serializing the entire journal-line table.
select
jel.journal_entry_id,
jsonb_agg(
jsonb_strip_nulls(jsonb_build_object(
'account_number', jel.account_number,
'debit_amount', jel.debit_amount,
'credit_amount', jel.credit_amount,
'line_description', jel.line_description,
'currency', jel.currency,
'amount_in_currency', jel.amount_in_currency,
'exchange_rate', jel.exchange_rate,
'tax_code', jel.tax_code,
'dimensions', jel.dimensions
))
order by jel.sort_order, jel.id
) as original_lines
from public.journal_entry_lines jel
join (
select distinct journal_entry_id
from all_candidates
) candidate on candidate.journal_entry_id = jel.journal_entry_id
group by jel.journal_entry_id
)
select
c.defect,
c.company_id,
co.name as company_name,
c.journal_entry_id,
c.voucher_series,
c.voucher_number,
c.entry_date,
c.committed_at,
c.fiscal_period_id,
c.cost_line_ids,
c.vat_line_ids,
c.observed_cost_account,
c.expected_cost_account,
c.observed_vat_account,
c.expected_vat_account,
c.cost_debit_amount,
c.vat_debit_amount,
c.observed_vat_rate,
c.expected_vat_rate,
c.transaction_description,
c.review_priority,
lines.original_lines,
case
when fp.id is null then 'missing_fiscal_period'
when fp.is_closed then 'closed'
when fp.locked_at is not null then 'locked'
when cs.bookkeeping_locked_through is not null
and c.entry_date <= cs.bookkeeping_locked_through then 'behind_company_lock_date'
else 'open'
end as effective_lock_status
from all_candidates c
join public.companies co on co.id = c.company_id
join entry_line_snapshots lines on lines.journal_entry_id = c.journal_entry_id
left join public.company_settings cs on cs.company_id = c.company_id
left join public.fiscal_periods fp on fp.id = c.fiscal_period_id
order by
c.defect,
case
when c.review_priority like 'high%' then 0
when c.review_priority like 'medium%' then 1
else 2
end,
c.entry_date desc;