a82f126031
* docs(bookkeeping): audit + runbook for template-caused mis-bookings Two of the template defects fixed this week produced postings that SUCCEEDED and are still sitting in customers' huvudbocker: travel_hotel debited 5820 Hyrbilskostnader instead of 5830 Kost och logi (#1397), and the representation template deducted 25% input VAT on a 12% restaurang supply (#1396). Fixing a template only changes future postings. Follows the pattern already established by SETTLEMENT_ACCOUNT_REMEDIATION.md for the same class of problem: read-only detection, per-entry evidence review, staged storno with explicit approval, no automated bulk mutation. Deliberately excludes vehicle_parking (5614) and it_cloud_hosting (5421). Those named accounts that never existed in BAS, so account-backfill could not seed them and every booking failed. Nothing was posted, nothing to remediate. Detection is by account signature and is diagnostic only, because 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. Both signatures have legitimate shapes (5820 IS correct for real car hire; representation at 25% IS lawful when the supplier charged 25%), so a row is a question and never a verdict. The classifier is verified against seeded probes rather than assumed: a hotel booked to 5820 with a hotel counterparty ranks high, a genuine car hire on 5820 falls to manual review, a 25% representation ranks high, and a correct 12% representation does not appear at all. Query confirmed to run against the real schema (the lock date lives on company_settings, not companies). The runbook records what BFL 5 kap 5 § actually requires: both tracks, that storno is the only one available once a period is locked or the bookkeeping has been relied upon, and that there is NO numeric materiality threshold in BFL. Materiality decides whether a historical correction is worth making, never whether a silent one is allowed. For the VAT defect it also flags that a filed momsdeklaration makes this an omprovning question, not just a ledger one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(bookkeeping): harden template misbooking audit * fix(bookkeeping): retain mixed voucher audit candidates --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com>
266 lines
8.8 KiB
SQL
266 lines
8.8 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.
|
|
--
|
|
-- 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;
|