573 lines
20 KiB
SQL
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;
|