diff --git a/DECISIONS.md b/DECISIONS.md index a895b635..01048c7f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -910,3 +910,5 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/docs/TEMPLATE_MISBOOKING_REMEDIATION.md b/docs/TEMPLATE_MISBOOKING_REMEDIATION.md new file mode 100644 index 00000000..5156262a --- /dev/null +++ b/docs/TEMPLATE_MISBOOKING_REMEDIATION.md @@ -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. diff --git a/scripts/audit-template-misbookings.probe.sql b/scripts/audit-template-misbookings.probe.sql new file mode 100644 index 00000000..b2ad75c3 --- /dev/null +++ b/scripts/audit-template-misbookings.probe.sql @@ -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; diff --git a/scripts/audit-template-misbookings.sql b/scripts/audit-template-misbookings.sql new file mode 100644 index 00000000..6772ffa3 --- /dev/null +++ b/scripts/audit-template-misbookings.sql @@ -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;