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:
co-authored by
Claude Opus 4.7
parent
eb77ad50b5
commit
980f29dae8
@@ -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)',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ export interface PayslipData {
|
||||
|
||||
// Employee
|
||||
employeeName: string
|
||||
personnummerMasked: string // XXXXXXXX-XXXX
|
||||
personnummerMasked: string // YYYYMMDD-XXXX
|
||||
employmentType: string
|
||||
|
||||
// Period
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user