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
@@ -60,3 +60,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-09] SalaryCalendar absence-type rainbow palette (red/amber/emerald/blue/indigo pills) left as-is in the UI consistency pass: those colors encode absence categories (data), not status chrome, and swapping them for the 3 semantic tokens would collapse 5 distinguishable categories; needs a proper categorical-palette decision instead of a mechanical fix.
|
||||
[2026-07-10] Momsverifikat from momsrapport (#980): the proposal clears each 26xx account at exact öre but books the 2650/1650 net at the FILED whole-krona amount (buildFiledAmounts, öretal faller bort) with the gap on 3740, so redovisningskontot always matches the skattekonto movement; and vat_settlement entries are excluded from the VAT report projection (web calculateVatDeclaration + MCP computeVatReport) because a pure-projection report would otherwise read zero (and Skatteverket submission would file zeros) the moment the settlement is booked.
|
||||
[2026-07-10] VatBookingCard hard-disables "Skapa verifikat" while a POSTED vat_settlement exists in the period (CodeRabbit finding, accepted over the initial warn-but-allow): the proposal is not delta-aware (it re-clears the FULL period), so booking twice corrupts 26xx balances; the sanctioned redo path is annullera (storno restores the balances and re-enables the button). Already-booked detection is by source_type + entry_date within the period, so redating the entry outside the period escapes the gate: accepted v1 limitation. Card copy is hardcoded Swedish per the file's existing momsdeklaration convention (i18n.md).
|
||||
[2026-07-11] Momsrapport after settlement (#984): extended the VAT-report exclusion from tag-only to shape-based. Any entry touching both a declaration account (ACCOUNT_RUTA) and a settlement net account (2650/1650) is treated as a momsredovisning and excluded from the projection (web calculateVatDeclaration + MCP computeVatReport), covering manual momsomforingar booked before #980 shipped, SIE-imported settlements, and stornos of a settlement (which would otherwise double the rutor after annullera, a latent bug in the #983 tag-only filter). Opening-balance entries are exempt from the shape rule: carried-in 26xx balances are unsettled VAT that belongs in the next declaration. Shaped POSTED entries also gate the "Skapa verifikat" button via existing_entries (the proposal re-clears the full period, so booking over a manual settlement would corrupt 26xx); stornos never gate, or annullera could not re-enable booking. Rejected the frozen-snapshot alternative the issue suggested: pure projection heals historical periods retroactively (a snapshot would not exist for them) and needs no migration.
|
||||
[2026-07-11] #984 shape-rule residuals triaged and ACCEPTED (compliance-bot review): a compound verifikat mixing business VAT lines with a 2650/1650 payment/correction line in ONE entry is excluded from the rutor by the shape rule (under-reports). Kept anyway: such compound entries are rare bad practice, and the suggested direction guard (only exclude when 2650 is credited / 1650 debited) would break the storno exclusion, whose reversal carries exactly the flipped sides. Opening-balance concern verified false for app flows: SIE import and set_opening_balances both tag source_type 'opening_balance' (sie-import.ts); only a hand-booked IB verifikat shares the compound-entry residual.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1192,3 +1192,99 @@ describe('calculateVatDeclaration: annual VAT spans the räkenskapsår', () => {
|
||||
expect(result.period.end).toBe('2026-03-31')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// #984: settlement entries never zero the report
|
||||
// ============================================================
|
||||
|
||||
describe('calculateVatDeclaration: settlement-shaped exclusion (#984)', () => {
|
||||
function line(entryId: string, account: string, debit: number, credit: number) {
|
||||
return {
|
||||
journal_entry_id: entryId,
|
||||
account_number: account,
|
||||
debit_amount: debit,
|
||||
credit_amount: credit,
|
||||
}
|
||||
}
|
||||
|
||||
it('excludes a manual momsomföring so the report survives nollställning', async () => {
|
||||
results = [
|
||||
{
|
||||
data: [{ id: 'e1' }, { id: 'e2', source_type: 'manual' }],
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: [
|
||||
// Business activity.
|
||||
line('e1', '2611', 0, 2500),
|
||||
line('e1', '2641', 1000, 0),
|
||||
line('e1', '3001', 0, 10000),
|
||||
// Manual settlement clearing the period to 2650 (booked without
|
||||
// the vat_settlement source_type, e.g. before #980 shipped).
|
||||
line('e2', '2611', 2500, 0),
|
||||
line('e2', '2641', 0, 1000),
|
||||
line('e2', '2650', 0, 1500),
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // entry counts
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2026, 5)
|
||||
|
||||
// Without the shape exclusion every ruta reads 0 after the settlement.
|
||||
expect(result.rutor.ruta10).toBe(2500)
|
||||
expect(result.rutor.ruta48).toBe(1000)
|
||||
expect(result.rutor.ruta49).toBe(1500)
|
||||
expect(result.rutor.ruta05).toBe(10000)
|
||||
})
|
||||
|
||||
it('excludes a storno of a settlement (annullera must not inflate the rutor)', async () => {
|
||||
results = [
|
||||
{
|
||||
// The tagged settlement itself is filtered out by the query; its
|
||||
// storno reversal is not, and would otherwise re-credit 2611.
|
||||
data: [{ id: 'e1' }, { id: 'e3', source_type: 'storno' }],
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: [
|
||||
line('e1', '2611', 0, 100),
|
||||
line('e3', '2611', 0, 100),
|
||||
line('e3', '2650', 100, 0),
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // entry counts
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2026, 5)
|
||||
|
||||
expect(result.rutor.ruta10).toBe(100)
|
||||
expect(result.rutor.ruta49).toBe(100)
|
||||
})
|
||||
|
||||
it('keeps opening-balance entries: carried-in 26xx balances are unsettled VAT', async () => {
|
||||
results = [
|
||||
{
|
||||
data: [{ id: 'ib', source_type: 'opening_balance' }],
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: [
|
||||
// Migrating company: undeclared input VAT and a prior VAT debt
|
||||
// carried in through the same opening-balance entry.
|
||||
line('ib', '2641', 500, 0),
|
||||
line('ib', '2650', 0, 300),
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // entry counts
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2026, 1)
|
||||
|
||||
expect(result.rutor.ruta48).toBe(500)
|
||||
expect(result.rutor.ruta49).toBe(-500)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,14 +2,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { buildVatSettlementProposal } from '../vat-settlement'
|
||||
|
||||
// ============================================================
|
||||
// Mock: results routed by table + select shape (the builder runs its two
|
||||
// Mock: results routed by table + applied filters (the builder runs its two
|
||||
// ledger queries and the existing-entries lookup concurrently, so a
|
||||
// sequential result queue would be order-fragile).
|
||||
// ============================================================
|
||||
|
||||
interface MockData {
|
||||
/** journal_entries rows for the entry-scope query (fetchEntryLines step 1). */
|
||||
entries?: Array<{ id: string }>
|
||||
/**
|
||||
* journal_entries rows for the entry-scope query (fetchEntryLines step 1).
|
||||
* Shape-detection reads status/entry_date/source_type/voucher_* off these.
|
||||
*/
|
||||
entries?: Array<Record<string, unknown>>
|
||||
/** journal_entry_lines rows (fetchEntryLines step 2). */
|
||||
lines?: Array<Record<string, unknown>>
|
||||
/** Existing vat_settlement entries in the period. */
|
||||
@@ -26,16 +29,16 @@ function makeClient(data: MockData) {
|
||||
neqCalls = []
|
||||
return {
|
||||
from: vi.fn().mockImplementation((table: string) => {
|
||||
let selectStr = ''
|
||||
const eqCalls: Array<[string, unknown]> = []
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b: Record<string, any> = {}
|
||||
b.select = vi.fn().mockImplementation((s: string) => {
|
||||
selectStr = s
|
||||
return b
|
||||
})
|
||||
for (const m of ['eq', 'in', 'gte', 'lte', 'order', 'range', 'limit']) {
|
||||
for (const m of ['select', 'in', 'gte', 'lte', 'order', 'range', 'limit']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.eq = vi.fn().mockImplementation((col: string, val: unknown) => {
|
||||
eqCalls.push([col, val])
|
||||
return b
|
||||
})
|
||||
b.neq = vi.fn().mockImplementation((col: string, val: unknown) => {
|
||||
neqCalls.push([col, val])
|
||||
return b
|
||||
@@ -44,9 +47,9 @@ function makeClient(data: MockData) {
|
||||
b.then = (resolve: (v: unknown) => void) => {
|
||||
if (table === 'journal_entry_lines') return resolve({ data: data.lines ?? [], error: null })
|
||||
// journal_entries serves two queries: the entry scope for the ledger
|
||||
// totals (select 'id') and the existing-settlement lookup (selects
|
||||
// voucher columns).
|
||||
if (selectStr.includes('voucher_series')) {
|
||||
// totals (filters vat_settlement OUT via .neq) and the tagged
|
||||
// existing-settlement lookup (filters it IN via .eq).
|
||||
if (eqCalls.some(([col, val]) => col === 'source_type' && val === 'vat_settlement')) {
|
||||
return resolve(
|
||||
data.existingError
|
||||
? { data: null, error: data.existingError }
|
||||
@@ -62,11 +65,11 @@ function makeClient(data: MockData) {
|
||||
}
|
||||
|
||||
let lineId = 0
|
||||
function vatLine(account: string, debit: number, credit: number) {
|
||||
function vatLine(account: string, debit: number, credit: number, entryId = 'e1') {
|
||||
lineId += 1
|
||||
return {
|
||||
id: `l${lineId}`,
|
||||
journal_entry_id: 'e1',
|
||||
journal_entry_id: entryId,
|
||||
account_number: account,
|
||||
debit_amount: debit,
|
||||
credit_amount: credit,
|
||||
@@ -212,6 +215,114 @@ describe('buildVatSettlementProposal', () => {
|
||||
expect(proposal.existing_entries).toEqual(existing)
|
||||
})
|
||||
|
||||
it('gates on a manual settlement-shaped entry and still proposes the full-period clear (#984)', async () => {
|
||||
const manualSettlement = {
|
||||
id: 'e2', status: 'posted', entry_date: '2026-03-31',
|
||||
source_type: 'manual', voucher_series: 'A', voucher_number: 9,
|
||||
}
|
||||
const supabase = makeClient({
|
||||
entries: [{ id: 'e1' }, manualSettlement],
|
||||
lines: [
|
||||
// Business activity on e1.
|
||||
vatLine('2611', 0, 100),
|
||||
vatLine('2641', 25, 0),
|
||||
// Manual momsomföring on e2: clears 26xx to 2650 without the
|
||||
// vat_settlement source_type (booked before #980 shipped).
|
||||
vatLine('2611', 100, 0, 'e2'),
|
||||
vatLine('2641', 0, 25, 'e2'),
|
||||
vatLine('2650', 0, 75, 'e2'),
|
||||
],
|
||||
})
|
||||
|
||||
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1)
|
||||
|
||||
// The manual settlement is excluded from the projection: the proposal
|
||||
// shows the same full-period clear the report shows, and the posted
|
||||
// shaped entry gates the booking button via existing_entries.
|
||||
expect(proposal.is_empty).toBe(false)
|
||||
expect(proposal.filed_net).toBe(75)
|
||||
expect(proposal.lines).toEqual([
|
||||
{ account_number: '2611', debit_amount: 100, credit_amount: 0 },
|
||||
{ account_number: '2641', debit_amount: 0, credit_amount: 25 },
|
||||
{
|
||||
account_number: '2650', debit_amount: 0, credit_amount: 75,
|
||||
line_description: 'Moms att betala',
|
||||
},
|
||||
])
|
||||
expect(proposal.existing_entries).toEqual([manualSettlement])
|
||||
})
|
||||
|
||||
it('does not gate on a storno of a settlement (annullera must re-enable booking)', async () => {
|
||||
const supabase = makeClient({
|
||||
entries: [
|
||||
{ id: 'e1' },
|
||||
// A manual settlement that has been annulled...
|
||||
{
|
||||
id: 'e2', status: 'reversed', entry_date: '2026-03-31',
|
||||
source_type: 'manual', voucher_series: 'A', voucher_number: 9,
|
||||
},
|
||||
// ...and its storno reversal.
|
||||
{
|
||||
id: 'e3', status: 'posted', entry_date: '2026-03-31',
|
||||
source_type: 'storno', voucher_series: 'A', voucher_number: 10,
|
||||
},
|
||||
],
|
||||
lines: [
|
||||
vatLine('2611', 0, 100),
|
||||
vatLine('2611', 100, 0, 'e2'),
|
||||
vatLine('2650', 0, 100, 'e2'),
|
||||
vatLine('2611', 0, 100, 'e3'),
|
||||
vatLine('2650', 100, 0, 'e3'),
|
||||
],
|
||||
})
|
||||
|
||||
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1)
|
||||
|
||||
// Settlement + storno are both excluded from the projection (they would
|
||||
// otherwise double ruta 10), and neither gates: the period can be
|
||||
// settled again.
|
||||
expect(proposal.existing_entries).toEqual([])
|
||||
expect(proposal.filed_net).toBe(100)
|
||||
expect(proposal.lines).toEqual([
|
||||
{ account_number: '2611', debit_amount: 100, credit_amount: 0 },
|
||||
{
|
||||
account_number: '2650', debit_amount: 0, credit_amount: 100,
|
||||
line_description: 'Moms att betala',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores a plain VAT payment on 2650 (no declaration accounts touched)', async () => {
|
||||
const supabase = makeClient({
|
||||
entries: [
|
||||
{ id: 'e1' },
|
||||
{
|
||||
id: 'e2', status: 'posted', entry_date: '2026-02-12',
|
||||
source_type: 'bank_transaction', voucher_series: 'A', voucher_number: 7,
|
||||
},
|
||||
],
|
||||
lines: [
|
||||
vatLine('2611', 0, 100),
|
||||
// Paying last period's VAT debt: 2650 against the bank account.
|
||||
// Touches a settlement net account but no declaration account, so it
|
||||
// is NOT settlement-shaped: it must neither gate nor shift the rutor.
|
||||
vatLine('2650', 75, 0, 'e2'),
|
||||
],
|
||||
})
|
||||
|
||||
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1)
|
||||
|
||||
expect(proposal.existing_entries).toEqual([])
|
||||
expect(proposal.filed_net).toBe(100)
|
||||
expect(proposal.lines).toEqual([
|
||||
{ account_number: '2611', debit_amount: 100, credit_amount: 0 },
|
||||
{
|
||||
account_number: '2650', debit_amount: 0, credit_amount: 100,
|
||||
line_description: 'Moms att betala',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('throws when the existing-settlement lookup fails (the UI gate depends on it)', async () => {
|
||||
const supabase = makeClient({
|
||||
entries: [{ id: 'e1' }],
|
||||
|
||||
@@ -238,31 +238,73 @@ export async function resolvePeriodDates(
|
||||
return calculatePeriodDates(periodType, year, period)
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts a momsredovisning settles the period's net against: 2650
|
||||
* (Redovisningskonto för moms, att betala) and 1650 (Momsfordran, att återfå).
|
||||
* Mirrors VAT_SETTLEMENT_ACCOUNT/VAT_REFUND_ACCOUNT in vat-settlement.ts,
|
||||
* which imports from this module and therefore cannot be imported here.
|
||||
*/
|
||||
export const VAT_SETTLEMENT_NET_ACCOUNTS = ['2650', '1650']
|
||||
|
||||
/** A momsredovisning entry detected by shape rather than source_type. */
|
||||
export interface VatSettlementShapedEntry {
|
||||
id: string
|
||||
status: string
|
||||
entry_date: string
|
||||
source_type: string | null
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
}
|
||||
|
||||
export interface VatAccountTotals {
|
||||
totals: Map<string, { debit: number; credit: number }>
|
||||
/**
|
||||
* Untagged momsredovisning entries found in the period (manual vouchers,
|
||||
* SIE-imported settlements, stornos of a settlement). Already excluded
|
||||
* from `totals`; surfaced so the settlement proposal can warn and gate.
|
||||
*/
|
||||
settlementShapedEntries: VatSettlementShapedEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and aggregate debit/credit totals per VAT-relevant account
|
||||
* (ACCOUNT_RUTA) for a period. Shared by the declaration calculation and the
|
||||
* settlement proposal (lib/reports/vat-settlement.ts) so the two can never
|
||||
* disagree on which ledger lines count.
|
||||
*
|
||||
* Momsredovisning entries (source_type 'vat_settlement': the verifikat that
|
||||
* clears the 26xx accounts to 2650/1650) are excluded. They are bookkeeping
|
||||
* about the declaration, not VAT-bearing business activity; including them
|
||||
* would zero out the rutor the moment the settlement is booked, turning the
|
||||
* report, its exports, and a later Skatteverket submission into an empty
|
||||
* declaration.
|
||||
* Momsredovisning entries are excluded. They are bookkeeping about the
|
||||
* declaration, not VAT-bearing business activity; including them would zero
|
||||
* out the rutor the moment the settlement is booked, turning the report, its
|
||||
* exports, and a later Skatteverket submission into an empty declaration
|
||||
* (#984). Two detection paths:
|
||||
*
|
||||
* - tagged: source_type 'vat_settlement' (the app's own settlement flow),
|
||||
* filtered in the query;
|
||||
* - shaped: an entry with at least one line on a declaration account
|
||||
* (ACCOUNT_RUTA) and at least one on 2650/1650. This catches settlements
|
||||
* booked before the tagged flow existed, manual vouchers, SIE-imported
|
||||
* settlements, and storno reversals of a settlement (source_type
|
||||
* 'storno', which would otherwise re-inflate the rutor after annullera).
|
||||
*
|
||||
* Opening-balance entries are exempt from the shape rule: 26xx balances
|
||||
* carried in by a migrating company are unsettled VAT that belongs in the
|
||||
* next declaration, even when the same entry carries a 2650/1650 balance.
|
||||
*/
|
||||
export async function fetchVatAccountTotals(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
start: string,
|
||||
end: string
|
||||
): Promise<Map<string, { debit: number; credit: number }>> {
|
||||
): Promise<VatAccountTotals> {
|
||||
const lines = await fetchEntryLines<{
|
||||
journal_entry_id: string
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
journal_entries?: VatSettlementShapedEntry
|
||||
}>({
|
||||
supabase,
|
||||
entryColumns: 'id, status, entry_date, source_type, voucher_series, voucher_number',
|
||||
lineColumns: 'account_number, debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q
|
||||
@@ -271,17 +313,39 @@ export async function fetchVatAccountTotals(
|
||||
.neq('source_type', 'vat_settlement')
|
||||
.gte('entry_date', start)
|
||||
.lte('entry_date', end),
|
||||
filterLines: (q: EntryLinesQuery) => q.in('account_number', VAT_ACCOUNTS),
|
||||
filterLines: (q: EntryLinesQuery) =>
|
||||
q.in('account_number', [...VAT_ACCOUNTS, ...VAT_SETTLEMENT_NET_ACCOUNTS]),
|
||||
})
|
||||
|
||||
// Shape detection: an entry is a settlement when it touches both a
|
||||
// declaration account and a settlement net account (2650/1650).
|
||||
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 shapedById = new Map<string, VatSettlementShapedEntry>()
|
||||
for (const line of lines) {
|
||||
const id = line.journal_entry_id
|
||||
if (!declarationEntryIds.has(id) || !netEntryIds.has(id)) continue
|
||||
const entry = line.journal_entries
|
||||
if (!entry || entry.source_type === 'opening_balance') continue
|
||||
shapedById.set(id, entry)
|
||||
}
|
||||
|
||||
const totals = new Map<string, { debit: number; credit: number }>()
|
||||
for (const line of lines) {
|
||||
if (shapedById.has(line.journal_entry_id)) continue
|
||||
const t = totals.get(line.account_number) || { debit: 0, credit: 0 }
|
||||
t.debit += Number(line.debit_amount) || 0
|
||||
t.credit += Number(line.credit_amount) || 0
|
||||
totals.set(line.account_number, t)
|
||||
}
|
||||
return totals
|
||||
return { totals, settlementShapedEntries: [...shapedById.values()] }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,7 +414,7 @@ export async function calculateVatDeclaration(
|
||||
)
|
||||
|
||||
// Fetch and aggregate posted VAT-account activity for the period
|
||||
const totals = await fetchVatAccountTotals(supabase, companyId, start, end)
|
||||
const { totals } = await fetchVatAccountTotals(supabase, companyId, start, end)
|
||||
|
||||
// Map account balances to momsdeklaration boxes
|
||||
const rutor = rutorFromTotals(totals)
|
||||
|
||||
@@ -50,11 +50,16 @@ export interface VatSettlementProposalLine {
|
||||
line_description?: string
|
||||
}
|
||||
|
||||
/** A vat_settlement entry already booked (or drafted) inside the period. */
|
||||
/**
|
||||
* A settlement entry already booked (or drafted) inside the period: tagged
|
||||
* (source_type 'vat_settlement') or detected by shape (a manual momsomföring
|
||||
* clearing 26xx to 2650/1650, see fetchVatAccountTotals in vat-declaration.ts).
|
||||
*/
|
||||
export interface VatSettlementExistingEntry {
|
||||
id: string
|
||||
status: string
|
||||
entry_date: string
|
||||
source_type: string | null
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
}
|
||||
@@ -103,11 +108,11 @@ export async function buildVatSettlementProposal(
|
||||
supabase, companyId, periodType, year, period, options.fiscalPeriodId
|
||||
)
|
||||
|
||||
const [totals, existingResult] = await Promise.all([
|
||||
const [{ totals, settlementShapedEntries }, existingResult] = await Promise.all([
|
||||
fetchVatAccountTotals(supabase, companyId, start, end),
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id, status, entry_date, voucher_series, voucher_number')
|
||||
.select('id, status, entry_date, source_type, voucher_series, voucher_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source_type', 'vat_settlement')
|
||||
.in('status', ['draft', 'posted'])
|
||||
@@ -126,6 +131,22 @@ export async function buildVatSettlementProposal(
|
||||
)
|
||||
}
|
||||
|
||||
// Shape-detected settlements (manual momsomföring, SIE imports) gate the
|
||||
// booking button exactly like tagged ones (#984): the proposal re-clears
|
||||
// the FULL period, so booking on top of a manual settlement would corrupt
|
||||
// the 26xx balances. Stornos are the CANCELLATION of a settlement and must
|
||||
// not gate, or annullera could never re-enable the button. Only posted
|
||||
// entries gate: the shape of an unposted draft has no balance effect.
|
||||
const shapedExisting = settlementShapedEntries.filter(
|
||||
(e) => e.status === 'posted' && e.source_type !== 'storno'
|
||||
)
|
||||
const existingEntries = [
|
||||
...((existingResult.data ?? []) as VatSettlementExistingEntry[]),
|
||||
...shapedExisting,
|
||||
]
|
||||
.sort((a, b) => (a.entry_date < b.entry_date ? 1 : a.entry_date > b.entry_date ? -1 : 0))
|
||||
.slice(0, 5)
|
||||
|
||||
const rutor = rutorFromTotals(totals)
|
||||
const { net: filedNet } = buildFiledAmounts(rutor)
|
||||
|
||||
@@ -192,6 +213,6 @@ export async function buildVatSettlementProposal(
|
||||
filed_net: filedNet,
|
||||
rounding_amount: roundingAmount,
|
||||
is_empty: lines.length === 0,
|
||||
existing_entries: (existingResult.data ?? []) as VatSettlementExistingEntry[],
|
||||
existing_entries: existingEntries,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user