fix(bookkeeping): classify template audit evidence (#1594)

This commit is contained in:
Mattsson
2026-08-13 18:58:09 +02:00
committed by GitHub
parent d02fd82191
commit e494662530
4 changed files with 489 additions and 98 deletions
+2
View File
@@ -934,3 +934,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+64 -20
View File
@@ -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`.
+94 -56
View File
@@ -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;
+329 -22
View File
@@ -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