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:
co-authored by
Claude Fable 5
parent
2774e01258
commit
9d91ee0168
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user