fix(bookkeeping): classify template audit evidence (#1594)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user