-- 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. -- -- 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: -- 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') ), 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 where t.journal_entry_id is not null union all 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 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 candidate_transactions 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, 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, '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, je.source_type, 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, source_type, 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, source_type, 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 ), 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, 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, 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, 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 when c.review_priority like 'medium%' then 1 else 2 end, c.entry_date desc;