Files
accounted/lib/reports/vat-settlement.ts
T
Jakob Wennberg 9d91ee0168 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>
2026-07-11 21:37:30 +02:00

219 lines
8.0 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
import {
fetchVatAccountTotals,
formatPeriodLabel,
resolvePeriodDates,
rutorFromTotals,
VAT_INPUT_ACCOUNTS,
VAT_OUTPUT_ACCOUNTS,
} from './vat-declaration'
import { buildFiledAmounts } from './vat-manual-filing'
import type { VatPeriodType } from '@/types'
/**
* Momsredovisning settlement proposal (issue #980): the verifikat that closes
* a VAT period by clearing every 26xx account the momsrapport reads from into
* the redovisningskonto.
*
* Shape of the proposed entry (standard Swedish momsomföring, booked on the
* period's last day):
* - each output-VAT account (261x/262x/263x incl. reverse charge + import)
* is debited by its period balance, each input-VAT account (264x) is
* credited, at exact öre so the accounts land on zero for the period;
* - the net goes to 2650 (Redovisningskonto för moms, credit = att betala)
* or 1650 (Momsfordran, debit = att återfå) at the WHOLE-KRONA amount the
* declaration is filed with (buildFiledAmounts: öretal faller bort per
* SFL 22 kap 1 §), so 2650/1650 always matches the skattekonto movement;
* - the öre gap between the exact clearing lines and the filed net is
* balanced on 3740 (Öres- och kronutjämning).
*
* This is a PROPOSAL: the user reviews and edits the lines in the journal
* entry form before committing, and the entry books through the normal
* engine (balance validation, period locks, voucher numbering) with
* source_type 'vat_settlement'. That source type is excluded from the
* declaration projection (see fetchVatAccountTotals), so booking the
* settlement never changes the report it was created from.
*/
/** Redovisningskonto för moms: net VAT to pay (credit). */
export const VAT_SETTLEMENT_ACCOUNT = '2650'
/** Momsfordran: net VAT refund (debit). */
export const VAT_REFUND_ACCOUNT = '1650'
/** Öres- och kronutjämning: absorbs the filed whole-krona truncation gap. */
export const VAT_ROUNDING_ACCOUNT = '3740'
export interface VatSettlementProposalLine {
account_number: string
debit_amount: number
credit_amount: number
line_description?: string
}
/**
* 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
}
export interface VatSettlementProposal {
period: {
type: VatPeriodType
year: number
period: number
start: string
end: string
}
/** Swedish period label, e.g. "Kvartal 1 2026" (Skatteverket-bound wording). */
period_label: string
/** Proposed entry date: the period's last day. */
entry_date: string
/** Proposed verifikationstext, e.g. "Momsredovisning Kvartal 1 2026". */
description: string
lines: VatSettlementProposalLine[]
/** Ruta 49 as filed (whole kronor, signed: positive = att betala). */
filed_net: number
/** Signed öre gap balanced on 3740 (positive = credited, negative = debited). */
rounding_amount: number
/** True when the period has no VAT activity to clear. */
is_empty: boolean
existing_entries: VatSettlementExistingEntry[]
}
/**
* Build the settlement verifikat proposal for a VAT period. Reads the same
* aggregated ledger totals as the momsrapport (fetchVatAccountTotals), so the
* proposal always ties out with the report on screen and the filed eSKD/PDF
* amounts.
*/
export async function buildVatSettlementProposal(
supabase: SupabaseClient,
companyId: string,
periodType: VatPeriodType,
year: number,
period: number,
options: { fiscalPeriodId?: string } = {}
): Promise<VatSettlementProposal> {
// Yearly (helårsmoms) resolves to the räkenskapsår bounds when a fiscal
// period is supplied: same resolution as the declaration itself.
const { start, end } = await resolvePeriodDates(
supabase, companyId, periodType, year, period, options.fiscalPeriodId
)
const [{ totals, settlementShapedEntries }, existingResult] = await Promise.all([
fetchVatAccountTotals(supabase, companyId, start, end),
supabase
.from('journal_entries')
.select('id, status, entry_date, source_type, voucher_series, voucher_number')
.eq('company_id', companyId)
.eq('source_type', 'vat_settlement')
.in('status', ['draft', 'posted'])
.gte('entry_date', start)
.lte('entry_date', end)
.order('entry_date', { ascending: false })
.limit(5),
])
// The existing-settlement lookup gates the UI's "already booked" warning
// and its create button; a swallowed error here would silently re-enable
// booking a period that already has a settlement, so fail loud instead.
if (existingResult.error) {
throw new Error(
`existing vat_settlement lookup failed: ${existingResult.error.message}`
)
}
// 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)
// Clear every 26xx account the declaration reads from, at exact öre, so the
// accounts land on zero for the period. A positive (credit) balance clears
// with a debit and vice versa: the same formula handles credit-note-heavy
// periods where an account sits on the "wrong" side.
const clearingAccounts = [...new Set([...VAT_OUTPUT_ACCOUNTS, ...VAT_INPUT_ACCOUNTS])].sort()
const lines: VatSettlementProposalLine[] = []
for (const account of clearingAccounts) {
const t = totals.get(account)
if (!t) continue
const balance = roundOre(t.credit - t.debit)
if (balance > 0) {
lines.push({ account_number: account, debit_amount: balance, credit_amount: 0 })
} else if (balance < 0) {
lines.push({ account_number: account, debit_amount: 0, credit_amount: -balance })
}
}
if (lines.length > 0) {
if (filedNet > 0) {
lines.push({
account_number: VAT_SETTLEMENT_ACCOUNT,
debit_amount: 0,
credit_amount: filedNet,
line_description: 'Moms att betala',
})
} else if (filedNet < 0) {
lines.push({
account_number: VAT_REFUND_ACCOUNT,
debit_amount: -filedNet,
credit_amount: 0,
line_description: 'Moms att återfå',
})
}
}
// Balance the öre/krona gap left by the whole-krona filed net on 3740.
let roundingAmount = 0
if (lines.length > 0) {
const gap = roundOre(
lines.reduce((sum, l) => sum + l.debit_amount - l.credit_amount, 0)
)
if (gap !== 0) {
roundingAmount = gap
lines.push({
account_number: VAT_ROUNDING_ACCOUNT,
debit_amount: gap < 0 ? -gap : 0,
credit_amount: gap > 0 ? gap : 0,
line_description: 'Öres- och kronutjämning',
})
}
}
const periodLabel = formatPeriodLabel(periodType, year, period)
return {
period: { type: periodType, year, period, start, end },
period_label: periodLabel,
entry_date: end,
description: `Momsredovisning ${periodLabel}`,
lines,
filed_net: filedNet,
rounding_amount: roundingAmount,
is_empty: lines.length === 0,
existing_entries: existingEntries,
}
}