9d91ee0168
* 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>
219 lines
8.0 KiB
TypeScript
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,
|
|
}
|
|
}
|