fix(vat): keep the momsrapport intact after a manual nollställning (#990)

* fix(vat): keep the momsrapport intact after a manual nollställning (#984)

The momsrapport already excludes settlement verifikat tagged with
source_type 'vat_settlement' (#983), but settlements booked any other
way still zeroed every ruta the moment they were posted: manual
momsomföringar booked before the tagged flow existed (the report in
issue #984), SIE-imported settlements, and storno reversals of a
settlement, which inverted the sign instead and silently doubled the
rutor after an annullera.

Exclude settlement entries by SHAPE as well: an entry with at least one
line on a declaration account (ACCOUNT_RUTA) and at least one on a
settlement net account (2650/1650) is bookkeeping about the declaration,
not VAT-bearing activity, in both the web projection
(fetchVatAccountTotals) and the MCP twin (computeVatReport).
Opening-balance entries are exempt: carried-in 26xx balances are
unsettled VAT that belongs in the next declaration.

Shape-detected POSTED settlements now also gate the "Skapa verifikat"
button through existing_entries, since the proposal re-clears the full
period and booking it on top of a manual settlement would corrupt the
26xx balances. Stornos never gate, so annullera still re-enables
booking.

Fixes #984

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): stable id order for the paginated VAT-report line fetch (CodeRabbit)

fetchAllRows pages with .range(); without a unique .order() rows can
shift across page boundaries once a period exceeds 1000 lines, skipping
or double-counting journal lines in the rutor. Same discipline as the
web projection (fetch-all.ts). Pre-existing, but the query was already
being touched for #984.

Also documents the shape-rule triage from the compliance-bot review in
DECISIONS.md: compound business-VAT-plus-2650 verifikat stay a known
accepted residual (a direction guard would break the storno exclusion),
and the opening-balance concern is false for app flows (SIE import and
set_opening_balances both tag source_type 'opening_balance').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-11 21:37:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 2774e01258
commit 9d91ee0168
7 changed files with 404 additions and 29 deletions
@@ -13,6 +13,8 @@ interface MockLine {
account_number: string
debit_amount: number
credit_amount: number
journal_entry_id?: string
journal_entries?: { source_type: string | null } | Array<{ source_type: string | null }>
}
function mockSupabaseWithLines(lines: MockLine[]) {
@@ -24,6 +26,7 @@ function mockSupabaseWithLines(lines: MockLine[]) {
const terminal = { data: lines, error: null }
const chain: Record<string, () => unknown> = {}
chain.range = () => terminal
chain.order = () => chain
chain.lte = () => chain
chain.gte = () => chain
chain.neq = () => chain
@@ -186,6 +189,50 @@ describe('computeVatReport', () => {
expect(result.rutor.ruta40).toBe(0)
})
it('excludes a manual settlement-shaped entry from the rutor (#984)', async () => {
const lines: MockLine[] = [
// Business activity on e1.
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, journal_entries: { source_type: 'invoice_created' } },
{ journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, journal_entries: { source_type: 'invoice_created' } },
// Manual momsomföring on e2 (no vat_settlement tag): would zero ruta10.
{ journal_entry_id: 'e2', account_number: '2611', debit_amount: 250, credit_amount: 0, journal_entries: { source_type: 'manual' } },
{ journal_entry_id: 'e2', account_number: '2650', debit_amount: 0, credit_amount: 250, journal_entries: { source_type: 'manual' } },
]
const result = await computeVatReport(
{ period_type: 'monthly', year: 2026, period: 1 },
'company-1',
mockSupabaseWithLines(lines)
)
expect(result.rutor.ruta05).toBe(1000)
expect(result.rutor.ruta10).toBe(250)
expect(result.rutor.ruta49).toBe(250)
})
it('settlement-shape exclusion handles the array-typed embed and exempts opening balances', async () => {
const lines: MockLine[] = [
// Storno of a settlement, with the embed in array form (the client's
// inferred shape): must be excluded from ruta10.
{ journal_entry_id: 'e3', account_number: '2611', debit_amount: 0, credit_amount: 100, journal_entries: [{ source_type: 'storno' }] },
{ journal_entry_id: 'e3', account_number: '2650', debit_amount: 100, credit_amount: 0, journal_entries: [{ source_type: 'storno' }] },
// Opening balance carrying undeclared input VAT and a prior VAT debt:
// stays IN the projection.
{ journal_entry_id: 'ib', account_number: '2641', debit_amount: 500, credit_amount: 0, journal_entries: [{ source_type: 'opening_balance' }] },
{ journal_entry_id: 'ib', account_number: '2650', debit_amount: 0, credit_amount: 300, journal_entries: [{ source_type: 'opening_balance' }] },
]
const result = await computeVatReport(
{ period_type: 'monthly', year: 2026, period: 1 },
'company-1',
mockSupabaseWithLines(lines)
)
expect(result.rutor.ruta10).toBe(0)
expect(result.rutor.ruta48).toBe(500)
expect(result.rutor.ruta49).toBe(-500)
})
it('refund summary string when ruta49 is negative', async () => {
const lines: MockLine[] = [
{ account_number: '2641', debit_amount: 100, credit_amount: 0 },
+35 -1
View File
@@ -26,6 +26,7 @@ import {
calculateVatLiability,
} from '@/lib/reports/kpi'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { ACCOUNT_RUTA, VAT_SETTLEMENT_NET_ACCOUNTS } from '@/lib/reports/vat-declaration'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { generateARLedger } from '@/lib/reports/ar-ledger'
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
@@ -1071,14 +1072,18 @@ export async function computeVatReport(
// Paginate. An unbounded .select() caps at PostgREST's 1000-row default,
// which silently truncates a yearly (or busy quarterly) VAT period with
// >1000 entry lines and under-reports the momsdeklaration.
// journal_entries is a to-one embed: PostgREST returns an object at
// runtime, but the untyped client infers an array, so accept both shapes.
const lines = await fetchAllRows<{
journal_entry_id: string
account_number: string
debit_amount: number
credit_amount: number
journal_entries?: { source_type: string | null } | Array<{ source_type: string | null }>
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id)')
.select('journal_entry_id, account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id, source_type)')
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
// Momsredovisning entries (the settlement verifikat clearing 26xx to
@@ -1087,11 +1092,40 @@ export async function computeVatReport(
.neq('journal_entries.source_type', 'vat_settlement')
.gte('journal_entries.entry_date', startDate)
.lte('journal_entries.entry_date', endDate)
// Stable total order for correct paging (see fetch-all.ts): without it,
// rows can shift across page boundaries on reports over 1000 lines.
.order('id', { ascending: true })
.range(from, to)
)
// Settlements booked WITHOUT the vat_settlement tag (manual momsomföring,
// SIE-imported settlements, stornos of a settlement) are excluded by shape,
// mirroring fetchVatAccountTotals (#984): an entry touching both a
// declaration account (ACCOUNT_RUTA) and a settlement net account
// (2650/1650) is a momsredovisning, not VAT-bearing activity. Opening
// balances are exempt: carried-in 26xx balances are unsettled VAT that
// belongs in the next declaration.
const declarationEntryIds = new Set<string>()
const netEntryIds = new Set<string>()
for (const line of lines) {
if (ACCOUNT_RUTA[line.account_number]) declarationEntryIds.add(line.journal_entry_id)
else if (VAT_SETTLEMENT_NET_ACCOUNTS.includes(line.account_number)) {
netEntryIds.add(line.journal_entry_id)
}
}
const settlementShapedIds = new Set<string>()
for (const line of lines) {
const id = line.journal_entry_id
if (!declarationEntryIds.has(id) || !netEntryIds.has(id)) continue
const embedded = line.journal_entries
const entry = Array.isArray(embedded) ? embedded[0] : embedded
if (!entry || entry.source_type === 'opening_balance') continue
settlementShapedIds.add(id)
}
const accountTotals = new Map<string, { debit: number; credit: number }>()
for (const line of lines) {
if (settlementShapedIds.has(line.journal_entry_id)) continue
const acc = line.account_number
const existing = accountTotals.get(acc) ?? { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0