fix(vat): make the ruta drill-down reconcile with the figure it explains (#2016)
* fix(vat): make the ruta drill-down reconcile with the figure it explains get_vat_declaration_totals drops four classes of entry before summing: posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and anything shaped like a momsredovisning. The drill-down behind each ruta filtered on company, status and date only. So expanding a ruta listed verifikat that are not in the number it claims to explain, and the panel shows no total that would reveal the mismatch. On production, 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation under BFL 5 kap. and this drill-down is what a consultant uses to substantiate a filed figure, so the two have to agree exactly. The exclusion CTEs are lifted verbatim from the figure rather than re-derived, because any divergence reintroduces exactly this bug. The new pg test asserts the equality for the whole account set at once, so editing one function and not the other fails CI instead of silently misreporting. opening_balance entries are deliberately kept: the figure exempts them from its `shaped` set, which leaves their lines in the totals, so excluding them here would break the equality in the other direction. That has its own test. Verified the test catches the defect by reinstalling the old function body and watching it fail with the real numbers (2611: drill-down 250/240 vs figure 0/200), then restoring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(vat): update the existing drill-down pg test to the new signature get_vat_ruta_source_lines gained p_ruta_accounts / p_net_accounts, and production-error-regressions.pg.test.ts still called the old 9-argument form, so pg-real failed with 42883 "function does not exist". I had grepped app/, lib/ and extensions/ for callers and not tests/. Neither fixture in that paging test is settlement-shaped, so paging behaviour is unchanged; the equality itself is covered by the new reconcile test. Also documents, in the tool-pg reset script, that its blanket grant to `anon` (which PostgREST requires) makes that database invalid for the pg-real suite: ~29 of those files assert least privilege and fail there even on unmodified main. That cost a confusing local run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1335,4 +1335,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-28] Employee-save failure reported inline (role=alert in the dialog footer, carrying the requestId) in addition to the single destructive toast, and a missing PERSONNUMMER_ENCRYPTION_KEY typed as 503 PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED rather than INTERNAL_ERROR (#1996): the Radix modal aria-hides the root-layout Toaster while the dialog is open, so the toast is invisible to assistive tech and E2E drivers; TOAST_LIMIT is 1, so a second toast is not an option; and the missing key is a permanent configuration gap where "try again later" is wrong and "contact support" is right (same reasoning as CUSTOMER_PERSONAL_NUMBER_UNREADABLE and INVOICE_SEND_EMAIL_NOT_CONFIGURED). The shared postAction helper was not extended (it takes no body and exposes no requestId): keeping the change local to the dialog avoids widening a helper other panels rely on.
|
||||
[2026-08-28] /migrate SIE guard extended to every provider (Fortnox exemption removed) as "a completed SIE import must exist for the company", not "must be part of this run", plus a wizard hint that disables Start when SIE is unchecked and never imported; chose this over forcing the checkbox on because the route is the only seam a direct API call or a stale client cannot bypass, and "must exist" keeps entities-only re-runs after a full migration working (#2000).
|
||||
[2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review).
|
||||
[2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring.
|
||||
[2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card.
|
||||
|
||||
@@ -3,6 +3,8 @@ import { NextResponse } from 'next/server'
|
||||
import {
|
||||
ACCOUNT_RUTA,
|
||||
resolvePeriodDates,
|
||||
VAT_ACCOUNTS,
|
||||
VAT_SETTLEMENT_NET_ACCOUNTS,
|
||||
} from '@/lib/reports/vat-declaration'
|
||||
import { fetchDynamicVatAccounts } from '@/lib/reports/vat-revenue-accounts'
|
||||
import type { ReportSourceLine } from '@/lib/reports/source-lines'
|
||||
@@ -151,6 +153,13 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>(
|
||||
p_start: start,
|
||||
p_end: end,
|
||||
p_accounts: accountsForRuta,
|
||||
// Settlement-shape detectors, passed exactly as fetchVatAccountTotals
|
||||
// passes them to get_vat_declaration_totals. The drill-down must drop the
|
||||
// same entries the figure drops (posted closing entries, vat_settlement,
|
||||
// the kontantmetod year-end reversals, and anything shaped like a
|
||||
// momsredovisning), or it lists verifikat that are not in the number.
|
||||
p_ruta_accounts: VAT_ACCOUNTS,
|
||||
p_net_accounts: VAT_SETTLEMENT_NET_ACCOUNTS,
|
||||
p_cursor_date: cursorDate,
|
||||
p_cursor_voucher_number: cursorVoucherNum,
|
||||
p_cursor_entry_id: cursorEntryId,
|
||||
|
||||
@@ -137,7 +137,14 @@ export const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side
|
||||
'4547': { box: 'ruta50', side: 'debit' }, // Beskattningsunderlag import 6%
|
||||
}
|
||||
|
||||
const VAT_ACCOUNTS = Object.keys(ACCOUNT_RUTA)
|
||||
/**
|
||||
* The fixed BAS accounts that define a momsdeklaration line, and with it the
|
||||
* settlement SHAPE detector passed as `p_ruta_accounts` to both
|
||||
* get_vat_declaration_totals and get_vat_ruta_source_lines. Exported so the
|
||||
* ruta drill-down route detects shape from the same list the figure does; a
|
||||
* second copy is what let the two disagree.
|
||||
*/
|
||||
export const VAT_ACCOUNTS = Object.keys(ACCOUNT_RUTA)
|
||||
|
||||
/**
|
||||
* 26xx output VAT accounts feeding rutor 10/11/12, 30/31/32 and 60/61/62.
|
||||
|
||||
@@ -68,6 +68,11 @@ for f in "$REPO_ROOT"/supabase/migrations/*.sql; do
|
||||
done
|
||||
echo "==> $count migrations applied"
|
||||
|
||||
# NOTE: this blanket grant includes `anon`, which PostgREST needs to answer at
|
||||
# all. It also means THIS DATABASE IS NOT VALID FOR THE pg-real SUITE: ~29 of
|
||||
# those files assert least privilege ("does not grant EXECUTE to anon"), and
|
||||
# they fail here on unmodified main. Point `npm run test:pg` at its own
|
||||
# database, not at this one.
|
||||
echo "==> granting on everything the migrations created"
|
||||
psql_run -c "
|
||||
GRANT ALL ON ALL TABLES IN SCHEMA public TO postgres, anon, authenticated, service_role;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
-- Make the VAT ruta drill-down return exactly the lines the filed figure sums.
|
||||
--
|
||||
-- `get_vat_declaration_totals` (the FIGURE, migration 20260813124510) drops
|
||||
-- four classes of entry before summing: posted closing entries, source_type
|
||||
-- 'vat_settlement', the two kontantmetod year-end reversals, and any entry
|
||||
-- shaped like a momsredovisning (a line on a ruta account AND a line on
|
||||
-- 2650/1650). `get_vat_ruta_source_lines` (the DRILL-DOWN, migration
|
||||
-- 20260721103000) filtered on company, status and date only, so expanding a
|
||||
-- ruta listed verifikat that are not in the number it claims to explain, with
|
||||
-- no total on the panel to reveal the mismatch.
|
||||
--
|
||||
-- Measured on production 2026-08-28: 322 posted/reversed entries carrying 26xx
|
||||
-- lines across 214 companies sit in those excluded classes. A momsdeklaration
|
||||
-- is räkenskapsinformation (BFL 5 kap.), and this drill-down is what a
|
||||
-- consultant uses to substantiate a filed figure, so the two must agree
|
||||
-- exactly.
|
||||
--
|
||||
-- The exclusion CTEs below are lifted VERBATIM from get_vat_declaration_totals
|
||||
-- rather than re-derived. That is deliberate: any divergence between the two
|
||||
-- reintroduces exactly this bug, and a copy that reads identically is easy to
|
||||
-- diff when the figure changes. tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts
|
||||
-- asserts the equality directly, so a future edit to one and not the other
|
||||
-- fails CI rather than silently misreporting.
|
||||
--
|
||||
-- Settlement-shape is detected against journal_entry_lines directly instead of
|
||||
-- through the figure's `vat_lines` CTE. That is equivalent, not a shortcut:
|
||||
-- the figure passes p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts =
|
||||
-- ['2650','1650'], both strict subsets of its p_accounts, so restricting to
|
||||
-- vat_lines first cannot change which entries match. It lets the drill-down
|
||||
-- keep p_accounts meaning "the accounts of the ruta being expanded" without a
|
||||
-- fourth account parameter.
|
||||
--
|
||||
-- opening_balance entries are deliberately NOT excluded. They are excluded
|
||||
-- from the figure's `shaped` set, which keeps their lines IN the totals, so
|
||||
-- dropping them here would break the equality in the other direction.
|
||||
--
|
||||
-- pg-test: tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts
|
||||
|
||||
-- The signature gains p_ruta_accounts / p_net_accounts. DROP first: adding
|
||||
-- parameters with CREATE OR REPLACE registers a second overload, and PostgREST
|
||||
-- then cannot choose between them (documented in 20260421140000).
|
||||
DROP FUNCTION IF EXISTS public.get_vat_ruta_source_lines(
|
||||
uuid, date, date, text[], date, integer, uuid, uuid, integer
|
||||
);
|
||||
|
||||
-- OR REPLACE on the NEW signature, so re-running this file is a no-op rather
|
||||
-- than "function already exists with same argument types". The DROP above is
|
||||
-- what removes the old arity; this cannot reintroduce an overload.
|
||||
CREATE OR REPLACE FUNCTION public.get_vat_ruta_source_lines(
|
||||
p_company_id uuid,
|
||||
p_start date,
|
||||
p_end date,
|
||||
-- Accounts of the ruta being expanded: which lines to RETURN.
|
||||
p_accounts text[],
|
||||
-- Settlement-shape detectors, mirroring the figure's own parameters.
|
||||
p_ruta_accounts text[],
|
||||
p_net_accounts text[],
|
||||
p_cursor_date date DEFAULT NULL,
|
||||
p_cursor_voucher_number integer DEFAULT NULL,
|
||||
p_cursor_entry_id uuid DEFAULT NULL,
|
||||
p_cursor_line_id uuid DEFAULT NULL,
|
||||
p_limit integer DEFAULT 501
|
||||
)
|
||||
RETURNS TABLE (
|
||||
line_id uuid,
|
||||
journal_entry_id uuid,
|
||||
voucher_number integer,
|
||||
voucher_series text,
|
||||
entry_date date,
|
||||
description text,
|
||||
debit_amount numeric,
|
||||
credit_amount numeric
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY INVOKER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
WITH closing_entries AS (
|
||||
SELECT fp.closing_entry_id AS id
|
||||
FROM public.fiscal_periods fp
|
||||
WHERE fp.company_id = p_company_id
|
||||
AND fp.closing_entry_id IS NOT NULL
|
||||
),
|
||||
scoped_entries AS (
|
||||
SELECT e.id, e.status, e.entry_date, e.source_type, e.description,
|
||||
e.voucher_series, e.voucher_number
|
||||
FROM public.journal_entries e
|
||||
WHERE e.company_id = p_company_id
|
||||
AND e.status IN ('posted', 'reversed')
|
||||
AND e.entry_date >= p_start
|
||||
AND e.entry_date <= p_end
|
||||
AND NOT (
|
||||
e.status = 'posted'
|
||||
AND EXISTS (SELECT 1 FROM closing_entries c WHERE c.id = e.id)
|
||||
)
|
||||
),
|
||||
non_settlement_entries AS (
|
||||
SELECT * FROM scoped_entries
|
||||
WHERE source_type IS DISTINCT FROM 'vat_settlement'
|
||||
AND NOT (
|
||||
source_type = 'year_end'
|
||||
AND description IN (
|
||||
'Vändning kundfordringar bokslut (kontantmetoden)',
|
||||
'Vändning leverantörsskulder bokslut (kontantmetoden)'
|
||||
)
|
||||
)
|
||||
),
|
||||
shaped AS (
|
||||
SELECT e.id
|
||||
FROM non_settlement_entries e
|
||||
WHERE e.source_type IS DISTINCT FROM 'opening_balance'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.journal_entry_lines l
|
||||
WHERE l.journal_entry_id = e.id
|
||||
AND l.account_number = ANY (p_ruta_accounts)
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.journal_entry_lines l
|
||||
WHERE l.journal_entry_id = e.id
|
||||
AND l.account_number = ANY (p_net_accounts)
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
l.id AS line_id,
|
||||
je.id AS journal_entry_id,
|
||||
je.voucher_number,
|
||||
COALESCE(je.voucher_series, 'A') AS voucher_series,
|
||||
je.entry_date,
|
||||
COALESCE(je.description, '') AS description,
|
||||
l.debit_amount,
|
||||
l.credit_amount
|
||||
FROM non_settlement_entries je
|
||||
JOIN public.journal_entry_lines l ON l.journal_entry_id = je.id
|
||||
WHERE l.account_number = ANY (p_accounts)
|
||||
AND NOT EXISTS (SELECT 1 FROM shaped s WHERE s.id = je.id)
|
||||
AND (
|
||||
p_cursor_date IS NULL
|
||||
OR (
|
||||
je.entry_date,
|
||||
je.voucher_number,
|
||||
je.id,
|
||||
l.id
|
||||
) > (
|
||||
p_cursor_date,
|
||||
p_cursor_voucher_number,
|
||||
COALESCE(p_cursor_entry_id, 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid),
|
||||
COALESCE(p_cursor_line_id, 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid)
|
||||
)
|
||||
)
|
||||
ORDER BY je.entry_date, je.voucher_number, je.id, l.id
|
||||
LIMIT LEAST(GREATEST(COALESCE(p_limit, 501), 1), 501);
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -128,24 +128,31 @@ describe('production error regressions', () => {
|
||||
],
|
||||
})
|
||||
|
||||
// p_ruta_accounts / p_net_accounts are the settlement-shape detectors the
|
||||
// drill-down gained in 20260828172003 so it drops the same entries as the
|
||||
// filed figure. Neither fixture here is settlement-shaped, so paging is
|
||||
// unaffected; the equality itself is covered by
|
||||
// tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts.
|
||||
const first = await getPool().query(
|
||||
`SELECT * FROM public.get_vat_ruta_source_lines(
|
||||
$1, $2, $3, $4, NULL, NULL, NULL, NULL, 1
|
||||
$1, $2, $3, $4, $5, $6, NULL, NULL, NULL, NULL, 1
|
||||
)`,
|
||||
[ctx.companyId, '2026-03-01', '2026-03-31', ['2611']],
|
||||
[ctx.companyId, '2026-03-01', '2026-03-31', ['2611'], ['2611'], ['2650', '1650']],
|
||||
)
|
||||
expect(first.rows).toHaveLength(1)
|
||||
expect(first.rows[0].voucher_number).toBe(1)
|
||||
|
||||
const second = await getPool().query(
|
||||
`SELECT * FROM public.get_vat_ruta_source_lines(
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, 1
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 1
|
||||
)`,
|
||||
[
|
||||
ctx.companyId,
|
||||
'2026-03-01',
|
||||
'2026-03-31',
|
||||
['2611'],
|
||||
['2611'],
|
||||
['2650', '1650'],
|
||||
first.rows[0].entry_date,
|
||||
first.rows[0].voucher_number,
|
||||
first.rows[0].journal_entry_id,
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* The VAT ruta drill-down must return exactly the lines the filed figure sums.
|
||||
*
|
||||
* `get_vat_declaration_totals` drops four classes of entry before summing:
|
||||
* posted closing entries, source_type 'vat_settlement', the two kontantmetod
|
||||
* year-end reversals, and any entry shaped like a momsredovisning (a line on a
|
||||
* ruta account AND a line on 2650/1650). `get_vat_ruta_source_lines` filtered
|
||||
* on company, status and date only, so expanding a ruta listed verifikat that
|
||||
* were not in the number it claims to explain. 322 posted/reversed entries
|
||||
* across 214 companies sat in those classes on production (2026-08-28).
|
||||
*
|
||||
* A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down
|
||||
* is what substantiates a filed figure, so the headline test here is a single
|
||||
* equality: for every account, the drill-down's sum equals the figure's total.
|
||||
* It holds for the whole account set at once, so a future edit to one function
|
||||
* and not the other fails here rather than silently misreporting.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getPool } from './setup'
|
||||
import {
|
||||
insertAuthUser,
|
||||
insertCompany,
|
||||
insertFiscalPeriod,
|
||||
insertPostedJournalEntry,
|
||||
} from './fixtures'
|
||||
|
||||
// Mirrors the TS call site: a representative slice of ACCOUNT_RUTA. The full
|
||||
// list is a parameter, never baked into the SQL.
|
||||
const RUTA_ACCOUNTS = ['2611', '2641', '2645', '3001']
|
||||
const NET_ACCOUNTS = ['2650', '1650']
|
||||
const ALL_ACCOUNTS = [...RUTA_ACCOUNTS, ...NET_ACCOUNTS]
|
||||
const BALANCING_ACCOUNT = '2999'
|
||||
|
||||
interface DrillLine {
|
||||
line_id: string
|
||||
journal_entry_id: string
|
||||
voucher_number: number
|
||||
entry_date: string
|
||||
description: string
|
||||
debit_amount: string | number
|
||||
credit_amount: string | number
|
||||
}
|
||||
|
||||
async function figureTotals(companyId: string) {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT public.get_vat_declaration_totals($1,$2,$3,$4,$5,$6) AS payload`,
|
||||
[companyId, '2026-01-01', '2026-12-31', ALL_ACCOUNTS, RUTA_ACCOUNTS, NET_ACCOUNTS],
|
||||
)
|
||||
const payload = rows[0].payload as {
|
||||
totals: Array<{ account_number: string; debit: number; credit: number }>
|
||||
}
|
||||
return new Map(payload.totals.map((t) => [t.account_number, t]))
|
||||
}
|
||||
|
||||
async function drillDown(companyId: string, accounts: string[]): Promise<DrillLine[]> {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT * FROM public.get_vat_ruta_source_lines(
|
||||
$1,$2,$3,$4,$5,$6, NULL, NULL, NULL, NULL, 501)`,
|
||||
[companyId, '2026-01-01', '2026-12-31', accounts, RUTA_ACCOUNTS, NET_ACCOUNTS],
|
||||
)
|
||||
return rows as DrillLine[]
|
||||
}
|
||||
|
||||
function sumOf(lines: DrillLine[]) {
|
||||
return lines.reduce(
|
||||
(acc, l) => ({
|
||||
debit: acc.debit + Number(l.debit_amount ?? 0),
|
||||
credit: acc.credit + Number(l.credit_amount ?? 0),
|
||||
}),
|
||||
{ debit: 0, credit: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
async function insertEntry(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
voucherNumber: number
|
||||
sourceType?: string
|
||||
description?: string
|
||||
lines: Array<{ account: string; debit: number; credit: number }>
|
||||
}): Promise<string> {
|
||||
return insertPostedJournalEntry({
|
||||
userId: params.userId,
|
||||
companyId: params.companyId,
|
||||
fiscalPeriodId: params.fiscalPeriodId,
|
||||
voucherNumber: params.voucherNumber,
|
||||
entryDate: '2026-03-15',
|
||||
description: params.description ?? 'drilldown test',
|
||||
sourceType: params.sourceType ?? 'manual',
|
||||
lines: params.lines.map((l) => ({
|
||||
accountNumber: l.account,
|
||||
debitAmount: l.debit,
|
||||
creditAmount: l.credit,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
describe('VAT ruta drill-down reconciles with the declaration figure', () => {
|
||||
let userId: string
|
||||
let companyId: string
|
||||
let fiscalPeriodId: string
|
||||
let plainEntryId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
userId = await insertAuthUser()
|
||||
companyId = await insertCompany({ createdBy: userId })
|
||||
fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
|
||||
|
||||
// 1. A plain sale. Must appear in BOTH the figure and the drill-down.
|
||||
plainEntryId = await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 1,
|
||||
description: 'Vanlig försäljning',
|
||||
lines: [
|
||||
{ account: '3001', debit: 0, credit: 800 },
|
||||
{ account: '2611', debit: 0, credit: 200 },
|
||||
{ account: BALANCING_ACCOUNT, debit: 1000, credit: 0 },
|
||||
],
|
||||
})
|
||||
|
||||
// 2. A tagged momsredovisning. Excluded from the figure.
|
||||
await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 2,
|
||||
sourceType: 'vat_settlement', description: 'Momsredovisning',
|
||||
lines: [
|
||||
{ account: '2611', debit: 200, credit: 0 },
|
||||
{ account: '2650', debit: 0, credit: 200 },
|
||||
],
|
||||
})
|
||||
|
||||
// 3. Settlement SHAPE without the tag: a ruta account plus 2650.
|
||||
await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 3,
|
||||
sourceType: 'manual', description: 'Otaggad momsredovisning',
|
||||
lines: [
|
||||
{ account: '2641', debit: 0, credit: 125 },
|
||||
{ account: '2650', debit: 125, credit: 0 },
|
||||
],
|
||||
})
|
||||
|
||||
// 4. The kontantmetod year-end reversal, excluded by description.
|
||||
await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 4,
|
||||
sourceType: 'year_end',
|
||||
description: 'Vändning kundfordringar bokslut (kontantmetoden)',
|
||||
lines: [
|
||||
{ account: '2611', debit: 50, credit: 0 },
|
||||
{ account: BALANCING_ACCOUNT, debit: 0, credit: 50 },
|
||||
],
|
||||
})
|
||||
|
||||
// 5. An opening balance. Deliberately NOT excluded: the figure exempts
|
||||
// opening_balance from `shaped`, which keeps its lines in the totals.
|
||||
// Dropping it here would break the equality in the other direction.
|
||||
await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 5,
|
||||
sourceType: 'opening_balance', description: 'Ingående balans',
|
||||
lines: [
|
||||
{ account: '2641', debit: 75, credit: 0 },
|
||||
{ account: BALANCING_ACCOUNT, debit: 0, credit: 75 },
|
||||
],
|
||||
})
|
||||
|
||||
// 6. A posted closing entry, excluded once the period points at it.
|
||||
const closingId = await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 6,
|
||||
sourceType: 'year_end', description: 'Bokslutsverifikat',
|
||||
lines: [
|
||||
{ account: '2611', debit: 0, credit: 40 },
|
||||
{ account: BALANCING_ACCOUNT, debit: 40, credit: 0 },
|
||||
],
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods SET closing_entry_id = $1 WHERE id = $2`,
|
||||
[closingId, fiscalPeriodId],
|
||||
)
|
||||
}, 60_000)
|
||||
|
||||
it('sums identically to the figure, for every account', async () => {
|
||||
// The headline assertion. Any entry the figure drops and the drill-down
|
||||
// keeps (or vice versa) shows up here as a mismatched account.
|
||||
const totals = await figureTotals(companyId)
|
||||
const mismatches: string[] = []
|
||||
|
||||
for (const account of ALL_ACCOUNTS) {
|
||||
const lines = await drillDown(companyId, [account])
|
||||
const drilled = sumOf(lines)
|
||||
const figure = totals.get(account) ?? { debit: 0, credit: 0 }
|
||||
if (
|
||||
Math.round(drilled.debit * 100) !== Math.round(Number(figure.debit) * 100) ||
|
||||
Math.round(drilled.credit * 100) !== Math.round(Number(figure.credit) * 100)
|
||||
) {
|
||||
mismatches.push(
|
||||
`${account}: drill-down ${drilled.debit}/${drilled.credit} vs figure ${figure.debit}/${figure.credit}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
expect(mismatches).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('still returns the ordinary sale it is meant to explain', async () => {
|
||||
// Guards the other direction: a filter that excluded everything would
|
||||
// satisfy the equality above trivially if the figure were empty too.
|
||||
const lines = await drillDown(companyId, ['2611'])
|
||||
expect(lines.length).toBeGreaterThan(0)
|
||||
expect(lines.map((l) => l.journal_entry_id)).toContain(plainEntryId)
|
||||
expect(sumOf(lines).credit).toBe(200)
|
||||
}, 30_000)
|
||||
|
||||
it('excludes tagged settlements, shaped settlements, kontantmetod reversals and closing entries', async () => {
|
||||
const descriptions = (await drillDown(companyId, ALL_ACCOUNTS)).map((l) => l.description)
|
||||
expect(descriptions).not.toContain('Momsredovisning')
|
||||
expect(descriptions).not.toContain('Otaggad momsredovisning')
|
||||
expect(descriptions).not.toContain('Vändning kundfordringar bokslut (kontantmetoden)')
|
||||
expect(descriptions).not.toContain('Bokslutsverifikat')
|
||||
}, 30_000)
|
||||
|
||||
it('keeps opening-balance lines, which the figure also counts', async () => {
|
||||
// The subtle one. `shaped` exempts opening_balance, so its 26xx lines stay
|
||||
// in the totals; excluding them from the drill-down would be a new bug in
|
||||
// the opposite direction.
|
||||
const lines = await drillDown(companyId, ['2641'])
|
||||
expect(lines.map((l) => l.description)).toContain('Ingående balans')
|
||||
}, 30_000)
|
||||
})
|
||||
Reference in New Issue
Block a user