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)
|
||||
|
||||
Reference in New Issue
Block a user