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:
Mattsson
2026-05-13 15:17:32 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 9e0b89ee7e
commit da39eb2d43
13 changed files with 724 additions and 36 deletions
@@ -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')
})
})
})
+46 -3
View File
@@ -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)
+21 -2
View File
@@ -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