Bug/momsdeklaration skv (#449)

* fix(salary): show birthdate in masked personnummer, hide the 4-digit suffix

Flip the personnummer display format from XXXXXXXX-NNNN to YYYYMMDD-XXXX so
the sensitive 4-digit suffix is hidden while the (public) birthdate stays
visible. Affects the employees list/detail, salary run, payslip PDF, payslip
email, and the MCP server tools (list_employees, get_salary_run). Each call
site now decrypts the stored personnummer before masking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(transactions): allow deleting unbooked transactions from "Alla transaktioner"

The history list only let users delete via the inbox card; once a category or
mall was picked but the verifikation hadn't been created, the row showed
"Ej bokförd" with no way to remove it. The API already permits delete while
journal_entry_id is null, so the gap was purely a missing UI affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(vat): populate ruta 20-24 for reverse charge + dishonest "Validera OK"

Three connected issues caused Skatteverket to reject momsdeklarationer
with FK004 even after our local "Validera"-knapp returned OK.

1. supplier-invoice-entries booked fiktiv moms (2614/2624/2634 + 2645/2647)
   on reverse-charge invoices but never the underlying basbelopp on 44xx/45xx.
   Ruta 30-32 filled up at SKV while ruta 20-24 stayed at 0 — SKV's FK004
   ("silent netting prohibited", ML 13 kap kräver båda sidor).

   Fix: generateReverseChargeBasisLines in vat-entries.ts emits parallel
   45xx/44xx debit + 4598 motkonto credit per rate group. Engine calls it
   from registration, cash, and credit-note paths. Skipped when the user
   booked the expense directly on a basis account to avoid double-counting.
   4598 added to BAS reference (no migration needed; account_number is
   plain text on journal_entry_lines).

2. rutorToMomsuppgift rounded each ruta independently but computed
   summaMoms from the unrounded ruta49. SKV recomputes the sum from
   integer rutor on their side, so fractional öres caused ±1 SEK drift
   and SKV rejected with FK009.

   Fix: derive summaMoms from the already-rounded VAT-amount rutor.

3. "Validera"-knappen only confirmed SKV's internal arithmetic — a
   declaration with ruta 30-32 populated and ruta 20-24 empty validated
   fine until /utkast hit FK004. Users got a false green light.

   Fix: vat-declaration-checks.ts runs locally before the SKV call,
   blocks Validera/Spara when ERROR-level findings exist, and surfaces
   them in a separate "Lokala kontroller"-section. Success message
   reworded so SKV's OK is no longer presented as filing-ready.

Tests: 4535/4536/4531/4425 lines + 4598 motkonto on EU/non-EU/byggtjänster
RC, credit-note reversal, fractional-öres summaMoms, all four pre-flight
codes (RC_BASIS_MISSING, RC_OUTPUT_MISSING, RC_INPUT_VAT_MISMATCH,
SUMMA_MOMS_DRIFT).

Backfill for already-posted entries follows in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: add skattekonto matching functionality

- Enhance TransactionInboxCard to display a warning for potential 1930↔1630 transfers.
- Implement match suggestions for skattekonto transactions in the backend.
- Create SkattekontoMatchDialog component for linking skattekonto rows to existing journal entries.
- Develop SkattekontoInboxCard component to handle skattekonto transactions in the inbox.
- Introduce skattekonto-match utility functions for candidate matching and linking.
- Update types to include match suggestions and enriched transaction responses.

* refactor: reorganize skattekonto types and implement bank counterpart matching logic

* docs: update CLAUDE.md to streamline integrations and clarify architecture details

* refactor: enhance reverse charge logic to handle non-basis accounts and prevent double-counting

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-12 18:04:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent eb77ad50b5
commit 980f29dae8
33 changed files with 3337 additions and 493 deletions
@@ -55,6 +55,23 @@ vi.mock('../vat-entries', () => ({
]
}
),
generateReverseChargeBasisLines: vi.fn().mockImplementation(
(baseAmount: number, vatRate: number = 0.25, supplierType: 'eu_business' | 'non_eu_business' | 'swedish_business') => {
if (baseAmount <= 0) return []
const rateIdx = vatRate === 0.25 ? 0 : vatRate === 0.12 ? 1 : vatRate === 0.06 ? 2 : -1
if (rateIdx < 0) return []
const accounts = {
eu_business: ['4535', '4536', '4537'],
non_eu_business: ['4531', '4532', '4533'],
swedish_business: ['4425', '4426', '4427'],
}[supplierType]
const amount = Math.round(baseAmount * 100) / 100
return [
{ account_number: accounts[rateIdx], debit_amount: amount, credit_amount: 0, line_description: `basbelopp ${vatRate * 100}%` },
{ account_number: '4598', debit_amount: 0, credit_amount: amount, line_description: `motkonto ${vatRate * 100}%` },
]
}
),
}))
const { createJournalEntry, findFiscalPeriod } = await import('../engine')
@@ -178,7 +195,7 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
assertBalanced(input)
})
it('creates EU reverse charge entry at 25%', async () => {
it('creates EU reverse charge entry at 25% with basbelopp on 4535 (ruta 21)', async () => {
const invoice = makeSupplierInvoice({
subtotal: 10000,
vat_amount: 0,
@@ -204,14 +221,74 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
expect(credit2614).toHaveLength(1)
expect(credit2614[0].credit_amount).toBe(2500)
// Basbeloppsrader för ruta 21 (EU tjänster huvudregeln) — utan dessa
// avvisar Skatteverket deklarationen med FK004.
const debit4535 = findByAccount(input.lines, '4535')
expect(debit4535).toHaveLength(1)
expect(debit4535[0].debit_amount).toBe(10000)
const credit4598 = findByAccount(input.lines, '4598')
expect(credit4598).toHaveLength(1)
expect(credit4598[0].credit_amount).toBe(10000)
const credit2440 = findByAccount(input.lines, '2440')
// 2440 = totalDebits - totalCredits = (10000 + 2500) - 2500 = 10000
// The fiktiv moms (D 2645 / C 2614) are offsetting; 2440 only reflects actual supplier debt
// 2440 = totalDebits - totalCredits.
// Debit: 6540 (10 000) + 2645 (2 500) + 4535 (10 000) = 22 500
// Credit: 2614 (2 500) + 4598 (10 000) = 12 500
// 2440 = 22 500 - 12 500 = 10 000 (faktisk leverantörsskuld)
expect(credit2440[0].credit_amount).toBe(10000)
assertBalanced(input)
})
it('books non-EU services to 4531 (ruta 22) and motkonto 4598', async () => {
const invoice = makeSupplierInvoice({
subtotal: 8000,
vat_amount: 0,
total: 8000,
reverse_charge: true,
})
const items = [makeItem({ line_total: 8000, account_number: '6540', vat_rate: 0.25 })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'non_eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '4531')[0].debit_amount).toBe(8000)
expect(findByAccount(input.lines, '4598')[0].credit_amount).toBe(8000)
// No EU-services account when supplier is non-EU
expect(findByAccount(input.lines, '4535')).toHaveLength(0)
assertBalanced(input)
})
it('books domestic RC services to 4425 (ruta 24) and motkonto 4598', async () => {
const invoice = makeSupplierInvoice({
subtotal: 20000,
vat_amount: 0,
total: 20000,
reverse_charge: true,
})
const items = [makeItem({ line_total: 20000, account_number: '4170', vat_rate: 0.25 })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '4425')[0].debit_amount).toBe(20000)
expect(findByAccount(input.lines, '4598')[0].credit_amount).toBe(20000)
// Domestic RC uses 2647, not 2645
expect(findByAccount(input.lines, '2647')[0].debit_amount).toBe(5000)
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(5000)
expect(findByAccount(input.lines, '4535')).toHaveLength(0)
assertBalanced(input)
})
it('creates EU reverse charge entry at reduced 12%', async () => {
const invoice = makeSupplierInvoice({
subtotal: 5000,
@@ -234,6 +311,10 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
expect(credit2624).toHaveLength(1)
expect(credit2624[0].credit_amount).toBe(600)
// 12%-raden går till 4536 (EU tjänster 12%)
expect(findByAccount(input.lines, '4536')[0].debit_amount).toBe(5000)
expect(findByAccount(input.lines, '4598')[0].credit_amount).toBe(5000)
assertBalanced(input)
})
@@ -451,6 +532,13 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
// No regular input VAT
expect(findByAccount(input.lines, '2641')).toHaveLength(0)
// User picked 4425 directly as the expense account, so the engine must
// not add parallel basbeloppsrader on 4425/4598 — that would double the
// basis. Exactly one 4425 line (the user's expense) and zero 4598.
expect(findByAccount(input.lines, '4425')).toHaveLength(1)
expect(findByAccount(input.lines, '4425')[0].debit_amount).toBe(20000)
expect(findByAccount(input.lines, '4598')).toHaveLength(0)
// 2440 = expense only (RC is offsetting)
const credit2440 = findByAccount(input.lines, '2440')
expect(credit2440[0].credit_amount).toBe(20000)
@@ -911,7 +999,7 @@ describe('createSupplierCreditNoteEntry', () => {
assertBalanced(input)
})
it('EU reverse charge reversal (C 2645, D 2614)', async () => {
it('EU reverse charge reversal (C 2645, D 2614, reverses 4535/4598 basis)', async () => {
const creditNote = makeSupplierInvoice({
is_credit_note: true,
subtotal: -10000,
@@ -939,8 +1027,19 @@ describe('createSupplierCreditNoteEntry', () => {
const credit6540 = findByAccount(input.lines, '6540')[0]
expect(credit6540.credit_amount).toBe(10000)
// Reverserade basbeloppsrader: 4535 ska krediteras och 4598 debiteras med
// samma belopp så att kreditfakturan nollställer ruta 21 från originalet.
const credit4535 = findByAccount(input.lines, '4535')[0]
expect(credit4535.credit_amount).toBe(10000)
expect(credit4535.debit_amount).toBe(0)
const debit4598 = findByAccount(input.lines, '4598')[0]
expect(debit4598.debit_amount).toBe(10000)
expect(debit4598.credit_amount).toBe(0)
const debit2440 = findByAccount(input.lines, '2440')[0]
expect(debit2440.debit_amount).toBe(10000) // totalCredits - totalDebits = (2500 + 10000) - 2500
// totalCredits - totalDebits = (2500 + 10000 + 10000) - (2500 + 10000) = 10000
expect(debit2440.debit_amount).toBe(10000)
assertBalanced(input)
})
@@ -573,6 +573,17 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4598',
account_name: 'Motkonto beräknad omvänd moms',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'credit',
description: 'Tekniskt motkonto till beräknad omvänd skattskyldighet (4415-4427, 4515-4537). Nettar ut basbeloppet i resultatrapporten samtidigt som 45xx-konton synliggör underlaget för momsdeklarationens ruta 20-24.',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4600',
account_name: 'Inköp av tjänster, underentreprenader och legoarbeten i Sverige (gruppkonto)',
+111 -3
View File
@@ -1,6 +1,6 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { generateReverseChargeLines } from './vat-entries'
import { generateReverseChargeLines, generateReverseChargeBasisLines } from './vat-entries'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
@@ -13,6 +13,29 @@ import type {
const log = createLogger('supplier-invoice-entries')
/**
* Accounts that already populate momsdeklaration ruta 20-24 directly when
* debited. If the user picked one of these as the expense account on an RC
* invoice item, the engine must NOT add the parallel basbeloppsrader (those
* would double-count the basis).
*/
const RC_BASIS_ACCOUNTS = new Set([
// ruta 20 — EU goods
'4515', '4516', '4517',
// ruta 21 — EU services
'4535', '4536', '4537',
// ruta 22 — non-EU services
'4531', '4532', '4533',
// ruta 23 — domestic goods RC
'4415', '4416', '4417',
// ruta 24 — domestic services RC
'4425', '4426', '4427',
])
function isBasisAccount(account: string): boolean {
return RC_BASIS_ACCOUNTS.has(account)
}
/**
* Build a BFL-compliant verifikation description with event type, counterparty, and suffix.
* Falls back to prefix + invoiceNumber + suffix if name is not provided (backward compat).
@@ -88,11 +111,33 @@ export async function createSupplierInvoiceRegistrationEntry(
if (isReverseCharge) {
// Reverse charge: fiktiv moms entries per rate group
// Domestic (byggtjänster etc.): 2647/26x4, EU/non-EU: 2645/26x4
//
// Also generate basbeloppsrader on 44xx/45xx + motkonto 4598 so SKV's
// momsdeklaration ruta 20-24 reflects the underlying purchase amount.
// Without these the fiktiv moms (2614/2624/2634) populates ruta 30-32
// but ruta 20-24 stay at 0, which Skatteverket rejects with felkod
// FK004 ("silent netting prohibited"; ML 13 kap kräver båda sidor).
//
// The basis-account check is done per (rate, account) bucket: if the user
// booked an item directly to a 44xx/45xx basis account at a given rate,
// that item's belopp already populates ruta 20-24 via the expense line —
// we only emit basbeloppsrader for the portion of that rate's base that
// went to NON-basis accounts. Mixed invoices (4535 + 6540 at 25%) used to
// skip basis lines entirely under a per-invoice flag, leaving ruta 30
// larger than ruta 21 by the 6540 portion — the exact FK004 pattern.
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate)
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
for (const [rate, amount] of vatByRate) {
if (rate > 0 && amount > 0) {
const rcLines = generateReverseChargeLines(amount / rate, rate, isDomesticRC)
const baseAmount = amount / rate
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
lines.push(...rcLines)
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
if (nonBasisBase > 0) {
const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)
lines.push(...basisLines)
}
}
}
} else if (invoice.vat_amount > 0) {
@@ -283,11 +328,26 @@ export async function createSupplierInvoiceCashEntry(
if (isReverseCharge) {
// Reverse charge: fiktiv moms entries per rate group
// Domestic (byggtjänster etc.): 2647/26x4, EU/non-EU: 2645/26x4
//
// Also generate basbeloppsrader on 44xx/45xx + motkonto 4598 so SKV's
// momsdeklaration ruta 20-24 reflects the underlying purchase amount.
// Without these the fiktiv moms (2614/2624/2634) populates ruta 30-32
// but ruta 20-24 stay at 0, which Skatteverket rejects with felkod
// FK004 ("silent netting prohibited"; ML 13 kap kräver båda sidor).
// Per-rate bucketing: see registration entry above for the FK004 rationale.
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate)
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
for (const [rate, amount] of vatByRate) {
if (rate > 0 && amount > 0) {
const rcLines = generateReverseChargeLines(amount / rate, rate, isDomesticRC)
const baseAmount = amount / rate
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
lines.push(...rcLines)
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
if (nonBasisBase > 0) {
const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)
lines.push(...basisLines)
}
}
}
} else if (invoice.vat_amount > 0) {
@@ -379,6 +439,12 @@ export async function createSupplierCreditNoteEntry(
// Input VAT account: 2647 for domestic RC, 2645 for EU/non-EU
const inputAccount = isDomesticRC ? '2647' : '2645'
const vatByRate = groupVatByRate(items, creditNote.currency, creditNote.exchange_rate, true)
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, creditNote.currency, creditNote.exchange_rate, true)
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
// Only reverse basbeloppsraderna for the portion the registration would
// have emitted them — namely the non-basis-account base per rate. Items
// booked directly to 44xx/45xx had no parallel basis lines in registration
// and so are reversed only via the expense credit line above.
for (const [rate, amount] of vatByRate) {
if (rate > 0 && amount > 0) {
// Determine the output account for this rate
@@ -400,6 +466,25 @@ export async function createSupplierCreditNoteEntry(
credit_amount: 0,
line_description: `Omvänd fiktiv utgående moms ${Math.round(rate * 100)}% ${desc}`,
})
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
if (nonBasisBase > 0) {
// Reverse the basbeloppsrader (44xx/45xx debit & 4598 credit on the
// registration entry become credits & debits here). Without this the
// credit note would only undo the VAT amounts (ruta 30-32 + 48) but
// leave ruta 20-24 still showing the original basbelopp — exactly
// the same FK004-style mismatch the registration fix prevents.
const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)
// Swap debit/credit on every basis line so the credit note nets
// against the original registration verifikat.
for (const line of basisLines) {
lines.push({
account_number: line.account_number,
debit_amount: line.credit_amount,
credit_amount: line.debit_amount,
line_description: line.line_description,
})
}
}
}
}
} else {
@@ -461,3 +546,26 @@ function groupVatByRate(
}
return vatByRate
}
/**
* Sum, per VAT rate, the base (line_total in SEK) of items booked to
* non-basis expense accounts. Items already booked to a 44xx/45xx basis
* account populate ruta 20-24 directly via the expense line, so they must be
* excluded here to avoid double-counting in basbeloppsraderna.
*/
function groupNonBasisBaseByRate(
items: SupplierInvoiceItem[],
currency: string,
exchangeRate: number | null,
useAbsoluteValues = false
): Map<number, number> {
const baseByRate = new Map<number, number>()
for (const item of items) {
if (isBasisAccount(item.account_number)) continue
const rate = item.vat_rate ?? 0.25
let itemSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
if (useAbsoluteValues) itemSek = Math.abs(itemSek)
baseByRate.set(rate, (baseByRate.get(rate) || 0) + itemSek)
}
return baseByRate
}
+85
View File
@@ -78,6 +78,91 @@ export function generateSalesVatLines(config: VatEntryConfig): CreateJournalEntr
return lines
}
/**
* Generate reverse-charge basis lines for momsdeklaration ruta 20-24.
*
* The fiktiv-moms pair (2645/26x4 or 2647/26x4) only carries the VAT amounts
* (ruta 30-32 and the offsetting part of ruta 48). The underlying basbelopp
* (vad köpet de facto kostade) must also land on the 44xx/45xx series so
* Skatteverket sees ruta 20-24 populated — ML 13 kap kräver att både underlag
* och moms redovisas. SKV avvisar deklarationer med ruta 30-32 men tom 20-24
* (felkod FK004 "Eftersom det finns ett belopp i någon momsuppgift som avser
* utgående moms på inköp (30-32) måste det finnas ett belopp i någon av
* momsuppgifterna avseende momspliktiga inköp vid omvänd betalningsskyldighet
* (20-24)").
*
* Användarens valda kostnadskonto (t.ex. 6540) bibehålls i resultaträkningen
* via en parallell motkonto-rad: 45xx debiteras, 4598 krediteras med samma
* belopp. Resultaträkningen påverkas inte (4598 nettar ut 45xx), men 45xx
* fångas av momsdeklarationsberäkningen för rätt ruta 20-24.
*
* Konto-mappning (BAS 2026 + swedish-vat reference §7):
*
* EU services (huvudregeln) 4535/4536/4537 → ruta 21
* Non-EU services 4531/4532/4533 → ruta 22
* Domestic services (byggtjänster) 4425/4426/4427 → ruta 24
* Domestic goods (RC varor) 4415/4416/4417 → ruta 23
*
* EU-varor (ruta 20, 4515/4516/4517) hanteras inte här eftersom våra supplier
* invoices saknar varor/tjänster-diskriminering. Standard-supplier-flödet är
* tjänster (SaaS, konsulttjänster); EU-varuhandel sker normalt via SIE-import
* eller manuell verifikation och får bokas direkt på 4515-konton.
*/
export function generateReverseChargeBasisLines(
baseAmount: number,
vatRate: number = 0.25,
supplierType: 'eu_business' | 'non_eu_business' | 'swedish_business',
): CreateJournalEntryLineInput[] {
if (baseAmount <= 0) return []
const basisAccount = pickBasisAccount(vatRate, supplierType)
if (!basisAccount) return []
const amount = Math.round(baseAmount * 100) / 100
const rateLabel = `${Math.round(vatRate * 100)}%`
return [
{
account_number: basisAccount.account,
debit_amount: amount,
credit_amount: 0,
line_description: `${basisAccount.label} ${rateLabel} (basbelopp omvänd skattskyldighet)`,
},
{
account_number: '4598',
debit_amount: 0,
credit_amount: amount,
line_description: `Motkonto beräknad omvänd moms ${rateLabel}`,
},
]
}
function pickBasisAccount(
vatRate: number,
supplierType: 'eu_business' | 'non_eu_business' | 'swedish_business',
): { account: string; label: string } | null {
const rateIdx = vatRate === 0.25 ? 0 : vatRate === 0.12 ? 1 : vatRate === 0.06 ? 2 : -1
if (rateIdx < 0) return null
if (supplierType === 'eu_business') {
return {
account: ['4535', '4536', '4537'][rateIdx],
label: 'Inköp tjänster annat EU-land',
}
}
if (supplierType === 'non_eu_business') {
return {
account: ['4531', '4532', '4533'][rateIdx],
label: 'Inköp tjänster land utanför EU',
}
}
// swedish_business — domestic RC (byggtjänster m.m.)
return {
account: ['4425', '4426', '4427'][rateIdx],
label: 'Inköp tjänster i Sverige omvänd skattskyldighet',
}
}
/**
* Generate reverse charge lines (fiktiv moms)
* For EU/non-EU purchases: Debit 2645 + Credit 26x4 (offsetting entries)
@@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest'
import type { VatDeclarationRutor } from '@/types'
import { runVatDeclarationChecks } from '../vat-declaration-checks'
const emptyRutor: VatDeclarationRutor = {
ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0,
ruta10: 0, ruta11: 0, ruta12: 0,
ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0,
ruta30: 0, ruta31: 0, ruta32: 0,
ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0,
ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0,
ruta48: 0, ruta49: 0,
ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0,
}
describe('runVatDeclarationChecks', () => {
it('returns empty findings for a balanced sales-only declaration', () => {
const rutor: VatDeclarationRutor = {
...emptyRutor,
ruta05: 100000,
ruta10: 25000,
ruta49: 25000,
}
expect(runVatDeclarationChecks(rutor)).toEqual([])
})
it('returns empty findings for a balanced declaration with RC basis + output VAT', () => {
const rutor: VatDeclarationRutor = {
...emptyRutor,
ruta21: 10000, // EU services basis
ruta30: 2500, // RC output VAT
ruta48: 2500, // matching input VAT
ruta49: 0,
}
expect(runVatDeclarationChecks(rutor)).toEqual([])
})
// FK004 mirror: SKV's primary rejection signal we want to catch locally.
it('flags ERROR when ruta 30-32 populated but ruta 20-24 is empty', () => {
const rutor: VatDeclarationRutor = {
...emptyRutor,
ruta05: 78852,
ruta10: 19713,
ruta30: 2500,
ruta48: 2500,
ruta49: 19713,
}
const findings = runVatDeclarationChecks(rutor)
const fk004 = findings.find((f) => f.code === 'RC_BASIS_MISSING')
expect(fk004).toBeDefined()
expect(fk004?.status).toBe('ERROR')
expect(fk004?.message).toMatch(/ruta 30-32/)
expect(fk004?.message).toMatch(/ruta 20-24/)
})
it('flags ERROR when basis is present but no output RC VAT', () => {
const rutor: VatDeclarationRutor = {
...emptyRutor,
ruta21: 10000,
ruta48: 2500,
ruta49: -2500,
}
const findings = runVatDeclarationChecks(rutor)
expect(findings.find((f) => f.code === 'RC_OUTPUT_MISSING')?.status).toBe('ERROR')
})
it('warns when input VAT is materially smaller than RC output VAT', () => {
const rutor: VatDeclarationRutor = {
...emptyRutor,
ruta21: 10000,
ruta30: 2500,
ruta48: 100, // Calculated input VAT missing — should be ~2500
ruta49: 2400,
}
const findings = runVatDeclarationChecks(rutor)
const mismatch = findings.find((f) => f.code === 'RC_INPUT_VAT_MISMATCH')
expect(mismatch?.status).toBe('WARNING')
})
// FK009 detection: if our calculator and SKV's recomputed sum disagree
// we flag locally so we never submit a drift.
it('flags ERROR when ruta49 drifts from the canonical formula', () => {
const rutor: VatDeclarationRutor = {
...emptyRutor,
ruta10: 100,
ruta48: 20,
ruta49: 99, // wrong — should be 80
}
const findings = runVatDeclarationChecks(rutor)
const drift = findings.find((f) => f.code === 'SUMMA_MOMS_DRIFT')
expect(drift?.status).toBe('ERROR')
})
it('ignores fractional-öre drift (≤ 0.5 SEK)', () => {
const rutor: VatDeclarationRutor = {
...emptyRutor,
ruta10: 100.30,
ruta48: 20.10,
ruta49: 80.20, // canonical formula exactly, only fractional öre
}
const findings = runVatDeclarationChecks(rutor)
expect(findings.find((f) => f.code === 'SUMMA_MOMS_DRIFT')).toBeUndefined()
})
})
+140
View File
@@ -0,0 +1,140 @@
import type { VatDeclarationRutor } from '@/types'
/**
* Local pre-flight checks for the momsdeklaration, run BEFORE the SKV
* /kontrollera or /utkast calls.
*
* Why we need this: Skatteverket's "validering" only confirms that the
* payload is internally arithmetically consistent — it does NOT confirm
* that the declaration reflects reality. A declaration of all zeros
* validates fine; one with output VAT but no underlying purchases
* validates fine too, until the gateway-level FK004 rule fires.
*
* The checks below catch the patterns we have seen in practice where
* "Validera" returned OK but the declaration was wrong:
*
* - Reverse charge: ruta 30-32 populated but ruta 20-24 empty. Caused by
* supplier invoices flagged as reverse charge that booked the fiktiv
* moms (2614/2624/2634) without the parallel basis lines on 44xx/45xx.
* Fixed at the data layer by generateReverseChargeBasisLines, but we
* keep the check here as a safety net for legacy verifikat and direct
* journal entries that bypass the supplier invoice flow.
*
* - Reverse charge: ruta 20-24 populated but ruta 30-32 empty. The mirror
* case — basis booked but fiktiv moms missing. Less common but equally
* broken.
*
* - Mismatch between output RC VAT (ruta 30-32) and offsetting input VAT
* in ruta 48. The 2614/2645 (or 2647) pair must net to zero in the
* buyer's input deduction. A mismatch indicates one half of the pair
* was booked without the other.
*
* Output is consumed by the UI; ERROR findings should block "Skicka",
* WARNING findings should surface but allow the user to proceed if they
* understand the reason.
*/
export type VatDeclarationCheckStatus = 'ERROR' | 'WARNING'
export interface VatDeclarationCheck {
/** Stable identifier so the UI can render specific guidance per rule. */
code:
| 'RC_BASIS_MISSING'
| 'RC_OUTPUT_MISSING'
| 'RC_INPUT_VAT_MISMATCH'
| 'SUMMA_MOMS_DRIFT'
status: VatDeclarationCheckStatus
/** Swedish user-facing message; safe to render directly in the UI. */
message: string
/** Optional rutor that the user should investigate. */
rutor?: Array<keyof VatDeclarationRutor>
}
/**
* Run all local checks against a calculated VatDeclarationRutor.
*
* Returns an empty array when the declaration looks consistent. Order
* within the returned array is stable so the UI can rely on it for
* snapshot tests.
*/
export function runVatDeclarationChecks(rutor: VatDeclarationRutor): VatDeclarationCheck[] {
const findings: VatDeclarationCheck[] = []
const rcOutput = rutor.ruta30 + rutor.ruta31 + rutor.ruta32
const rcBasis =
rutor.ruta20 + rutor.ruta21 + rutor.ruta22 + rutor.ruta23 + rutor.ruta24
// Use a 0.5 SEK epsilon — values are rounded to öres in the calculator
// and we don't want a 0.01 rounding scrap to trip a sanity check.
const eps = 0.5
// FK004 mirror: output RC VAT exists, basis missing.
if (rcOutput > eps && rcBasis <= eps) {
findings.push({
code: 'RC_BASIS_MISSING',
status: 'ERROR',
message:
'Du har redovisat utgående moms på inköp (ruta 30-32) men inget ' +
'basbelopp för omvänd skattskyldighet (ruta 20-24). Skatteverket ' +
'kräver att båda sidor finns med (ML 13 kap; SKV felkod FK004). ' +
'Kontrollera att leverantörsfakturor med omvänd skattskyldighet ' +
'är bokförda med basbelopp på 44xx/45xx-konton.',
rutor: ['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24', 'ruta30', 'ruta31', 'ruta32'],
})
}
// Mirror: basis present but no output VAT — equally broken, often a
// half-finished manual posting.
if (rcBasis > eps && rcOutput <= eps) {
findings.push({
code: 'RC_OUTPUT_MISSING',
status: 'ERROR',
message:
'Du har redovisat basbelopp för omvänd skattskyldighet (ruta 20-24) ' +
'men ingen utgående moms (ruta 30-32). Vid omvänd skattskyldighet ' +
'måste köparen redovisa både underlag och fiktiv moms (ML 13 kap). ' +
'Kontrollera att fiktiv moms är bokförd på 2614/2624/2634.',
rutor: ['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24', 'ruta30', 'ruta31', 'ruta32'],
})
}
// The fiktiv-moms-pair must net to zero in the buyer's input deduction.
// We can't isolate the RC portion of ruta 48 without the breakdown, but
// we can flag when ruta 48 is smaller than rcOutput — that means the
// RC purchase didn't fully recover the calculated input VAT, which is
// a strong signal that one half of the 2645/2614 pair is missing.
if (rcOutput > eps && rutor.ruta48 + eps < rcOutput) {
findings.push({
code: 'RC_INPUT_VAT_MISMATCH',
status: 'WARNING',
message:
'Utgående moms på omvänd skattskyldighet (ruta 30-32) är högre än ' +
'avdragsgill ingående moms (ruta 48). Vid full avdragsrätt ska ' +
'beräknad ingående moms (2645/2647) nolla ut den fiktiva utgående ' +
'momsen. Kontrollera att 2645/2647 är bokförd för varje 2614/2624/2634-rad.',
rutor: ['ruta30', 'ruta31', 'ruta32', 'ruta48'],
})
}
// SummaMoms drift — sanity check that our local ruta49 matches what the
// mapper will send. If this fires, the calculator and mapper disagree
// and we'd hit SKV's FK009.
const expectedRuta49 =
rutor.ruta10 + rutor.ruta11 + rutor.ruta12 +
rutor.ruta30 + rutor.ruta31 + rutor.ruta32 +
rutor.ruta60 + rutor.ruta61 + rutor.ruta62 -
rutor.ruta48
if (Math.abs(expectedRuta49 - rutor.ruta49) > eps) {
findings.push({
code: 'SUMMA_MOMS_DRIFT',
status: 'ERROR',
message:
'Beräknad ruta 49 (moms att betala) stämmer inte överens med summan ' +
'av övriga rutor. Detta tyder på avrundningsfel i bokföringen. ' +
'Kontrollera huvudboken för perioden innan inlämning.',
rutor: ['ruta49'],
})
}
return findings
}
+6 -2
View File
@@ -80,8 +80,12 @@ describe('calculateAgeAtYearStart', () => {
})
describe('maskPersonnummer', () => {
it('masks with XXXXXXXX-XXXX format', () => {
expect(maskPersonnummer('9802')).toBe('XXXXXXXX-9802')
it('shows birthdate and masks the 4-digit suffix', () => {
expect(maskPersonnummer('199001019802')).toBe('19900101-XXXX')
})
it('strips non-digits before masking', () => {
expect(maskPersonnummer('19900101-9802')).toBe('19900101-XXXX')
})
})
+1 -1
View File
@@ -203,7 +203,7 @@ export interface PayslipData {
// Employee
employeeName: string
personnummerMasked: string // XXXXXXXX-XXXX
personnummerMasked: string // YYYYMMDD-XXXX
employmentType: string
// Period
+4 -3
View File
@@ -151,10 +151,11 @@ export function calculateAgeAtYearStart(personnummer: string, year: number): num
}
/**
* Mask personnummer for display: XXXXXXXX-XXXX
* Mask personnummer for display: YYYYMMDD-XXXX (birthdate visible, suffix hidden).
*/
export function maskPersonnummer(last4: string): string {
return `XXXXXXXX-${last4}`
export function maskPersonnummer(personnummer: string): string {
const digits = personnummer.replace(/\D/g, '')
return `${digits.slice(0, 8)}-XXXX`
}
/**
@@ -0,0 +1,143 @@
import { describe, it, expect } from 'vitest'
import {
findBankSkvCounterparts,
BANK_SKV_DATE_WINDOW_DAYS,
} from '../bank-counterpart'
import type { StoredSkattekontoTransaction } from '@/types/skatteverket'
function skv(
partial: Partial<StoredSkattekontoTransaction> &
Pick<StoredSkattekontoTransaction, 'id' | 'transaktionsdatum' | 'belopp_skatteverket'>,
): Pick<StoredSkattekontoTransaction, 'id' | 'transaktionsdatum' | 'belopp_skatteverket'> {
return partial
}
describe('findBankSkvCounterparts', () => {
it('pairs a -5000 bank outflow with a +5000 SKV inflow within window', () => {
const result = findBankSkvCounterparts({
bankRows: [{ id: 'bank-1', date: '2026-03-16', amount: -5000 }],
skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-17', belopp_skatteverket: 5000 })],
})
expect(result.get('bank-1')).toBe('2026-03-17')
})
it('pairs a +5000 bank inflow (refund) with a -5000 SKV outflow', () => {
const result = findBankSkvCounterparts({
bankRows: [{ id: 'bank-1', date: '2026-03-20', amount: 5000 }],
skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-19', belopp_skatteverket: -5000 })],
})
expect(result.get('bank-1')).toBe('2026-03-19')
})
it('does NOT pair when signs are equal (not a transfer)', () => {
// Bank -5000 (outgoing) and SKV -5000 (outgoing from skattekonto)
// would mean the user both paid 5000 from bank AND was charged 5000
// by SKV. Not the same event — independent cash flows.
const result = findBankSkvCounterparts({
bankRows: [{ id: 'bank-1', date: '2026-03-16', amount: -5000 }],
skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-17', belopp_skatteverket: -5000 })],
})
expect(result.has('bank-1')).toBe(false)
})
it('does NOT pair when amounts differ even slightly', () => {
const result = findBankSkvCounterparts({
bankRows: [{ id: 'bank-1', date: '2026-03-16', amount: -5000 }],
skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-17', belopp_skatteverket: 5000.01 })],
})
expect(result.has('bank-1')).toBe(false)
})
it('rounds to öre — 5000.001 equals 5000', () => {
const result = findBankSkvCounterparts({
bankRows: [{ id: 'bank-1', date: '2026-03-16', amount: -5000 }],
skvRows: [
skv({ id: 'skv-1', transaktionsdatum: '2026-03-17', belopp_skatteverket: 5000.001 }),
],
})
expect(result.get('bank-1')).toBe('2026-03-17')
})
it('does NOT pair when SKV date is outside the ±14 day window', () => {
const result = findBankSkvCounterparts({
bankRows: [{ id: 'bank-1', date: '2026-03-01', amount: -5000 }],
skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-20', belopp_skatteverket: 5000 })],
})
expect(result.has('bank-1')).toBe(false)
})
it('pairs at the exact 14-day boundary', () => {
const result = findBankSkvCounterparts({
bankRows: [{ id: 'bank-1', date: '2026-03-01', amount: -5000 }],
skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-15', belopp_skatteverket: 5000 })],
})
expect(result.get('bank-1')).toBe('2026-03-15')
})
it('first plausible match wins when multiple SKV rows would qualify', () => {
// Two SKV inflows of 5000 within window — the first one (in iteration
// order) wins. UI only shows one hint, so we don't need to rank.
const result = findBankSkvCounterparts({
bankRows: [{ id: 'bank-1', date: '2026-03-16', amount: -5000 }],
skvRows: [
skv({ id: 'skv-a', transaktionsdatum: '2026-03-17', belopp_skatteverket: 5000 }),
skv({ id: 'skv-b', transaktionsdatum: '2026-03-15', belopp_skatteverket: 5000 }),
],
})
expect(result.get('bank-1')).toBe('2026-03-17')
})
it('handles multiple bank txs independently', () => {
const result = findBankSkvCounterparts({
bankRows: [
{ id: 'bank-a', date: '2026-03-16', amount: -5000 },
{ id: 'bank-b', date: '2026-04-16', amount: -3000 },
],
skvRows: [
skv({ id: 'skv-1', transaktionsdatum: '2026-03-17', belopp_skatteverket: 5000 }),
skv({ id: 'skv-2', transaktionsdatum: '2026-04-18', belopp_skatteverket: 3000 }),
],
})
expect(result.size).toBe(2)
expect(result.get('bank-a')).toBe('2026-03-17')
expect(result.get('bank-b')).toBe('2026-04-18')
})
it('ignores zero-amount bank tx', () => {
const result = findBankSkvCounterparts({
bankRows: [{ id: 'bank-1', date: '2026-03-16', amount: 0 }],
skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-17', belopp_skatteverket: 0 })],
})
expect(result.has('bank-1')).toBe(false)
})
it('returns empty map when no SKV rows are provided', () => {
const result = findBankSkvCounterparts({
bankRows: [{ id: 'bank-1', date: '2026-03-16', amount: -5000 }],
skvRows: [],
})
expect(result.size).toBe(0)
})
it('returns empty map when no bank rows are provided', () => {
const result = findBankSkvCounterparts({
bankRows: [],
skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-17', belopp_skatteverket: 5000 })],
})
expect(result.size).toBe(0)
})
it('respects a custom dateWindowDays override', () => {
// 20 days apart — would fail default window, passes with override.
const result = findBankSkvCounterparts({
bankRows: [{ id: 'bank-1', date: '2026-03-01', amount: -5000 }],
skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-21', belopp_skatteverket: 5000 })],
dateWindowDays: 30,
})
expect(result.get('bank-1')).toBe('2026-03-21')
})
it('exposes a sensible default window constant', () => {
expect(BANK_SKV_DATE_WINDOW_DAYS).toBe(14)
})
})
+76
View File
@@ -0,0 +1,76 @@
import type { StoredSkattekontoTransaction } from '@/types/skatteverket'
/**
* Heuristic for spotting a 1930↔1630-transfer that has been observed
* from both sides (bank PSD2 + Skatteverket API).
*
* Used to render a passive dublett-varning on the bank-tx card in
* /transactions so the user doesn't book the same transfer twice (once
* via /transactions, once via /skattekonto).
*
* Rule, intentionally conservative:
* - Equal absolute amount (rounded to öre)
* - Opposite signs (a transfer looks like -X on bank, +X on SKV — or
* vice versa for a refund). Same-sign pairs are unrelated cash flows
* that happen to share an amount.
* - Transaktionsdatum within ±DATE_WINDOW_DAYS of bank.date. Real
* settlement is usually 1–3 working days but we widen the window to
* handle weekends and holidays.
*
* The function is non-blocking: false positives just mean an extra
* warning panel the user can ignore. False negatives mean no warning
* (user might double-book — but the SKV row will still have its own
* `match_suggestion` once they book one side, so the dublett-flow has a
* second chance to fire).
*/
export const BANK_SKV_DATE_WINDOW_DAYS = 14
interface BankCounterpartInput {
/** Bank transactions that are still uncategorized (inbox candidates). */
bankRows: ReadonlyArray<{ id: string; date: string; amount: number }>
/** SKV rows that have not been linked to a verifikat yet. */
skvRows: ReadonlyArray<
Pick<StoredSkattekontoTransaction, 'id' | 'transaktionsdatum' | 'belopp_skatteverket'>
>
/** Override for testing — defaults to BANK_SKV_DATE_WINDOW_DAYS. */
dateWindowDays?: number
}
function isoToTime(iso: string): number {
return new Date(iso + 'T00:00:00Z').getTime()
}
function diffDays(a: string, b: string): number {
return Math.abs(isoToTime(a) - isoToTime(b)) / 86_400_000
}
/**
* Map each bank tx that has a plausible SKV counterpart to that SKV row's
* transaktionsdatum. First plausible match wins per bank tx — we don't
* return a ranked list since the UI only renders a single hint per card.
*/
export function findBankSkvCounterparts({
bankRows,
skvRows,
dateWindowDays = BANK_SKV_DATE_WINDOW_DAYS,
}: BankCounterpartInput): Map<string, string> {
const result = new Map<string, string>()
if (skvRows.length === 0 || bankRows.length === 0) return result
for (const tx of bankRows) {
const txAmount = Math.round(Math.abs(tx.amount) * 100) / 100
if (txAmount === 0) continue
const txSign = Math.sign(tx.amount)
for (const r of skvRows) {
const skvAmount = Math.round(Math.abs(Number(r.belopp_skatteverket)) * 100) / 100
if (skvAmount !== txAmount) continue
// Transfer scenario: bank-side and SKV-side have opposite signs.
if (txSign === Math.sign(Number(r.belopp_skatteverket))) continue
if (diffDays(r.transaktionsdatum, tx.date) > dateWindowDays) continue
result.set(tx.id, r.transaktionsdatum)
break
}
}
return result
}