Bug/momsdek overflow (#465)
* feat(settings): add option for company name position in invoice PDF * feat(migrations): add backfill for VAT account labels to correct bad seed data * feat(migrations): add backfill for VAT account labels to correct bad seed data * fix(ui): improve accessibility for company name position toggle in PDF settings * fix(bookkeeping): align BAS 2026 reference data with official PDF Reconciled lib/bookkeeping/bas-data/ against the BAS 2026 v1.1 official chart (1286 accounts). All real discrepancies fixed: - 2089 Fond för utvecklingsutgifter: k2_excluded → true - 8417 Räntekostnader för dold räntekompensation: k2_excluded → true - 1250, 1260 renamed to "(Fritt konto för Inventarier, verktyg och installationer)" — BAS 2026 freed these slots - Periodiseringsfond 2120-2139: added year suffixes (2120 = "...2020" etc.) and added 8 missing accounts (2121-2127, 2129) for years 2019, 2021-2027. Dropped phantom 2022/2024 prior-parser garbage. - 4075-4078: EUland → EU-land - 8411: förlagsoch → förlags- och Verified: 1282 of 1286 PDF accounts match exactly after edits (remaining 4 are PDF-parser artifacts, not real data). Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(migrations): backfill BAS 2026 account labels in chart_of_accounts Companion to the TS reference fix. Updates existing companies' rows where they still carry seed-data typos or generic names that don't match BAS 2026: - 4075-4078: EUland → EU-land (hyphenation) - 8411: förlagsoch → förlags- och (hyphenation) - 2120, 2130-2137, 2139: rename "Periodiseringsfond" (generic, no year) to the BAS 2026 canonical name with year suffix Defensive: every WHERE clause matches an EXACT current value. Rows that have been manually renamed by users — including those with a wrong year that may reference legacy fonds from an earlier BAS numbering cycle — are left untouched. No row is deleted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(migrations): fix wrong-year labels on Periodiseringsfond accounts Follow-up to 20260513140000. The first backfill only renamed accounts whose name was the generic "Periodiseringsfond" (no year). Many customers were seeded from an older BAS numbering cycle where 2126 = "2016", 2127 = "2017", etc. — BAS 2026 reuses those account numbers for years 2026/2027. This migration aligns the year tag with the BAS 2026 meaning of each account number across 2120-2127, 2129, 2130-2137, 2139. Only rows whose name still starts with "Periodiseringsfond" are touched — customers who renamed the account to something custom keep their name. Verified on staging: all 18 accounts now carry BAS 2026 canonical names. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(data): update account names and descriptions for clarity and consistency * fix(migrations): backfill account names for BAS 2026 freed accounts and refine Periodiseringsfond name matching * feat: enhance VAT handling with reverse charge logic and supplier type support * feat: implement VAT declaration validation rules and enhance moms box mapping --------- 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
9e0b89ee7e
commit
da39eb2d43
@@ -316,18 +316,80 @@ describe('buildMappingResultFromTemplate', () => {
|
||||
expect(result.vat_lines[0].debit_amount).toBe(30) // 530 * 0.06 / 1.06 = 30
|
||||
})
|
||||
|
||||
it('produces reverse charge lines for EU purchases', () => {
|
||||
it('produces reverse charge lines for EU purchases (fiktiv moms + basbelopp)', () => {
|
||||
const template = getTemplate('it_saas_eu')
|
||||
const tx = makeTransaction({ amount: -1000 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(2)
|
||||
// Fiktiv ingående moms
|
||||
// Four lines: fiktiv-moms pair + basbelopp pair. Without the basbelopp
|
||||
// pair the deklaration is rejected with FK004 (ruta 30-32 without 20-24).
|
||||
expect(result.vat_lines).toHaveLength(4)
|
||||
// Fiktiv ingående moms (EU: 2645)
|
||||
expect(result.vat_lines[0].account_number).toBe('2645')
|
||||
expect(result.vat_lines[0].debit_amount).toBe(250)
|
||||
// Fiktiv utgående moms
|
||||
// Fiktiv utgående moms (25%: 2614)
|
||||
expect(result.vat_lines[1].account_number).toBe('2614')
|
||||
expect(result.vat_lines[1].credit_amount).toBe(250)
|
||||
// Basbelopp EU services 25% → ruta 21
|
||||
expect(result.vat_lines[2].account_number).toBe('4535')
|
||||
expect(result.vat_lines[2].debit_amount).toBe(1000)
|
||||
// Motkonto basbelopp
|
||||
expect(result.vat_lines[3].account_number).toBe('4598')
|
||||
expect(result.vat_lines[3].credit_amount).toBe(1000)
|
||||
})
|
||||
|
||||
it('defaults to eu_business supplier type when not set on template', () => {
|
||||
// it_cloud_hosting has no explicit reverse_charge_supplier_type
|
||||
const template = getTemplate('it_cloud_hosting')
|
||||
const tx = makeTransaction({ amount: -800 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(4)
|
||||
// Defaults to EU services → 4535
|
||||
expect(result.vat_lines[2].account_number).toBe('4535')
|
||||
expect(result.vat_lines[2].debit_amount).toBe(800)
|
||||
})
|
||||
|
||||
it('uses 4531 basbelopp for non-EU supplier type', () => {
|
||||
const template: BookingTemplate = {
|
||||
...getTemplate('it_cloud_hosting'),
|
||||
reverse_charge_supplier_type: 'non_eu_business',
|
||||
}
|
||||
const tx = makeTransaction({ amount: -1000 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(4)
|
||||
expect(result.vat_lines[0].account_number).toBe('2645') // non-EU still uses 2645
|
||||
expect(result.vat_lines[2].account_number).toBe('4531') // non-EU services → ruta 22
|
||||
})
|
||||
|
||||
it('uses 4425 basbelopp and 2647 for domestic (swedish) reverse charge', () => {
|
||||
const template: BookingTemplate = {
|
||||
...getTemplate('it_cloud_hosting'),
|
||||
reverse_charge_supplier_type: 'swedish_business',
|
||||
}
|
||||
const tx = makeTransaction({ amount: -1000 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(4)
|
||||
// Domestic RC uses 2647 (ML 16 kap) for the input pair
|
||||
expect(result.vat_lines[0].account_number).toBe('2647')
|
||||
// Domestic services (byggtjänster) → 4425, ruta 24
|
||||
expect(result.vat_lines[2].account_number).toBe('4425')
|
||||
})
|
||||
|
||||
it('skips basbelopp emission when template already debits a basis account', () => {
|
||||
const template: BookingTemplate = {
|
||||
...getTemplate('it_cloud_hosting'),
|
||||
debit_account: '4535', // user-customized template that books directly to basis
|
||||
}
|
||||
const tx = makeTransaction({ amount: -1000 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
// Only the fiktiv-moms pair — basbelopp would double-count.
|
||||
expect(result.vat_lines).toHaveLength(2)
|
||||
expect(result.vat_lines[0].account_number).toBe('2645')
|
||||
expect(result.vat_lines[1].account_number).toBe('2614')
|
||||
})
|
||||
|
||||
it('produces no VAT lines for exempt expenses', () => {
|
||||
|
||||
@@ -341,5 +341,114 @@ describe('mapping-engine', () => {
|
||||
expect(result.credit_account).toBe('1930')
|
||||
expect(result.confidence).toBe(0.95)
|
||||
})
|
||||
|
||||
it('emits both fiktiv-moms and basbelopp lines for reverse_charge rules', async () => {
|
||||
const { evaluateMappingRules } = await import('../mapping-engine')
|
||||
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
merchant_name: 'AWS',
|
||||
description: 'AWS EU-WEST-1',
|
||||
})
|
||||
|
||||
mockResult({
|
||||
data: [
|
||||
{
|
||||
id: 'rule-rc',
|
||||
user_id: 'user-1',
|
||||
rule_name: 'AWS reverse charge',
|
||||
rule_type: 'merchant_name',
|
||||
priority: 10,
|
||||
mcc_codes: null,
|
||||
merchant_pattern: 'AWS',
|
||||
description_pattern: null,
|
||||
amount_min: null,
|
||||
amount_max: null,
|
||||
debit_account: '5421',
|
||||
credit_account: '1930',
|
||||
vat_treatment: 'reverse_charge',
|
||||
vat_debit_account: null,
|
||||
vat_credit_account: null,
|
||||
risk_level: 'LOW',
|
||||
default_private: false,
|
||||
requires_review: false,
|
||||
confidence_score: 0.9,
|
||||
capitalization_threshold: null,
|
||||
capitalized_debit_account: null,
|
||||
is_active: true,
|
||||
source: 'system',
|
||||
user_description: null,
|
||||
template_id: null,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
|
||||
// Fiktiv-moms pair + basbelopp pair = 4 lines (FK004 guard)
|
||||
expect(result.vat_lines).toHaveLength(4)
|
||||
expect(result.vat_lines[0].account_number).toBe('2645')
|
||||
expect(result.vat_lines[0].debit_amount).toBe(250)
|
||||
expect(result.vat_lines[1].account_number).toBe('2614')
|
||||
expect(result.vat_lines[1].credit_amount).toBe(250)
|
||||
expect(result.vat_lines[2].account_number).toBe('4535')
|
||||
expect(result.vat_lines[2].debit_amount).toBe(1000)
|
||||
expect(result.vat_lines[3].account_number).toBe('4598')
|
||||
expect(result.vat_lines[3].credit_amount).toBe(1000)
|
||||
})
|
||||
|
||||
it('skips basbelopp emission when rule already debits a basis account', async () => {
|
||||
const { evaluateMappingRules } = await import('../mapping-engine')
|
||||
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
merchant_name: 'AWS',
|
||||
})
|
||||
|
||||
mockResult({
|
||||
data: [
|
||||
{
|
||||
id: 'rule-rc-basis',
|
||||
user_id: 'user-1',
|
||||
rule_name: 'AWS RC to basis',
|
||||
rule_type: 'merchant_name',
|
||||
priority: 10,
|
||||
mcc_codes: null,
|
||||
merchant_pattern: 'AWS',
|
||||
description_pattern: null,
|
||||
amount_min: null,
|
||||
amount_max: null,
|
||||
debit_account: '4535',
|
||||
credit_account: '1930',
|
||||
vat_treatment: 'reverse_charge',
|
||||
vat_debit_account: null,
|
||||
vat_credit_account: null,
|
||||
risk_level: 'LOW',
|
||||
default_private: false,
|
||||
requires_review: false,
|
||||
confidence_score: 0.9,
|
||||
capitalization_threshold: null,
|
||||
capitalized_debit_account: null,
|
||||
is_active: true,
|
||||
source: 'system',
|
||||
user_description: null,
|
||||
template_id: null,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
|
||||
// Only fiktiv-moms pair — basbelopp already covered by the expense line
|
||||
expect(result.vat_lines).toHaveLength(2)
|
||||
expect(result.vat_lines[0].account_number).toBe('2645')
|
||||
expect(result.vat_lines[1].account_number).toBe('2614')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,12 @@ import type {
|
||||
VatTreatment,
|
||||
RiskLevel,
|
||||
} from '@/types'
|
||||
import { getVatRate, generateReverseChargeLines, generateInputVatLine } from './vat-entries'
|
||||
import {
|
||||
getVatRate,
|
||||
generateReverseChargeLines,
|
||||
generateReverseChargeBasisLines,
|
||||
generateInputVatLine,
|
||||
} from './vat-entries'
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
@@ -59,6 +64,13 @@ export interface BookingTemplate {
|
||||
description_sv: string
|
||||
common: boolean
|
||||
requires_vat_registration_data?: boolean
|
||||
/**
|
||||
* Supplier-type hint for reverse-charge bookings. Determines which 44xx/45xx
|
||||
* basbelopp account is emitted alongside the 2645/2614 fiktiv-moms pair so
|
||||
* Skatteverket's momsdeklaration rutor 20–24 line up with rutor 30–32
|
||||
* (felkod FK004 if absent). Default 'eu_business' when unset.
|
||||
*/
|
||||
reverse_charge_supplier_type?: 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||||
}
|
||||
|
||||
export interface TemplateGroupInfo {
|
||||
@@ -1536,6 +1548,15 @@ export function findMatchingTemplates(
|
||||
.slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account number sits in the reverse-charge basbelopp range
|
||||
* (44xx/45xx series — ruta 20–24 inputs). Used to skip redundant basis
|
||||
* emission when the template already books to such an account.
|
||||
*/
|
||||
function isBasisAccount(account: string): boolean {
|
||||
return /^4[45]\d{2}$/.test(account)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a booking template into a MappingResult.
|
||||
* Follows the same pattern as buildMappingResultFromCategory in category-mapping.ts.
|
||||
@@ -1562,9 +1583,17 @@ export function buildMappingResultFromTemplate(
|
||||
const vatRate = getVatRate(template.vat_treatment)
|
||||
|
||||
if (template.vat_treatment === 'reverse_charge' && isExpense) {
|
||||
// EU reverse charge: fiktiv moms (offsetting entries)
|
||||
// EU/non-EU/domestic reverse charge: emit BOTH the fiktiv-moms pair
|
||||
// (2645|2647 / 2614) AND the basbelopp pair (44xx|45xx / 4598). The
|
||||
// basbelopp pair populates momsdeklaration rutor 20–24; without it
|
||||
// Skatteverket rejects with FK004 ("ruta 30-32 utan motsvarande
|
||||
// basbelopp i 20-24" — ML 13 kap kräver båda sidor).
|
||||
const absAmount = Math.abs(transaction.amount)
|
||||
const rcLines = generateReverseChargeLines(absAmount)
|
||||
const supplierType = template.reverse_charge_supplier_type ?? 'eu_business'
|
||||
const isDomestic = supplierType === 'swedish_business'
|
||||
const rcRate = 0.25 // fiktiv moms rate; current templates are 25%
|
||||
|
||||
const rcLines = generateReverseChargeLines(absAmount, rcRate, isDomestic)
|
||||
for (const rcl of rcLines) {
|
||||
vatLines.push({
|
||||
account_number: rcl.account_number,
|
||||
@@ -1573,6 +1602,20 @@ export function buildMappingResultFromTemplate(
|
||||
description: rcl.line_description || '',
|
||||
})
|
||||
}
|
||||
|
||||
// Skip basbelopp emission if the template already books the expense
|
||||
// directly to a basis account (44xx/45xx series) — would double-count.
|
||||
if (!isBasisAccount(debitAccount)) {
|
||||
const basisLines = generateReverseChargeBasisLines(absAmount, rcRate, supplierType)
|
||||
for (const bl of basisLines) {
|
||||
vatLines.push({
|
||||
account_number: bl.account_number,
|
||||
debit_amount: bl.debit_amount,
|
||||
credit_amount: bl.credit_amount,
|
||||
description: bl.line_description || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (vatRate > 0 && isExpense) {
|
||||
// Input VAT deduction
|
||||
const absAmount = Math.abs(transaction.amount)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
generateInputVatLine,
|
||||
generateReverseChargeLines,
|
||||
generateReverseChargeBasisLines,
|
||||
} from './vat-entries'
|
||||
import { findMatchingTemplates, buildMappingResultFromTemplate } from './booking-templates'
|
||||
import {
|
||||
@@ -221,8 +222,13 @@ function buildResult(rule: MappingRule, transaction: Transaction, entityType?: E
|
||||
const vatLines: VatJournalLine[] = []
|
||||
if (isExpense && !rule.default_private && rule.vat_treatment) {
|
||||
if (rule.vat_treatment === 'reverse_charge') {
|
||||
// EU reverse charge: fiktiv moms (offsetting entries)
|
||||
const rcLines = generateReverseChargeLines(absAmount)
|
||||
// Reverse charge: emit BOTH the fiktiv-moms pair (2645/2614) AND the
|
||||
// basbelopp pair (44xx|45xx / 4598). The basbelopp pair populates
|
||||
// momsdeklaration rutor 20–24; without it Skatteverket rejects with
|
||||
// FK004. Mapping rules don't carry supplier-country today, so we
|
||||
// default to EU services — the most common reverse-charge scenario.
|
||||
const rcRate = 0.25
|
||||
const rcLines = generateReverseChargeLines(absAmount, rcRate, false)
|
||||
for (const rcl of rcLines) {
|
||||
vatLines.push({
|
||||
account_number: rcl.account_number,
|
||||
@@ -231,6 +237,19 @@ function buildResult(rule: MappingRule, transaction: Transaction, entityType?: E
|
||||
description: rcl.line_description || '',
|
||||
})
|
||||
}
|
||||
|
||||
// Skip basbelopp emission if the rule already books to a basis account.
|
||||
if (!/^4[45]\d{2}$/.test(debitAccount)) {
|
||||
const basisLines = generateReverseChargeBasisLines(absAmount, rcRate, 'eu_business')
|
||||
for (const bl of basisLines) {
|
||||
vatLines.push({
|
||||
account_number: bl.account_number,
|
||||
debit_amount: bl.debit_amount,
|
||||
credit_amount: bl.credit_amount,
|
||||
description: bl.line_description || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (rule.vat_treatment === 'standard_25' || rule.vat_treatment === 'reduced_12' || rule.vat_treatment === 'reduced_6') {
|
||||
const vatRate =
|
||||
rule.vat_treatment === 'standard_25' ? 0.25
|
||||
|
||||
@@ -101,4 +101,107 @@ describe('runVatDeclarationChecks', () => {
|
||||
const findings = runVatDeclarationChecks(rutor)
|
||||
expect(findings.find((f) => f.code === 'SUMMA_MOMS_DRIFT')).toBeUndefined()
|
||||
})
|
||||
|
||||
// SKV §4.1.1.4 rule 1 — taxable sales base without output VAT.
|
||||
it('flags ERROR when taxable sales (ruta 05) booked without output VAT', () => {
|
||||
const rutor: VatDeclarationRutor = {
|
||||
...emptyRutor,
|
||||
ruta05: 10000,
|
||||
// ruta 10/11/12 all zero — SKV rule 1 violation
|
||||
ruta49: 0,
|
||||
}
|
||||
const findings = runVatDeclarationChecks(rutor)
|
||||
const finding = findings.find((f) => f.code === 'TAXABLE_SALES_WITHOUT_OUTPUT')
|
||||
expect(finding?.status).toBe('ERROR')
|
||||
expect(finding?.message).toMatch(/försäljning/)
|
||||
expect(finding?.message).toMatch(/utgående moms/)
|
||||
})
|
||||
|
||||
it('flags ERROR for ruta 06 (uttag) without output VAT', () => {
|
||||
const rutor: VatDeclarationRutor = {
|
||||
...emptyRutor,
|
||||
ruta06: 5000,
|
||||
ruta49: 0,
|
||||
}
|
||||
const findings = runVatDeclarationChecks(rutor)
|
||||
expect(findings.find((f) => f.code === 'TAXABLE_SALES_WITHOUT_OUTPUT')?.status).toBe('ERROR')
|
||||
})
|
||||
|
||||
it('does not flag taxable sales without output VAT when output VAT is present', () => {
|
||||
const rutor: VatDeclarationRutor = {
|
||||
...emptyRutor,
|
||||
ruta05: 10000,
|
||||
ruta10: 2500,
|
||||
ruta49: 2500,
|
||||
}
|
||||
const findings = runVatDeclarationChecks(rutor)
|
||||
expect(findings.find((f) => f.code === 'TAXABLE_SALES_WITHOUT_OUTPUT')).toBeUndefined()
|
||||
})
|
||||
|
||||
// Mirror: output VAT without taxable sales base.
|
||||
it('flags ERROR when output VAT booked without taxable sales base', () => {
|
||||
const rutor: VatDeclarationRutor = {
|
||||
...emptyRutor,
|
||||
// No ruta 05/06/07/08
|
||||
ruta10: 2500,
|
||||
ruta49: 2500,
|
||||
}
|
||||
const findings = runVatDeclarationChecks(rutor)
|
||||
expect(findings.find((f) => f.code === 'OUTPUT_VAT_WITHOUT_SALES_BASE')?.status).toBe('ERROR')
|
||||
})
|
||||
|
||||
// SKV §4.1.1.4 rule 5 — import base without import output VAT.
|
||||
it('flags ERROR when import base (ruta 50) without import output VAT', () => {
|
||||
const rutor: VatDeclarationRutor = {
|
||||
...emptyRutor,
|
||||
ruta50: 10000,
|
||||
// ruta 60/61/62 all zero
|
||||
ruta48: 0,
|
||||
ruta49: 0,
|
||||
}
|
||||
const findings = runVatDeclarationChecks(rutor)
|
||||
expect(findings.find((f) => f.code === 'IMPORT_BASE_WITHOUT_OUTPUT')?.status).toBe('ERROR')
|
||||
})
|
||||
|
||||
// SKV §4.1.1.4 rule 6 — import output VAT without import base.
|
||||
it('flags ERROR when import output VAT (ruta 60) without ruta 50', () => {
|
||||
const rutor: VatDeclarationRutor = {
|
||||
...emptyRutor,
|
||||
ruta60: 2500,
|
||||
ruta48: 2500,
|
||||
ruta49: 0,
|
||||
}
|
||||
const findings = runVatDeclarationChecks(rutor)
|
||||
expect(findings.find((f) => f.code === 'IMPORT_OUTPUT_WITHOUT_BASE')?.status).toBe('ERROR')
|
||||
})
|
||||
|
||||
it('does not flag import checks when both base and output VAT are present', () => {
|
||||
const rutor: VatDeclarationRutor = {
|
||||
...emptyRutor,
|
||||
ruta50: 10000,
|
||||
ruta60: 2500,
|
||||
ruta48: 2500,
|
||||
ruta49: 0,
|
||||
}
|
||||
const findings = runVatDeclarationChecks(rutor)
|
||||
expect(findings.find((f) => f.code === 'IMPORT_BASE_WITHOUT_OUTPUT')).toBeUndefined()
|
||||
expect(findings.find((f) => f.code === 'IMPORT_OUTPUT_WITHOUT_BASE')).toBeUndefined()
|
||||
})
|
||||
|
||||
// Multiple findings should surface together so the user sees the whole picture.
|
||||
it('reports multiple distinct findings for a deeply broken declaration', () => {
|
||||
const rutor: VatDeclarationRutor = {
|
||||
...emptyRutor,
|
||||
ruta05: 10000, // taxable sales but no output VAT
|
||||
ruta30: 2500, // RC output but no RC basis
|
||||
ruta50: 5000, // import base but no import output
|
||||
ruta48: 0,
|
||||
ruta49: 2500,
|
||||
}
|
||||
const findings = runVatDeclarationChecks(rutor)
|
||||
const codes = findings.map((f) => f.code).sort()
|
||||
expect(codes).toContain('TAXABLE_SALES_WITHOUT_OUTPUT')
|
||||
expect(codes).toContain('RC_BASIS_MISSING')
|
||||
expect(codes).toContain('IMPORT_BASE_WITHOUT_OUTPUT')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,6 +43,10 @@ export interface VatDeclarationCheck {
|
||||
| 'RC_OUTPUT_MISSING'
|
||||
| 'RC_INPUT_VAT_MISMATCH'
|
||||
| 'SUMMA_MOMS_DRIFT'
|
||||
| 'TAXABLE_SALES_WITHOUT_OUTPUT'
|
||||
| 'IMPORT_BASE_WITHOUT_OUTPUT'
|
||||
| 'IMPORT_OUTPUT_WITHOUT_BASE'
|
||||
| 'OUTPUT_VAT_WITHOUT_SALES_BASE'
|
||||
status: VatDeclarationCheckStatus
|
||||
/** Swedish user-facing message; safe to render directly in the UI. */
|
||||
message: string
|
||||
@@ -116,6 +120,71 @@ export function runVatDeclarationChecks(rutor: VatDeclarationRutor): VatDeclarat
|
||||
})
|
||||
}
|
||||
|
||||
// SKV §4.1.1.4 rule 1 — taxable sales base requires output VAT.
|
||||
// If user has booked revenue (3001-3003, uttag, VMB, frivillig uthyrning)
|
||||
// without any output VAT (2611-2638), the declaration will be rejected.
|
||||
// Common cause: revenue posted but VAT line forgotten, or revenue on a
|
||||
// zero-rated account that should have been ruta 35/36/39/40.
|
||||
const taxableSalesBase = rutor.ruta05 + rutor.ruta06 + rutor.ruta07 + rutor.ruta08
|
||||
const taxableSalesOutput = rutor.ruta10 + rutor.ruta11 + rutor.ruta12
|
||||
if (taxableSalesBase > eps && taxableSalesOutput <= eps) {
|
||||
findings.push({
|
||||
code: 'TAXABLE_SALES_WITHOUT_OUTPUT',
|
||||
status: 'ERROR',
|
||||
message:
|
||||
'Du har redovisat momspliktig försäljning (ruta 05-08) men ingen ' +
|
||||
'utgående moms (ruta 10-12). Skatteverket kräver att momspliktig ' +
|
||||
'försäljning kombineras med utgående moms. Kontrollera att VAT-rader ' +
|
||||
'är bokförda på 2611/2621/2631 — eller flytta intäkterna till rätt ' +
|
||||
'momsfri ruta (35/36/39/40) om de inte är momspliktiga.',
|
||||
rutor: ['ruta05', 'ruta06', 'ruta07', 'ruta08', 'ruta10', 'ruta11', 'ruta12'],
|
||||
})
|
||||
}
|
||||
|
||||
// Mirror — output VAT without taxable sales base. Output VAT booked
|
||||
// standalone (e.g. manual correction without matching revenue posting)
|
||||
// would also fail SKV's contract.
|
||||
if (taxableSalesOutput > eps && taxableSalesBase <= eps) {
|
||||
findings.push({
|
||||
code: 'OUTPUT_VAT_WITHOUT_SALES_BASE',
|
||||
status: 'ERROR',
|
||||
message:
|
||||
'Du har redovisat utgående moms (ruta 10-12) men ingen momspliktig ' +
|
||||
'försäljning (ruta 05-08). Skatteverket kräver att utgående moms ' +
|
||||
'matchas med ett försäljningsunderlag. Kontrollera att intäktskonton ' +
|
||||
'(3001/3002/3003) är bokförda för varje VAT-rad.',
|
||||
rutor: ['ruta05', 'ruta06', 'ruta07', 'ruta08', 'ruta10', 'ruta11', 'ruta12'],
|
||||
})
|
||||
}
|
||||
|
||||
// SKV §4.1.1.4 rule 5 — import base requires import output VAT.
|
||||
const importOutput = rutor.ruta60 + rutor.ruta61 + rutor.ruta62
|
||||
if (rutor.ruta50 > eps && importOutput <= eps) {
|
||||
findings.push({
|
||||
code: 'IMPORT_BASE_WITHOUT_OUTPUT',
|
||||
status: 'ERROR',
|
||||
message:
|
||||
'Du har redovisat importunderlag (ruta 50) men ingen utgående ' +
|
||||
'importmoms (ruta 60-62). Skatteverket kräver båda. Kontrollera ' +
|
||||
'att importmoms är bokförd på 2615/2625/2635.',
|
||||
rutor: ['ruta50', 'ruta60', 'ruta61', 'ruta62'],
|
||||
})
|
||||
}
|
||||
|
||||
// SKV §4.1.1.4 rule 6 — import output VAT requires import base.
|
||||
// This was the canary that the Phase 1b ruta50 wiring fixed.
|
||||
if (importOutput > eps && rutor.ruta50 <= eps) {
|
||||
findings.push({
|
||||
code: 'IMPORT_OUTPUT_WITHOUT_BASE',
|
||||
status: 'ERROR',
|
||||
message:
|
||||
'Du har redovisat utgående importmoms (ruta 60-62) men inget ' +
|
||||
'importunderlag (ruta 50). Skatteverket kräver att importmoms ' +
|
||||
'kombineras med tullvärdesunderlag på 4545/4546/4547.',
|
||||
rutor: ['ruta50', 'ruta60', 'ruta61', 'ruta62'],
|
||||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -47,7 +47,7 @@ import type {
|
||||
* 4425/4426/4427 (domestic services reverse charge) → ruta 24
|
||||
* 4545/4546/4547 (import) → ruta 50
|
||||
*/
|
||||
const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side: 'credit' | 'debit' }> = {
|
||||
export const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side: 'credit' | 'debit' }> = {
|
||||
// Output VAT 25% → ruta 10
|
||||
'2610': { box: 'ruta10', side: 'credit' }, // Utgående moms 25% (summary/parent)
|
||||
'2611': { box: 'ruta10', side: 'credit' }, // Försäljning inom Sverige
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
ACCOUNT_TO_BOX,
|
||||
BOX_LABELS,
|
||||
getBoxForAccount,
|
||||
getBoxLabel,
|
||||
type MomsBox,
|
||||
} from '../moms-box-mapping'
|
||||
import { ACCOUNT_RUTA } from '@/lib/reports/vat-declaration'
|
||||
|
||||
describe('ACCOUNT_TO_BOX', () => {
|
||||
it('has a label for every box ID used in the map', () => {
|
||||
const usedBoxes = new Set(Object.values(ACCOUNT_TO_BOX))
|
||||
for (const box of usedBoxes) {
|
||||
expect(BOX_LABELS[box]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('maps all known revenue accounts to a sales box', () => {
|
||||
expect(ACCOUNT_TO_BOX['3001']).toBe('05')
|
||||
expect(ACCOUNT_TO_BOX['3002']).toBe('05')
|
||||
expect(ACCOUNT_TO_BOX['3003']).toBe('05')
|
||||
expect(ACCOUNT_TO_BOX['3108']).toBe('35')
|
||||
expect(ACCOUNT_TO_BOX['3308']).toBe('39')
|
||||
expect(ACCOUNT_TO_BOX['3105']).toBe('36')
|
||||
expect(ACCOUNT_TO_BOX['3305']).toBe('40')
|
||||
})
|
||||
|
||||
it('maps all output VAT accounts including parent/summary and vilande', () => {
|
||||
expect(ACCOUNT_TO_BOX['2610']).toBe('10')
|
||||
expect(ACCOUNT_TO_BOX['2611']).toBe('10')
|
||||
expect(ACCOUNT_TO_BOX['2618']).toBe('10')
|
||||
expect(ACCOUNT_TO_BOX['2620']).toBe('11')
|
||||
expect(ACCOUNT_TO_BOX['2630']).toBe('12')
|
||||
expect(ACCOUNT_TO_BOX['2614']).toBe('30')
|
||||
expect(ACCOUNT_TO_BOX['2624']).toBe('31')
|
||||
expect(ACCOUNT_TO_BOX['2634']).toBe('32')
|
||||
})
|
||||
|
||||
it('maps all input VAT accounts including parent and domestic RC', () => {
|
||||
expect(ACCOUNT_TO_BOX['2640']).toBe('48')
|
||||
expect(ACCOUNT_TO_BOX['2641']).toBe('48')
|
||||
expect(ACCOUNT_TO_BOX['2645']).toBe('48')
|
||||
expect(ACCOUNT_TO_BOX['2647']).toBe('48')
|
||||
expect(ACCOUNT_TO_BOX['2649']).toBe('48')
|
||||
})
|
||||
|
||||
it('maps reverse-charge basis accounts to the correct ruta', () => {
|
||||
// EU goods → ruta 20
|
||||
expect(ACCOUNT_TO_BOX['4515']).toBe('20')
|
||||
expect(ACCOUNT_TO_BOX['4516']).toBe('20')
|
||||
expect(ACCOUNT_TO_BOX['4517']).toBe('20')
|
||||
// EU services → ruta 21
|
||||
expect(ACCOUNT_TO_BOX['4535']).toBe('21')
|
||||
expect(ACCOUNT_TO_BOX['4536']).toBe('21')
|
||||
expect(ACCOUNT_TO_BOX['4537']).toBe('21')
|
||||
// Non-EU services → ruta 22
|
||||
expect(ACCOUNT_TO_BOX['4531']).toBe('22')
|
||||
expect(ACCOUNT_TO_BOX['4532']).toBe('22')
|
||||
expect(ACCOUNT_TO_BOX['4533']).toBe('22')
|
||||
// Domestic goods RC → ruta 23
|
||||
expect(ACCOUNT_TO_BOX['4415']).toBe('23')
|
||||
expect(ACCOUNT_TO_BOX['4416']).toBe('23')
|
||||
expect(ACCOUNT_TO_BOX['4417']).toBe('23')
|
||||
// Domestic services RC → ruta 24
|
||||
expect(ACCOUNT_TO_BOX['4425']).toBe('24')
|
||||
expect(ACCOUNT_TO_BOX['4426']).toBe('24')
|
||||
expect(ACCOUNT_TO_BOX['4427']).toBe('24')
|
||||
})
|
||||
|
||||
it('maps import beskattningsunderlag accounts to ruta 50', () => {
|
||||
expect(ACCOUNT_TO_BOX['4545']).toBe('50')
|
||||
expect(ACCOUNT_TO_BOX['4546']).toBe('50')
|
||||
expect(ACCOUNT_TO_BOX['4547']).toBe('50')
|
||||
})
|
||||
|
||||
it('maps import output VAT accounts to ruta 60/61/62', () => {
|
||||
expect(ACCOUNT_TO_BOX['2615']).toBe('60')
|
||||
expect(ACCOUNT_TO_BOX['2625']).toBe('61')
|
||||
expect(ACCOUNT_TO_BOX['2635']).toBe('62')
|
||||
})
|
||||
|
||||
it('maps momspliktiga uttag accounts to ruta 06', () => {
|
||||
expect(ACCOUNT_TO_BOX['3401']).toBe('06')
|
||||
expect(ACCOUNT_TO_BOX['3402']).toBe('06')
|
||||
expect(ACCOUNT_TO_BOX['3403']).toBe('06')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBoxForAccount', () => {
|
||||
it('returns the box for known accounts', () => {
|
||||
expect(getBoxForAccount('2611')).toBe('10')
|
||||
expect(getBoxForAccount('4535')).toBe('21')
|
||||
})
|
||||
|
||||
it('returns undefined for unknown accounts', () => {
|
||||
expect(getBoxForAccount('9999')).toBeUndefined()
|
||||
expect(getBoxForAccount('1930')).toBeUndefined() // bank account, not VAT-related
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBoxLabel', () => {
|
||||
it('returns Swedish labels for every box', () => {
|
||||
expect(getBoxLabel('10')).toMatch(/Utgående moms 25%/)
|
||||
expect(getBoxLabel('30')).toMatch(/inköp 25%/)
|
||||
expect(getBoxLabel('48')).toMatch(/Ingående moms/)
|
||||
expect(getBoxLabel('49')).toMatch(/Moms att betala/)
|
||||
})
|
||||
})
|
||||
|
||||
// Regression guard: ACCOUNT_TO_BOX must stay aligned with the source-of-truth
|
||||
// mapping in vat-declaration.ts. If a new account is added to one map without
|
||||
// the other, the calculation and the cross-validation labels drift apart.
|
||||
describe('ACCOUNT_TO_BOX ↔ ACCOUNT_RUTA alignment', () => {
|
||||
const RUTA_TO_BOX: Record<string, MomsBox> = {
|
||||
ruta05: '05', ruta06: '06', ruta07: '07', ruta08: '08',
|
||||
ruta10: '10', ruta11: '11', ruta12: '12',
|
||||
ruta20: '20', ruta21: '21', ruta22: '22', ruta23: '23', ruta24: '24',
|
||||
ruta30: '30', ruta31: '31', ruta32: '32',
|
||||
ruta35: '35', ruta36: '36', ruta37: '37', ruta38: '38',
|
||||
ruta39: '39', ruta40: '40', ruta41: '41', ruta42: '42',
|
||||
ruta48: '48', ruta49: '49',
|
||||
ruta50: '50', ruta60: '60', ruta61: '61', ruta62: '62',
|
||||
}
|
||||
|
||||
it('every account in ACCOUNT_RUTA exists in ACCOUNT_TO_BOX with the matching box', () => {
|
||||
const drift: string[] = []
|
||||
for (const [account, mapping] of Object.entries(ACCOUNT_RUTA)) {
|
||||
const expectedBox = RUTA_TO_BOX[mapping.box]
|
||||
const actualBox = ACCOUNT_TO_BOX[account]
|
||||
if (!actualBox) {
|
||||
drift.push(`missing in ACCOUNT_TO_BOX: ${account} (should be box ${expectedBox})`)
|
||||
} else if (actualBox !== expectedBox) {
|
||||
drift.push(`mismatched box for ${account}: ACCOUNT_TO_BOX=${actualBox}, ACCOUNT_RUTA=${expectedBox}`)
|
||||
}
|
||||
}
|
||||
expect(drift).toEqual([])
|
||||
})
|
||||
|
||||
it('every account in ACCOUNT_TO_BOX exists in ACCOUNT_RUTA (or is an extra cross-validation hint)', () => {
|
||||
// Allowed extras: accounts in ACCOUNT_TO_BOX that don't feed the declaration
|
||||
// but are useful for the Export VAT Monitor / EU Sales List. Currently these
|
||||
// are the frakter accounts that follow goods treatment.
|
||||
const allowedExtras = new Set<string>(['3521', '3522', '3109'])
|
||||
|
||||
const drift: string[] = []
|
||||
for (const account of Object.keys(ACCOUNT_TO_BOX)) {
|
||||
if (allowedExtras.has(account)) continue
|
||||
if (!ACCOUNT_RUTA[account]) {
|
||||
drift.push(`extra in ACCOUNT_TO_BOX: ${account} (not in ACCOUNT_RUTA — consider adding to declaration mapping or to allowedExtras)`)
|
||||
}
|
||||
}
|
||||
expect(drift).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -42,13 +42,28 @@ export type MomsBox =
|
||||
| '61' // Importmoms 12%
|
||||
| '62' // Importmoms 6%
|
||||
|
||||
/** Map BAS account to momsdeklaration box */
|
||||
/**
|
||||
* Map BAS account to momsdeklaration box.
|
||||
*
|
||||
* Source of truth for "which moms box does this BAS account contribute to?"
|
||||
* Used for cross-validation (Export VAT Monitor, EU Sales List) and any
|
||||
* UI that needs to label a journal line by its declaration ruta.
|
||||
*
|
||||
* Must stay aligned with `ACCOUNT_RUTA` in `lib/reports/vat-declaration.ts`
|
||||
* — a regression test asserts that every account mapped here points at the
|
||||
* matching ruta and vice versa.
|
||||
*/
|
||||
export const ACCOUNT_TO_BOX: Record<string, MomsBox> = {
|
||||
// Domestic revenue (taxable) → Box 05
|
||||
'3001': '05', // Försäljning varor/tjänster 25%
|
||||
'3002': '05', // Försäljning varor/tjänster 12%
|
||||
'3003': '05', // Försäljning varor/tjänster 6%
|
||||
|
||||
// Momspliktiga uttag → Box 06
|
||||
'3401': '06',
|
||||
'3402': '06',
|
||||
'3403': '06',
|
||||
|
||||
// EU goods (reverse charge, VAT-free) → Box 35
|
||||
'3108': '35', // Försäljning varor till annat EU-land
|
||||
'3521': '35', // Fakturerade frakter EU (follows goods treatment)
|
||||
@@ -69,22 +84,31 @@ export const ACCOUNT_TO_BOX: Record<string, MomsBox> = {
|
||||
// VAT-exempt sales → Box 42
|
||||
'3004': '42', // Momsfri försäljning (AB)
|
||||
'3100': '42', // Momsfria intäkter (EF)
|
||||
'3404': '42', // Momsfria uttag
|
||||
'3980': '42', // Erhållna offentliga stöd m.m.
|
||||
'3994': '42', // Övriga rörelseintäkter momsfria
|
||||
|
||||
// Output VAT 25% → Box 10
|
||||
'2610': '10', // Utgående moms 25% (summary/parent)
|
||||
'2611': '10', // Försäljning inom Sverige
|
||||
'2612': '10', // Egna uttag
|
||||
'2613': '10', // Uthyrning (frivillig skattskyldighet)
|
||||
'2616': '10', // Vinstmarginalbeskattning
|
||||
'2618': '10', // Vilande utgående moms 25%
|
||||
// Output VAT 12% → Box 11
|
||||
'2620': '11', // Utgående moms 12% (summary/parent)
|
||||
'2621': '11',
|
||||
'2622': '11', // Egna uttag
|
||||
'2623': '11', // Uthyrning
|
||||
'2626': '11', // VMB
|
||||
'2628': '11', // Vilande utgående moms 12%
|
||||
// Output VAT 6% → Box 12
|
||||
'2630': '12', // Utgående moms 6% (summary/parent)
|
||||
'2631': '12',
|
||||
'2632': '12', // Egna uttag
|
||||
'2633': '12', // Uthyrning
|
||||
'2636': '12', // VMB
|
||||
'2638': '12', // Vilande utgående moms 6%
|
||||
|
||||
// Reverse charge output VAT → Boxes 30, 31, 32
|
||||
'2614': '30',
|
||||
@@ -97,12 +121,35 @@ export const ACCOUNT_TO_BOX: Record<string, MomsBox> = {
|
||||
'2635': '62', // Import 6%
|
||||
|
||||
// Input VAT → Box 48
|
||||
'2640': '48', // Ingående moms (summary/parent)
|
||||
'2641': '48', // Debiterad ingående moms
|
||||
'2642': '48', // Frivillig skattskyldighet
|
||||
'2645': '48', // Beräknad ingående moms (EU/non-EU förvärv)
|
||||
'2646': '48', // Uthyrning
|
||||
'2647': '48', // Omvänd skattskyldighet i Sverige
|
||||
'2649': '48', // Blandad verksamhet
|
||||
|
||||
// Reverse-charge purchase bases (debit on cost accounts) → Boxes 20-24
|
||||
'4515': '20', // Inköp varor EU 25%
|
||||
'4516': '20', // Inköp varor EU 12%
|
||||
'4517': '20', // Inköp varor EU 6%
|
||||
'4535': '21', // Inköp tjänster EU 25% (huvudregeln)
|
||||
'4536': '21', // Inköp tjänster EU 12%
|
||||
'4537': '21', // Inköp tjänster EU 6%
|
||||
'4531': '22', // Inköp tjänster utanför EU 25%
|
||||
'4532': '22', // Inköp tjänster utanför EU 12%
|
||||
'4533': '22', // Inköp tjänster utanför EU 6%
|
||||
'4415': '23', // Inköp varor SE omvänd skattskyldighet 25%
|
||||
'4416': '23', // Inköp varor SE omvänd skattskyldighet 12%
|
||||
'4417': '23', // Inköp varor SE omvänd skattskyldighet 6%
|
||||
'4425': '24', // Inköp tjänster SE omvänd skattskyldighet 25%
|
||||
'4426': '24', // Inköp tjänster SE omvänd skattskyldighet 12%
|
||||
'4427': '24', // Inköp tjänster SE omvänd skattskyldighet 6%
|
||||
|
||||
// Import beskattningsunderlag → Box 50
|
||||
'4545': '50', // Import 25%
|
||||
'4546': '50', // Import 12%
|
||||
'4547': '50', // Import 6%
|
||||
}
|
||||
|
||||
/** Swedish labels for each momsdeklaration box */
|
||||
|
||||
Reference in New Issue
Block a user