From e494662530cc833eaee9600e4994c0d43a2e59c4 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:58:09 +0200 Subject: [PATCH] fix(bookkeeping): classify template audit evidence (#1594) --- DECISIONS.md | 2 + docs/TEMPLATE_MISBOOKING_REMEDIATION.md | 84 +++-- scripts/audit-template-misbookings.probe.sql | 150 +++++--- scripts/audit-template-misbookings.sql | 351 +++++++++++++++++-- 4 files changed, 489 insertions(+), 98 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index b44f8790..bdc092f2 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -934,3 +934,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] Kontantmetoden year-end VAT supersedes the 2026-08-06 VAT-reporting premise: BAS 2618/2628/2638 and 2648 feed the final declaration, reverse-charge purchases include both VAT sides and their basis, and only the mechanical day-one reversal is excluded from later VAT periods. Skatteverket requires unpaid invoice VAT in the final period and warns against reporting it twice after year end. [2026-08-13] Per-account VAT treatment is class-aware and explicit values override the static BAS mapping; SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. [2026-08-13] Per-account VAT treatment is class-aware; explicit values extend custom accounts while canonical accounts keep their static BAS momsdeklaration mapping. SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. VMB carries no default account rate because its VAT base is the margin, not gross sales. +[2026-08-13] Issue #1408 separates evidence classification from lock and VAT overlays, and treats import and correction sources as false positives for live-template provenance: 1,316 of 1,361 production signature matches came from those sources, so a broad account signature must not become a correction queue. +[2026-08-13] Issue #1408 does not route locked candidates through the current correctEntry service or an unlock: the service creates its storno in the original period, so a compliant locked-period correction needs a separately tested open-period storno and replacement path that preserves the original and full rättelse trail. diff --git a/docs/TEMPLATE_MISBOOKING_REMEDIATION.md b/docs/TEMPLATE_MISBOOKING_REMEDIATION.md index 5156262a..62c54506 100644 --- a/docs/TEMPLATE_MISBOOKING_REMEDIATION.md +++ b/docs/TEMPLATE_MISBOOKING_REMEDIATION.md @@ -48,11 +48,13 @@ anything. `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. +- Any `effective_lock_status` other than `open` is a hard stop for the current + `gnubok_correct_entry` path. Never unlock, reopen or edit the original merely + to make a correction possible. A locked-period correction requires a + separately reviewed and tested path that records a storno and replacement in + an open period, references the original, and preserves who corrected it and + when. If a declaration or closing relied on the locked period, establish the + required omprövning or closing consequences before requesting approval. - Never run a production correction without explicit approval for the exact company, vouchers and replacement lines. - No automated bulk mutation. Ever. @@ -63,16 +65,41 @@ Run [`scripts/audit-template-misbookings.sql`](../scripts/audit-template-misbook 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: +The audit now separates the broad signature from the evidence classification. +There is no direct template id on a journal entry, but `source_type`, exact line +shape, transaction context and linked underlag can rule candidates in or out. +Both signatures still 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: +Read `evidence_classification` as the remediation queue: + +| Classification | Meaning | +|---|---| +| `confirmed_correction` | Exact defective shape plus corroborating evidence. Review the underlag, then stage `gnubok_correct_entry` and request explicit approval for the exact voucher and replacement lines. | +| `insufficient_evidence` | The shape matches, but the underlag or provenance is not decisive. Obtain evidence before proposing any write. | +| `false_positive` | Not attributable to either defective live template. This is not a general verdict that every aspect of the voucher is correct. | + +`source_type` values `import` and `correction` are false positives for this +template-remediation audit because neither live template generated them. Keep +any independent accounting concern outside this remediation batch. + +Linked underlag is resolved from direct journal-entry and journal-line links, +transactions and supplier invoices. Document identifiers and extracted VAT +signals are returned, but storage paths and file names are not. A decisive 25% +extraction on a representation voucher rules out the defective 12% restaurant +template signature. It does not by itself prove that the representation cap, +participant record or business purpose was handled correctly. + +Every representation row therefore carries +`independent_representation_review = participants_purpose_and_300_sek_vat_base_cap_required`. +`observed_6072_cost_exceeds_300_sek` is a triage signal only: the statutory cap +is per person and occasion, so the number of participants and the underlag are +still required. + +Read `review_priority` as a supporting ordering aid: | Priority | Meaning | |---|---| @@ -82,14 +109,25 @@ Read `review_priority` as an ordering aid only: | `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. +The classifier is verified against read-only probes for confirmed, insufficient +and false-positive outcomes. The audit aggregates multiple `6072` and `2641` +lines per entry and returns one candidate instead of a many-to-many set of line +pairs. Mixed representation vouchers without decisive underlag remain +`insufficient_evidence`; they are never cleared merely because their aggregate +VAT ratio differs from the exact defective 25% signature. + +Treat lock and VAT-return impact as overlays, not evidence classes: + +- `effective_lock_status` identifies candidates that the current correction + service cannot handle. Never unlock or reopen merely to permit a correction. +- `vat_return_impact = potential_if_confirmed` means a representation candidate + could affect ruta 48. Determine the filing status before requesting approval. +- `vat_reporting_period`, `vat_deadline_statuses` and `vat_filing_status` + surface the in-app filing record. `not_proven_by_in_app_records` is unknown, + not proof that no declaration was filed externally. +- Keep exact company and voucher identifiers in an access-controlled approval + manifest. Public issue updates should use aggregate batches unless disclosure + has been explicitly approved. ## Review each candidate @@ -107,8 +145,8 @@ many-to-many set of line pairs. 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. + closed period may not warrant a separate correction in an open period; 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. @@ -121,6 +159,12 @@ 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. +`gnubok_correct_entry` routes through `correctEntry()` in +`lib/core/bookkeeping/storno-service.ts`. It retains the original, creates a +posted storno with `reverses_id`, creates the replacement with +`correction_of_id`, and records the acting user and timestamps. Never replace +this audited envelope with direct journal-table writes. + For the hotel defect, the only change is `account_number` on the cost line, `5820` to `5830`. diff --git a/scripts/audit-template-misbookings.probe.sql b/scripts/audit-template-misbookings.probe.sql index b2ad75c3..232e0794 100644 --- a/scripts/audit-template-misbookings.probe.sql +++ b/scripts/audit-template-misbookings.probe.sql @@ -1,68 +1,106 @@ --- Read-only classifier probes for scripts/audit-template-misbookings.sql. --- Expected result: four rows, each with actual_result = expected_result. +-- Read-only evidence-classifier probes for audit-template-misbookings.sql. +-- Expected result: eight rows, each with passed = true. -with probe_lines(probe, account_number, debit_amount) as ( +with probes( + probe, + defect, + source_type, + review_priority, + line_count, + observed_vat_rate, + document_has_12pct_vat, + document_has_25pct_vat, + expected_classification +) 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 + ( + 'exact_hotel_template_shape', + 'travel_hotel_5820', 'manual', + 'high_hotel_counterparty_on_car_hire_account', + 3, 0.12::numeric, false, false, 'confirmed_correction' + ), + ( + 'car_hire_with_25pct_vat', + 'travel_hotel_5820', 'manual', + 'manual_review_5820_no_corroborating_signal', + 3, 0.25::numeric, false, false, 'false_positive' + ), + ( + 'hotel_shape_without_hotel_evidence', + 'travel_hotel_5820', 'manual', + 'medium_12pct_vat_on_car_hire_account', + 3, 0.12::numeric, false, false, 'insufficient_evidence' + ), + ( + 'imported_representation_signature', + 'representation_25pct_vat', 'import', + 'high_vat_is_25pct_of_6072_cost', + 3, 0.25::numeric, false, false, 'false_positive' + ), + ( + 'live_representation_without_underlag', + 'representation_25pct_vat', 'manual', + 'high_vat_is_25pct_of_6072_cost', + 3, 0.25::numeric, false, false, 'insufficient_evidence' + ), + ( + 'live_representation_with_25pct_underlag', + 'representation_25pct_vat', 'manual', + 'high_vat_is_25pct_of_6072_cost', + 3, 0.25::numeric, false, true, 'false_positive' + ), + ( + 'live_representation_with_12pct_vat', + 'representation_25pct_vat', 'manual', + 'manual_review_6072_with_vat', + 3, 0.12::numeric, true, false, 'false_positive' + ), + ( + 'mixed_representation_without_underlag', + 'representation_25pct_vat', 'manual', + 'manual_review_6072_with_vat', + 4, 0.18::numeric, false, false, 'insufficient_evidence' + ) ), classified as ( select - context.probe, - context.expected_result, + probe.*, 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' + when source_type in ('import', 'correction') then 'false_positive' + when defect = 'representation_25pct_vat' + and review_priority = 'high_vat_is_25pct_of_6072_cost' + and document_has_25pct_vat + and not document_has_12pct_vat + then 'false_positive' + when defect = 'representation_25pct_vat' + and review_priority = 'high_vat_is_25pct_of_6072_cost' + then 'insufficient_evidence' + when defect = 'representation_25pct_vat' + and observed_vat_rate between 0.119 and 0.121 + then 'false_positive' + when defect = 'representation_25pct_vat' + and document_has_12pct_vat + and not document_has_25pct_vat + then 'false_positive' + when defect = 'representation_25pct_vat' then 'insufficient_evidence' + when defect = 'travel_hotel_5820' + and line_count = 3 + and observed_vat_rate between 0.119 and 0.121 + and review_priority = 'high_hotel_counterparty_on_car_hire_account' + then 'confirmed_correction' + when defect = 'travel_hotel_5820' + and line_count = 3 + and observed_vat_rate between 0.119 and 0.121 + then 'insufficient_evidence' + else 'false_positive' + end as actual_classification + from probes probe ) select probe, - expected_result, - actual_result, - actual_result = expected_result as passed + expected_classification, + actual_classification, + actual_classification = expected_classification as passed from classified order by probe; diff --git a/scripts/audit-template-misbookings.sql b/scripts/audit-template-misbookings.sql index 6772ffa3..bfcbb79c 100644 --- a/scripts/audit-template-misbookings.sql +++ b/scripts/audit-template-misbookings.sql @@ -6,11 +6,12 @@ -- 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 separates template-remediation evidence from broad account +-- signatures. Imported entries and earlier corrections cannot have been +-- generated by either live template in this database. The remaining signatures +-- can still 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: @@ -36,9 +37,11 @@ candidate_entry_ids as ( where account_number in ('5820', '6072') ), -transaction_refs as ( +candidate_transactions as ( select t.journal_entry_id, + t.id as transaction_id, + t.document_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 @@ -48,6 +51,8 @@ transaction_refs as ( select tvl.journal_entry_id, + t.id as transaction_id, + t.document_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 @@ -61,7 +66,7 @@ transaction_context as ( distinct transaction_description, ' | ' order by transaction_description ) as transaction_description - from transaction_refs + from candidate_transactions group by journal_entry_id ), @@ -77,6 +82,7 @@ hotel_candidates as ( je.entry_date, je.committed_at, je.fiscal_period_id, + je.source_type, cost.line_ids as cost_line_ids, coalesce(vat.line_ids, '{}'::uuid[]) as vat_line_ids, '5820'::text as observed_cost_account, @@ -117,6 +123,7 @@ representation_candidates as ( je.entry_date, je.committed_at, je.fiscal_period_id, + je.source_type, cost.line_ids as cost_line_ids, vat.line_ids as vat_line_ids, '6072'::text as observed_cost_account, @@ -152,6 +159,7 @@ all_candidates as ( entry_date, committed_at, fiscal_period_id, + source_type, cost_line_ids, vat_line_ids, observed_cost_account, @@ -177,6 +185,7 @@ all_candidates as ( entry_date, committed_at, fiscal_period_id, + source_type, cost_line_ids, vat_line_ids, observed_cost_account, @@ -217,18 +226,310 @@ entry_line_snapshots as ( from all_candidates ) candidate on candidate.journal_entry_id = jel.journal_entry_id group by jel.journal_entry_id +), + +candidate_documents as ( + -- Resolve every current underlag path that can legally support the entry: + -- direct entry or line links, transaction links, and supplier-invoice + -- references. Return document ids only, never storage paths or file names. + select distinct + c.journal_entry_id, + d.id as document_id, + d.extracted_data + from all_candidates c + join public.document_attachments d + on d.is_current_version = true + and ( + d.journal_entry_id = c.journal_entry_id + or exists ( + select 1 + from public.journal_entry_lines jel + where jel.journal_entry_id = c.journal_entry_id + and jel.id = d.journal_entry_line_id + ) + ) + + union + + select distinct + tx.journal_entry_id, + d.id as document_id, + d.extracted_data + from candidate_transactions tx + join public.document_attachments d + on d.id = tx.document_id + and d.is_current_version = true + + union + + select distinct + c.journal_entry_id, + d.id as document_id, + d.extracted_data + from all_candidates c + join public.supplier_invoices si + on si.registration_journal_entry_id = c.journal_entry_id + or si.payment_journal_entry_id = c.journal_entry_id + or exists ( + select 1 + from public.supplier_invoice_payments sip + where sip.supplier_invoice_id = si.id + and sip.journal_entry_id = c.journal_entry_id + ) + join public.document_attachments d + on d.id = si.document_id + and d.is_current_version = true +), + +candidate_document_vat_rates as ( + -- Extraction payloads are historical data and may have missing, malformed, + -- or differently scaled rate values. Accept only numeric values, normalizing + -- both 0.25 and 25 to percent before they reach the classifier. + select + cd.journal_entry_id, + cd.document_id, + case + when rate.raw_rate::numeric <= 1 then rate.raw_rate::numeric * 100 + else rate.raw_rate::numeric + end as normalized_rate + from candidate_documents cd + cross join lateral ( + select vat ->> 'rate' as raw_rate + from jsonb_array_elements( + case + when jsonb_typeof(cd.extracted_data -> 'vatBreakdown') = 'array' + then cd.extracted_data -> 'vatBreakdown' + else '[]'::jsonb + end + ) vat + + union all + + select item ->> 'vatRate' as raw_rate + from jsonb_array_elements( + case + when jsonb_typeof(cd.extracted_data -> 'lineItems') = 'array' + then cd.extracted_data -> 'lineItems' + else '[]'::jsonb + end + ) item + ) rate + where rate.raw_rate ~ '^\s*[0-9]+(?:\.[0-9]+)?\s*$' +), + +document_signals as ( + select + cd.journal_entry_id, + array_agg(distinct cd.document_id order by cd.document_id) as document_ids, + count(distinct cd.document_id)::integer as document_count, + count(distinct cd.document_id) filter ( + where cd.extracted_data is not null + )::integer as extracted_document_count, + max( + case + when cd.extracted_data ->> 'confidence' + ~ '^\s*[0-9]+(?:\.[0-9]+)?\s*$' + then (cd.extracted_data ->> 'confidence')::numeric + end + ) as max_extraction_confidence, + bool_or(rate.normalized_rate between 11.9 and 12.1) + as document_has_12pct_vat, + bool_or(rate.normalized_rate between 24.9 and 25.1) + as document_has_25pct_vat + from candidate_documents cd + left join candidate_document_vat_rates rate + on rate.journal_entry_id = cd.journal_entry_id + and rate.document_id = cd.document_id + group by cd.journal_entry_id +), + +candidate_output as ( + select + c.*, + co.name as company_name, + lines.original_lines, + jsonb_array_length(lines.original_lines) as line_count, + coalesce(doc.document_ids, '{}'::uuid[]) as document_ids, + coalesce(doc.document_count, 0) as document_count, + coalesce(doc.extracted_document_count, 0) as extracted_document_count, + doc.max_extraction_confidence, + coalesce(doc.document_has_12pct_vat, false) as document_has_12pct_vat, + coalesce(doc.document_has_25pct_vat, false) as document_has_25pct_vat, + case + when c.defect = 'representation_25pct_vat' + then 'participants_purpose_and_300_sek_vat_base_cap_required' + else 'not_applicable' + end as independent_representation_review, + c.defect = 'representation_25pct_vat' + and c.cost_debit_amount > 300 + as observed_6072_cost_exceeds_300_sek, + reporting.vat_reporting_period, + coalesce(filing.deadline_statuses, '{}'::text[]) as vat_deadline_statuses, + case + when c.defect <> 'representation_25pct_vat' then 'not_applicable' + when reporting.vat_reporting_period is null + then 'unknown_reporting_period' + when coalesce(filing.has_confirmed_record, false) + then 'confirmed_in_app' + when coalesce(filing.has_submitted_record, false) + then 'submitted_in_app' + else 'not_proven_by_in_app_records' + end as vat_filing_status, + 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 document_signals doc on doc.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 + left join lateral ( + select case + when c.defect <> 'representation_25pct_vat' then null + when cs.moms_period = 'monthly' + then to_char(c.entry_date, 'YYYY-MM') + when cs.moms_period = 'quarterly' + then concat( + extract(year from c.entry_date)::integer, + '-Q', + ceil(extract(month from c.entry_date) / 3.0)::integer + ) + when cs.moms_period = 'yearly' and fp.period_end is not null + then case + when extract(month from fp.period_end) = 12 + then extract(year from fp.period_end)::integer::text + else concat( + extract(year from fp.period_end)::integer - 1, + '/', + extract(year from fp.period_end)::integer + ) + end + else null + end as vat_reporting_period + ) reporting on true + left join lateral ( + select + array_agg(distinct d.status order by d.status) as deadline_statuses, + bool_or(d.status = 'submitted') as has_submitted_record, + bool_or(d.status = 'confirmed') as has_confirmed_record + from public.deadlines d + where c.defect = 'representation_25pct_vat' + and d.company_id = c.company_id + and d.tax_deadline_type = case cs.moms_period + when 'monthly' then 'moms_monthly' + when 'quarterly' then 'moms_quarterly' + when 'yearly' then 'moms_yearly' + end + and d.tax_period = reporting.vat_reporting_period + ) filing on true +), + +classified as ( + select + candidate.*, + case + -- Imports and earlier corrections were not generated by either live + -- template in this database. They can have independent accounting + -- issues, but are false positives for this template-remediation audit. + when candidate.source_type in ('import', 'correction') + then 'false_positive' + when candidate.defect = 'representation_25pct_vat' + and candidate.review_priority = 'high_vat_is_25pct_of_6072_cost' + and candidate.document_has_25pct_vat + and not candidate.document_has_12pct_vat + then 'false_positive' + when candidate.defect = 'representation_25pct_vat' + and candidate.review_priority = 'high_vat_is_25pct_of_6072_cost' + then 'insufficient_evidence' + when candidate.defect = 'representation_25pct_vat' + and candidate.observed_vat_rate between 0.119 and 0.121 + then 'false_positive' + when candidate.defect = 'representation_25pct_vat' + and candidate.document_has_12pct_vat + and not candidate.document_has_25pct_vat + then 'false_positive' + when candidate.defect = 'representation_25pct_vat' + then 'insufficient_evidence' + -- The defective hotel template deterministically emitted three lines: + -- 5820 cost, 2641 at 12 percent, and one settlement credit. The hotel + -- counterparty signal supplies the final discriminator from car hire. + when candidate.defect = 'travel_hotel_5820' + and candidate.line_count = 3 + and candidate.observed_vat_rate between 0.119 and 0.121 + and candidate.review_priority = 'high_hotel_counterparty_on_car_hire_account' + then 'confirmed_correction' + when candidate.defect = 'travel_hotel_5820' + and candidate.line_count = 3 + and candidate.observed_vat_rate between 0.119 and 0.121 + then 'insufficient_evidence' + else 'false_positive' + end as evidence_classification, + case + when candidate.source_type in ('import', 'correction') + then 'entry source cannot be either live template' + when candidate.defect = 'representation_25pct_vat' + and candidate.review_priority = 'high_vat_is_25pct_of_6072_cost' + and candidate.document_has_25pct_vat + and not candidate.document_has_12pct_vat + then 'extracted underlag confirms a 25 percent supply' + when candidate.defect = 'representation_25pct_vat' + and candidate.review_priority = 'high_vat_is_25pct_of_6072_cost' + then 'exact defective signature but no decisive underlag or provenance' + when candidate.defect = 'representation_25pct_vat' + and candidate.observed_vat_rate between 0.119 and 0.121 + then 'voucher has the intended 12 percent aggregate VAT signature' + when candidate.defect = 'representation_25pct_vat' + and candidate.document_has_12pct_vat + and not candidate.document_has_25pct_vat + then 'extracted underlag confirms a 12 percent supply' + when candidate.defect = 'representation_25pct_vat' + then 'mixed representation voucher needs decisive underlag or provenance' + when candidate.defect = 'travel_hotel_5820' + and candidate.line_count = 3 + and candidate.observed_vat_rate between 0.119 and 0.121 + and candidate.review_priority = 'high_hotel_counterparty_on_car_hire_account' + then 'exact defective hotel shape plus hotel counterparty' + when candidate.defect = 'travel_hotel_5820' + and candidate.line_count = 3 + and candidate.observed_vat_rate between 0.119 and 0.121 + then 'exact defective hotel shape without hotel evidence' + else 'voucher does not have the defective hotel template shape' + end as classification_reason + from candidate_output candidate ) select c.defect, + c.evidence_classification, + c.classification_reason, + case + when c.evidence_classification = 'confirmed_correction' + then 'explicit_write_approval_required_after_underlag_review' + when c.evidence_classification = 'insufficient_evidence' + then 'underlag_required_before_approval_request' + else 'no_template_remediation' + end as approval_status, + case + when c.defect = 'representation_25pct_vat' + and c.evidence_classification <> 'false_positive' + then 'potential_if_confirmed' + else 'none_from_this_defect' + end as vat_return_impact, c.company_id, - co.name as company_name, + c.company_name, c.journal_entry_id, c.voucher_series, c.voucher_number, c.entry_date, c.committed_at, c.fiscal_period_id, + c.source_type, c.cost_line_ids, c.vat_line_ids, c.observed_cost_account, @@ -241,21 +542,27 @@ select 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 + c.line_count, + c.document_ids, + c.document_count, + c.extracted_document_count, + c.max_extraction_confidence, + c.document_has_12pct_vat, + c.document_has_25pct_vat, + c.independent_representation_review, + c.observed_6072_cost_exceeds_300_sek, + c.vat_reporting_period, + c.vat_deadline_statuses, + c.vat_filing_status, + c.original_lines, + c.effective_lock_status +from classified c order by + case c.evidence_classification + when 'confirmed_correction' then 0 + when 'insufficient_evidence' then 1 + else 2 + end, c.defect, case when c.review_priority like 'high%' then 0