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] 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 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] 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 `gnubok_correct_entry`. Never `gnubok_reverse_journal_entry` alone: the
business event remains valid, only its classification is wrong. business event remains valid, only its classification is wrong.
- Do not write directly to `journal_entries` or `journal_entry_lines`. - Do not write directly to `journal_entries` or `journal_entry_lines`.
- Any `effective_lock_status` other than `open` is a hard stop. The correction - Any `effective_lock_status` other than `open` is a hard stop for the current
path never bypasses a lock. Unlocking or reopening requires a separately `gnubok_correct_entry` path. Never unlock, reopen or edit the original merely
reviewed and explicitly approved workflow for the exact company and period. to make a correction possible. A locked-period correction requires a
If a declaration or closing has relied on the period, establish the required separately reviewed and tested path that records a storno and replacement in
omprövning or closing consequences before requesting that approval. 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 - Never run a production correction without explicit approval for the exact
company, vouchers and replacement lines. company, vouchers and replacement lines.
- No automated bulk mutation. Ever. - 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 against the intended database. It is read-only, creates nothing, and returns
only posted entries. only posted entries.
**Output is diagnostic, never a verdict.** There is no provenance link from a The audit now separates the broad signature from the evidence classification.
posted entry back to the template that produced it (`template_id` lives on There is no direct template id on a journal entry, but `source_type`, exact line
`mapping_rules`, not on journal entries), so candidates are matched by account shape, transaction context and linked underlag can rule candidates in or out.
signature, and both signatures have legitimate shapes: Both signatures still have legitimate shapes:
- `5820` is the **correct** account for actual car hire. - `5820` is the **correct** account for actual car hire.
- Representation at 25% is lawful only when the supply is actually subject to - Representation at 25% is lawful only when the supply is actually subject to
25% VAT and the supplier invoice is correct. 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 | | 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_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. | | `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` The classifier is verified against read-only probes for confirmed, insufficient
ranks high, a genuine car hire on `5820` falls to manual review, and an exact and false-positive outcomes. The audit aggregates multiple `6072` and `2641`
25% representation signature ranks high. Correct 12% representation and mixed lines per entry and returns one candidate instead of a many-to-many set of line
vouchers whose aggregate ratio is not 25% remain visible for manual review, pairs. Mixed representation vouchers without decisive underlag remain
because unrelated `2641` lines can otherwise hide a defective representation `insufficient_evidence`; they are never cleared merely because their aggregate
component. The representation probes include multiple `6072` and `2641` lines; VAT ratio differs from the exact defective 25% signature.
the audit aggregates them per entry and returns one candidate instead of a
many-to-many set of line pairs. 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 ## 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 filed. If so, an omprövning is in scope and the ledger correction alone is
not sufficient. not sufficient.
6. Decide materiality. A 40 kr VAT difference on one lunch two years ago in a 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 closed period may not warrant a separate correction in an open period; a
across a year is different. Record the decision either way. systematic error across a year is different. Record the decision either way.
Keep the reviewed set, the evidence, the proposed replacement lines and the Keep the reviewed set, the evidence, the proposed replacement lines and the
reviewer identity together as the correction record. 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 verify the replacement balances, stage
`gnubok_correct_entry`, and approve only with explicit authorisation. `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, For the hotel defect, the only change is `account_number` on the cost line,
`5820` to `5830`. `5820` to `5830`.
+94 -56
View File
@@ -1,68 +1,106 @@
-- Read-only classifier probes for scripts/audit-template-misbookings.sql. -- Read-only evidence-classifier probes for audit-template-misbookings.sql.
-- Expected result: four rows, each with actual_result = expected_result. -- 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 values
('hotel_on_5820', '5820', 1000.00::numeric), (
('hotel_on_5820', '2641', 120.00::numeric), 'exact_hotel_template_shape',
('car_hire_on_5820', '5820', 1000.00::numeric), 'travel_hotel_5820', 'manual',
('car_hire_on_5820', '2641', 250.00::numeric), 'high_hotel_counterparty_on_car_hire_account',
('representation_25_multi_line', '6072', 60.00::numeric), 3, 0.12::numeric, false, false, 'confirmed_correction'
('representation_25_multi_line', '6072', 40.00::numeric), ),
('representation_25_multi_line', '2641', 10.00::numeric), (
('representation_25_multi_line', '2641', 15.00::numeric), 'car_hire_with_25pct_vat',
('representation_25_with_extra_vat', '6072', 100.00::numeric), 'travel_hotel_5820', 'manual',
('representation_25_with_extra_vat', '2641', 25.00::numeric), 'manual_review_5820_no_corroborating_signal',
('representation_25_with_extra_vat', '2641', 20.00::numeric), 3, 0.25::numeric, false, false, 'false_positive'
('representation_12', '6072', 100.00::numeric), ),
('representation_12', '2641', 12.00::numeric) (
), 'hotel_shape_without_hotel_evidence',
probe_context(probe, transaction_description, expected_result) as ( 'travel_hotel_5820', 'manual',
values 'medium_12pct_vat_on_car_hire_account',
('hotel_on_5820', 'Scandic Stockholm', 'high_hotel_counterparty_on_car_hire_account'), 3, 0.12::numeric, false, false, 'insufficient_evidence'
('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'), 'imported_representation_signature',
('representation_12', 'Customer dinner', 'manual_review_6072_with_vat') 'representation_25pct_vat', 'import',
), 'high_vat_is_25pct_of_6072_cost',
line_groups as ( 3, 0.25::numeric, false, false, 'false_positive'
select probe, account_number, sum(debit_amount) as debit_amount ),
from probe_lines (
group by probe, account_number '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 ( classified as (
select select
context.probe, probe.*,
context.expected_result,
case case
when hotel.debit_amount is not null when source_type in ('import', 'correction') then 'false_positive'
and context.transaction_description ~* '(hotel|hotell|scandic|elite|best western|nordic choice|clarion|quality inn|radisson|booking\.com|airbnb|logi|övernattning)' when defect = 'representation_25pct_vat'
then 'high_hotel_counterparty_on_car_hire_account' and review_priority = 'high_vat_is_25pct_of_6072_cost'
when hotel.debit_amount is not null and document_has_25pct_vat
and abs(coalesce(vat.debit_amount, 0) - hotel.debit_amount * 0.12) < 0.02 and not document_has_12pct_vat
then 'medium_12pct_vat_on_car_hire_account' then 'false_positive'
when hotel.debit_amount is not null when defect = 'representation_25pct_vat'
then 'manual_review_5820_no_corroborating_signal' and review_priority = 'high_vat_is_25pct_of_6072_cost'
when representation.debit_amount is not null then 'insufficient_evidence'
and abs(coalesce(vat.debit_amount, 0) - representation.debit_amount * 0.25) < 0.02 when defect = 'representation_25pct_vat'
then 'high_vat_is_25pct_of_6072_cost' and observed_vat_rate between 0.119 and 0.121
when representation.debit_amount is not null and vat.debit_amount is not null then 'false_positive'
then 'manual_review_6072_with_vat' when defect = 'representation_25pct_vat'
else 'absent' and document_has_12pct_vat
end as actual_result and not document_has_25pct_vat
from probe_context context then 'false_positive'
left join line_groups hotel when defect = 'representation_25pct_vat' then 'insufficient_evidence'
on hotel.probe = context.probe and hotel.account_number = '5820' when defect = 'travel_hotel_5820'
left join line_groups representation and line_count = 3
on representation.probe = context.probe and representation.account_number = '6072' and observed_vat_rate between 0.119 and 0.121
left join line_groups vat and review_priority = 'high_hotel_counterparty_on_car_hire_account'
on vat.probe = context.probe and vat.account_number = '2641' 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 select
probe, probe,
expected_result, expected_classification,
actual_result, actual_classification,
actual_result = expected_result as passed actual_classification = expected_classification as passed
from classified from classified
order by probe; order by probe;
+329 -22
View File
@@ -6,11 +6,12 @@
-- named accounts that never existed in BAS, so account-backfill could not seed -- named accounts that never existed in BAS, so account-backfill could not seed
-- them and every booking through those templates failed. Nothing was posted. -- them and every booking through those templates failed. Nothing was posted.
-- --
-- This query is diagnostic, not a list of confirmed errors. There is no -- The query separates template-remediation evidence from broad account
-- provenance link from a posted entry back to the template that produced it. -- signatures. Imported entries and earlier corrections cannot have been
-- Both signatures also have legitimate shapes: 5820 is correct for actual car -- generated by either live template in this database. The remaining signatures
-- hire, and representation can lawfully carry 25% VAT when the supply itself is -- can still have legitimate shapes: 5820 is correct for actual car hire, and
-- subject to 25% and the invoice is correct. Review the underlag before acting. -- 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 -- The query performs no writes, creates no objects, and returns one row per
-- posted journal entry and defect. Companion procedure: -- posted journal entry and defect. Companion procedure:
@@ -36,9 +37,11 @@ candidate_entry_ids as (
where account_number in ('5820', '6072') where account_number in ('5820', '6072')
), ),
transaction_refs as ( candidate_transactions as (
select select
t.journal_entry_id, t.journal_entry_id,
t.id as transaction_id,
t.document_id,
concat_ws(' ', nullif(t.merchant_name, ''), nullif(t.description, '')) as transaction_description concat_ws(' ', nullif(t.merchant_name, ''), nullif(t.description, '')) as transaction_description
from public.transactions t from public.transactions t
join candidate_entry_ids candidate on candidate.journal_entry_id = t.journal_entry_id join candidate_entry_ids candidate on candidate.journal_entry_id = t.journal_entry_id
@@ -48,6 +51,8 @@ transaction_refs as (
select select
tvl.journal_entry_id, tvl.journal_entry_id,
t.id as transaction_id,
t.document_id,
concat_ws(' ', nullif(t.merchant_name, ''), nullif(t.description, '')) as transaction_description concat_ws(' ', nullif(t.merchant_name, ''), nullif(t.description, '')) as transaction_description
from public.transaction_voucher_links tvl from public.transaction_voucher_links tvl
join candidate_entry_ids candidate on candidate.journal_entry_id = tvl.journal_entry_id join candidate_entry_ids candidate on candidate.journal_entry_id = tvl.journal_entry_id
@@ -61,7 +66,7 @@ transaction_context as (
distinct transaction_description, distinct transaction_description,
' | ' order by transaction_description ' | ' order by transaction_description
) as transaction_description ) as transaction_description
from transaction_refs from candidate_transactions
group by journal_entry_id group by journal_entry_id
), ),
@@ -77,6 +82,7 @@ hotel_candidates as (
je.entry_date, je.entry_date,
je.committed_at, je.committed_at,
je.fiscal_period_id, je.fiscal_period_id,
je.source_type,
cost.line_ids as cost_line_ids, cost.line_ids as cost_line_ids,
coalesce(vat.line_ids, '{}'::uuid[]) as vat_line_ids, coalesce(vat.line_ids, '{}'::uuid[]) as vat_line_ids,
'5820'::text as observed_cost_account, '5820'::text as observed_cost_account,
@@ -117,6 +123,7 @@ representation_candidates as (
je.entry_date, je.entry_date,
je.committed_at, je.committed_at,
je.fiscal_period_id, je.fiscal_period_id,
je.source_type,
cost.line_ids as cost_line_ids, cost.line_ids as cost_line_ids,
vat.line_ids as vat_line_ids, vat.line_ids as vat_line_ids,
'6072'::text as observed_cost_account, '6072'::text as observed_cost_account,
@@ -152,6 +159,7 @@ all_candidates as (
entry_date, entry_date,
committed_at, committed_at,
fiscal_period_id, fiscal_period_id,
source_type,
cost_line_ids, cost_line_ids,
vat_line_ids, vat_line_ids,
observed_cost_account, observed_cost_account,
@@ -177,6 +185,7 @@ all_candidates as (
entry_date, entry_date,
committed_at, committed_at,
fiscal_period_id, fiscal_period_id,
source_type,
cost_line_ids, cost_line_ids,
vat_line_ids, vat_line_ids,
observed_cost_account, observed_cost_account,
@@ -217,18 +226,310 @@ entry_line_snapshots as (
from all_candidates from all_candidates
) candidate on candidate.journal_entry_id = jel.journal_entry_id ) candidate on candidate.journal_entry_id = jel.journal_entry_id
group by 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 select
c.defect, 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_id,
co.name as company_name, c.company_name,
c.journal_entry_id, c.journal_entry_id,
c.voucher_series, c.voucher_series,
c.voucher_number, c.voucher_number,
c.entry_date, c.entry_date,
c.committed_at, c.committed_at,
c.fiscal_period_id, c.fiscal_period_id,
c.source_type,
c.cost_line_ids, c.cost_line_ids,
c.vat_line_ids, c.vat_line_ids,
c.observed_cost_account, c.observed_cost_account,
@@ -241,21 +542,27 @@ select
c.expected_vat_rate, c.expected_vat_rate,
c.transaction_description, c.transaction_description,
c.review_priority, c.review_priority,
lines.original_lines, c.line_count,
case c.document_ids,
when fp.id is null then 'missing_fiscal_period' c.document_count,
when fp.is_closed then 'closed' c.extracted_document_count,
when fp.locked_at is not null then 'locked' c.max_extraction_confidence,
when cs.bookkeeping_locked_through is not null c.document_has_12pct_vat,
and c.entry_date <= cs.bookkeeping_locked_through then 'behind_company_lock_date' c.document_has_25pct_vat,
else 'open' c.independent_representation_review,
end as effective_lock_status c.observed_6072_cost_exceeds_300_sek,
from all_candidates c c.vat_reporting_period,
join public.companies co on co.id = c.company_id c.vat_deadline_statuses,
join entry_line_snapshots lines on lines.journal_entry_id = c.journal_entry_id c.vat_filing_status,
left join public.company_settings cs on cs.company_id = c.company_id c.original_lines,
left join public.fiscal_periods fp on fp.id = c.fiscal_period_id c.effective_lock_status
from classified c
order by order by
case c.evidence_classification
when 'confirmed_correction' then 0
when 'insufficient_evidence' then 1
else 2
end,
c.defect, c.defect,
case case
when c.review_priority like 'high%' then 0 when c.review_priority like 'high%' then 0