Files
accounted/lib/bookkeeping/vat-entries.ts
T
MattssonandClaude Opus 4.7 980f29dae8 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>
2026-05-12 18:04:48 +02:00

250 lines
7.7 KiB
TypeScript

import type { CreateJournalEntryLineInput, VatTreatment } from '@/types'
/**
* Generate VAT journal entry lines based on VAT treatment
*
* Swedish VAT scenarios:
* - Domestic 25%: Credit 2611 (utgående moms)
* - Domestic 12%: Credit 2621
* - Domestic 6%: Credit 2631
* - Input VAT deduction: Debit 2641 (ingående moms)
* - EU reverse charge (fiktiv moms): Debit 2645, Credit 2614 (offsetting)
* - Export (non-EU): No VAT lines
*/
interface VatEntryConfig {
vatTreatment: VatTreatment
baseAmount: number // Amount before VAT
direction: 'sales' | 'purchase'
}
/**
* Get VAT rate from treatment
*/
export function getVatRate(treatment: VatTreatment): number {
switch (treatment) {
case 'standard_25':
return 0.25
case 'reduced_12':
return 0.12
case 'reduced_6':
return 0.06
case 'reverse_charge':
case 'export':
case 'exempt':
return 0
default:
return 0.25
}
}
/**
* Generate output VAT lines for sales invoices
* Debit 1510 Kundfordringar [total incl VAT]
* Credit 30xx Försäljning [subtotal]
* Credit 26xx Utgående moms [vat_amount]
*/
export function generateSalesVatLines(config: VatEntryConfig): CreateJournalEntryLineInput[] {
const lines: CreateJournalEntryLineInput[] = []
const vatRate = getVatRate(config.vatTreatment)
if (vatRate === 0) return lines
const vatAmount = Math.round(config.baseAmount * vatRate * 100) / 100
// Determine the output VAT account
let vatAccount: string
switch (config.vatTreatment) {
case 'standard_25':
vatAccount = '2611' // Utgående moms försäljning 25%
break
case 'reduced_12':
vatAccount = '2621' // Utgående moms försäljning 12%
break
case 'reduced_6':
vatAccount = '2631' // Utgående moms försäljning 6%
break
default:
return lines
}
lines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: vatAmount,
line_description: `Utgående moms ${vatRate * 100}%`,
})
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)
* For domestic reverse charge: Debit 2647 + Credit 26x4 (offsetting entries)
*/
export function generateReverseChargeLines(
baseAmount: number,
vatRate: number = 0.25,
isDomestic: boolean = false
): CreateJournalEntryLineInput[] {
const vatAmount = Math.round(baseAmount * vatRate * 100) / 100
// Determine output account based on rate
let outputAccount: string
switch (vatRate) {
case 0.25:
outputAccount = '2614' // Utgående moms omvänd skattskyldighet 25%
break
case 0.12:
outputAccount = '2624' // Utgående moms omvänd skattskyldighet 12%
break
case 0.06:
outputAccount = '2634' // Utgående moms omvänd skattskyldighet 6%
break
default:
outputAccount = '2614'
}
// Input VAT account: 2647 for domestic RC (ML 16 kap), 2645 for EU/non-EU
const inputAccount = isDomestic ? '2647' : '2645'
const context = isDomestic ? 'omvänd skattskyldighet i Sverige' : 'omvänd skattskyldighet'
return [
{
account_number: inputAccount,
debit_amount: vatAmount,
credit_amount: 0,
line_description: `Fiktiv ingående moms ${vatRate * 100}% (${context})`,
},
{
account_number: outputAccount,
debit_amount: 0,
credit_amount: vatAmount,
line_description: `Fiktiv utgående moms ${vatRate * 100}% (${context})`,
},
]
}
/**
* Generate input VAT deduction line for domestic purchases
* Debit 2641 Ingående moms
*/
export function generateInputVatLine(
totalAmount: number,
vatRate: number = 0.25
): CreateJournalEntryLineInput | null {
if (vatRate === 0) return null
// Extract VAT from total amount (VAT-inclusive)
const vatAmount = Math.round((totalAmount * vatRate) / (1 + vatRate) * 100) / 100
return {
account_number: '2641', // Debiterad ingående moms
debit_amount: vatAmount,
credit_amount: 0,
line_description: `Ingående moms ${vatRate * 100}%`,
}
}
/**
* Calculate the net amount (excl VAT) from a total amount
*/
export function extractNetAmount(totalAmount: number, vatRate: number): number {
if (vatRate === 0) return totalAmount
return Math.round((totalAmount / (1 + vatRate)) * 100) / 100
}
/**
* Calculate VAT amount from a total amount (VAT-inclusive)
*/
export function extractVatAmount(totalAmount: number, vatRate: number): number {
if (vatRate === 0) return 0
return Math.round((totalAmount - totalAmount / (1 + vatRate)) * 100) / 100
}