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)