Files
accounted/scripts/audit-template-misbookings.sql
T

573 lines
20 KiB
SQL

-- 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;