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
+2
View File
@@ -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 },
+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
@@ -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)
})
})
+125 -14
View File
@@ -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' }],
+74 -10
View File
@@ -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)
+25 -4
View File
@@ -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,
}
}