From da39eb2d43b895b00e182315a3ad2c7d01c9c5fa Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 13 May 2026 15:17:32 +0200 Subject: [PATCH] Bug/momsdek overflow (#465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) --- app/(dashboard)/reports/page.tsx | 6 +- components/reports/SkatteverketPanel.tsx | 82 ++++++--- .../transactions/JournalEntryPreview.tsx | 33 +++- components/transactions/QuickReviewDialog.tsx | 10 +- .../__tests__/booking-templates.test.ts | 70 +++++++- .../__tests__/mapping-engine.test.ts | 109 ++++++++++++ lib/bookkeeping/booking-templates.ts | 49 +++++- lib/bookkeeping/mapping-engine.ts | 23 ++- .../__tests__/vat-declaration-checks.test.ts | 103 ++++++++++++ lib/reports/vat-declaration-checks.ts | 69 ++++++++ lib/reports/vat-declaration.ts | 2 +- lib/vat/__tests__/moms-box-mapping.test.ts | 155 ++++++++++++++++++ lib/vat/moms-box-mapping.ts | 49 +++++- 13 files changed, 724 insertions(+), 36 deletions(-) create mode 100644 lib/vat/__tests__/moms-box-mapping.test.ts diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 1d679972..cd8438a8 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -1243,7 +1243,7 @@ function VatDeclarationView() { {/* Utgående moms */}

Utgående moms (försäljning)

-
+
{data.rutor.ruta05 > 0 && ( @@ -1305,7 +1305,7 @@ function VatDeclarationView() { data.rutor.ruta30 > 0 || data.rutor.ruta31 > 0 || data.rutor.ruta32 > 0) && ( <>

Omvänd skattskyldighet (inköp)

-
+
@@ -1324,7 +1324,7 @@ function VatDeclarationView() { {/* Ingående moms */}

Ingående moms (avdragsgill)

-
+
diff --git a/components/reports/SkatteverketPanel.tsx b/components/reports/SkatteverketPanel.tsx index f948ae75..df26116e 100644 --- a/components/reports/SkatteverketPanel.tsx +++ b/components/reports/SkatteverketPanel.tsx @@ -36,6 +36,22 @@ interface SkatteverketStatus { expiresAt?: string } +/** + * Codes from /lib/api-client.ts's SkatteverketAuthError that mean "the user + * needs to reconnect with BankID before this action can succeed". When the API + * returns one of these codes we flip the local status.expired flag so the + * "Session utgången" badge + "Förnya session" button surface, even if the + * upstream /status endpoint hasn't reflected the change yet. + */ +const AUTH_RECONNECT_CODES = new Set([ + 'NOT_CONNECTED', + 'SESSION_EXPIRED', + 'REFRESH_EXHAUSTED', + 'TOKEN_REVOKED', + 'TOKEN_CORRUPTED', + 'MISSING_SCOPE', +]) + // Shape per Skatteverket Momsdeklaration v1.0.24 RAML // (kontrollResultat.resultat[].{kod, status, beskrivning}) interface KontrollResult { @@ -90,6 +106,21 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk const localErrors = localChecks.filter((c) => c.status === 'ERROR') const localBlocked = localErrors.length > 0 + /** + * Apply an API JSON error result. When the error indicates the SKV session + * has expired/been revoked/lost scope, immediately reflect that in the + * local status so the "Förnya session" CTA appears next to the message — + * the user shouldn't have to wait for /status to catch up. + */ + const applyApiError = useCallback((result: { error?: string; code?: string } | null) => { + if (!result?.error) return false + setError(result.error) + if (result.code && AUTH_RECONNECT_CODES.has(result.code)) { + setStatus((prev) => prev ? { ...prev, expired: true } : prev) + } + return true + }, []) + const fetchStatus = useCallback(async () => { try { const res = await fetch('/api/extensions/ext/skatteverket/status') @@ -169,8 +200,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk body: JSON.stringify({ periodType, year, period }), }) const result = await res.json() - if (result.error) { - setError(result.error) + if (applyApiError(result)) { + // surfaced + status updated; nothing more to do } else { const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || [] setKontroller(controls) @@ -215,8 +246,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk body: JSON.stringify({ periodType, year, period }), }) const result = await res.json() - if (result.error) { - setError(result.error) + if (applyApiError(result)) { + // surfaced + status updated; nothing more to do } else { const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || [] setKontroller(controls) @@ -245,8 +276,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk { method: 'PUT' } ) const result = await res.json() - if (result.error) { - setError(result.error) + if (applyApiError(result)) { + // surfaced + status updated; nothing more to do } else if (result.data?.signeringsLank) { setSigneringslank(result.data.signeringsLank) setSuccess('Utkastet är låst. Öppna signeringslänken för att signera med BankID.') @@ -269,8 +300,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk { method: 'DELETE' } ) const result = await res.json() - if (result.error) { - setError(result.error) + if (applyApiError(result)) { + // surfaced + status updated; nothing more to do } else { setSigneringslank(null) setSuccess('Utkastet har låsts upp') @@ -292,8 +323,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk )}&redovisningsperiod=${getRedovisningsperiod()}` ) const result = await res.json() - if (result.error) { - setError(result.error) + if (applyApiError(result)) { + // surfaced + status updated; nothing more to do } else if (result.data) { setSubmitted(result.data) setSuccess('Deklarationen har lämnats in') @@ -323,7 +354,9 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk setSuccess('Utkastet har raderats från Eget utrymme') } else { const result = await res.json().catch(() => ({})) - setError(result.error || `Kunde inte radera utkast (${res.status})`) + if (!applyApiError(result)) { + setError(`Kunde inte radera utkast (${res.status})`) + } } } catch { setError('Kunde inte radera utkast') @@ -342,8 +375,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk )}&redovisningsperiod=${getRedovisningsperiod()}` ) const result = await res.json() - if (result.error) { - setError(result.error) + if (applyApiError(result)) { + // surfaced + status updated; nothing more to do } else if (!result.data) { setSuccess('Inget sparat utkast hittades för perioden') } else { @@ -369,8 +402,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk )}&redovisningsperiod=${getRedovisningsperiod()}` ) const result = await res.json() - if (result.error) { - setError(result.error) + if (applyApiError(result)) { + // surfaced + status updated; nothing more to do } else if (!result.data) { setSuccess('Inget beslut hittades för perioden') } else { @@ -455,10 +488,21 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk Ansluten {status.expired && ( - - - Session utgången - + <> + + + Session utgången + + + )} diff --git a/components/transactions/JournalEntryPreview.tsx b/components/transactions/JournalEntryPreview.tsx index 99cd8e53..94ba055b 100644 --- a/components/transactions/JournalEntryPreview.tsx +++ b/components/transactions/JournalEntryPreview.tsx @@ -24,6 +24,8 @@ interface JournalEntryPreviewProps { templateDebitAccount?: string templateCreditAccount?: string templateVatRate?: number + templateVatTreatment?: VatTreatment | null + templateSupplierType?: 'eu_business' | 'non_eu_business' | 'swedish_business' /** For multi-line counterparty template bookings */ linePattern?: LinePatternEntry[] settlementAccount?: string @@ -39,6 +41,8 @@ export default function JournalEntryPreview({ templateDebitAccount, templateCreditAccount, templateVatRate, + templateVatTreatment, + templateSupplierType, linePattern, settlementAccount = '1930', }: JournalEntryPreviewProps) { @@ -91,6 +95,7 @@ export default function JournalEntryPreview({ const vatAmt = extractVatAmount(absAmount, vatRate) const netAmt = extractNetAmount(absAmount, vatRate) const isIncome = amount > 0 + const isReverseCharge = templateVatTreatment === 'reverse_charge' && !isIncome if (isIncome) { // Income: debit bank gross, credit revenue net, credit output VAT @@ -101,6 +106,32 @@ export default function JournalEntryPreview({ const outputVatAccount = vatRate === 0.06 ? '2631' : vatRate === 0.12 ? '2621' : '2611' result.push({ side: 'kredit', account: outputVatAccount, amount: vatAmt }) } + } else if (isReverseCharge) { + // Expense with reverse charge: full reverse-charge verifikation + // (must match engine output in buildMappingResultFromTemplate). + const rcRate = 0.25 + const rcVatAmt = Math.round(absAmount * rcRate * 100) / 100 + const supplierType = templateSupplierType ?? 'eu_business' + const isDomestic = supplierType === 'swedish_business' + + // Expense gross + bank + result.push({ side: 'debet', account: templateDebitAccount, amount: absAmount }) + result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount }) + + // Fiktiv moms pair: 2645 (or 2647 domestic) / 2614 + result.push({ side: 'debet', account: isDomestic ? '2647' : '2645', amount: rcVatAmt }) + result.push({ side: 'kredit', account: '2614', amount: rcVatAmt }) + + // Basbelopp pair: 44xx|45xx / 4598 — populates rutor 20–24. + // Skip if the debit account is already a basis account. + if (!/^4[45]\d{2}$/.test(templateDebitAccount)) { + const basisAccount = + supplierType === 'eu_business' ? '4535' + : supplierType === 'non_eu_business' ? '4531' + : '4425' + result.push({ side: 'debet', account: basisAccount, amount: absAmount }) + result.push({ side: 'kredit', account: '4598', amount: absAmount }) + } } else { // Expense: debit expense net + input VAT, credit bank gross result.push({ side: 'debet', account: templateDebitAccount, amount: netAmt }) @@ -150,7 +181,7 @@ export default function JournalEntryPreview({ } return result - }, [amount, category, vatTreatment, accountOverride, entityType, templateDebitAccount, templateCreditAccount, templateVatRate, linePattern, settlementAccount]) + }, [amount, category, vatTreatment, accountOverride, entityType, templateDebitAccount, templateCreditAccount, templateVatRate, templateVatTreatment, templateSupplierType, linePattern, settlementAccount]) if (lines.length === 0) return null diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx index e6bb0241..032e8ea2 100644 --- a/components/transactions/QuickReviewDialog.tsx +++ b/components/transactions/QuickReviewDialog.tsx @@ -172,7 +172,7 @@ export default function QuickReviewDialog({ } onOpenChange(o) }}> - + Granska bokföring @@ -264,7 +264,13 @@ export default function QuickReviewDialog({ {...(isCounterpartyTemplate ? { linePattern: counterpartyLinePattern ?? undefined } : templateId && template - ? { templateDebitAccount: template.debit_account, templateCreditAccount: template.credit_account, templateVatRate: template.vat_rate } + ? { + templateDebitAccount: template.debit_account, + templateCreditAccount: template.credit_account, + templateVatRate: template.vat_rate, + templateVatTreatment: template.vat_treatment, + templateSupplierType: template.reverse_charge_supplier_type, + } : { category, vatTreatment: isLiabilityAccount ? 'none' : vatTreatment, accountOverride, entityType } )} /> diff --git a/lib/bookkeeping/__tests__/booking-templates.test.ts b/lib/bookkeeping/__tests__/booking-templates.test.ts index 391be003..e18c8cd3 100644 --- a/lib/bookkeeping/__tests__/booking-templates.test.ts +++ b/lib/bookkeeping/__tests__/booking-templates.test.ts @@ -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', () => { diff --git a/lib/bookkeeping/__tests__/mapping-engine.test.ts b/lib/bookkeeping/__tests__/mapping-engine.test.ts index 1e6863e1..bf992172 100644 --- a/lib/bookkeeping/__tests__/mapping-engine.test.ts +++ b/lib/bookkeeping/__tests__/mapping-engine.test.ts @@ -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') + }) }) }) diff --git a/lib/bookkeeping/booking-templates.ts b/lib/bookkeeping/booking-templates.ts index fbf94b62..0f5c881d 100644 --- a/lib/bookkeeping/booking-templates.ts +++ b/lib/bookkeeping/booking-templates.ts @@ -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) diff --git a/lib/bookkeeping/mapping-engine.ts b/lib/bookkeeping/mapping-engine.ts index 3d4860a2..270499a8 100644 --- a/lib/bookkeeping/mapping-engine.ts +++ b/lib/bookkeeping/mapping-engine.ts @@ -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 diff --git a/lib/reports/__tests__/vat-declaration-checks.test.ts b/lib/reports/__tests__/vat-declaration-checks.test.ts index 96649d1f..43e05a51 100644 --- a/lib/reports/__tests__/vat-declaration-checks.test.ts +++ b/lib/reports/__tests__/vat-declaration-checks.test.ts @@ -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') + }) }) diff --git a/lib/reports/vat-declaration-checks.ts b/lib/reports/vat-declaration-checks.ts index b0829d6d..afb4b8fd 100644 --- a/lib/reports/vat-declaration-checks.ts +++ b/lib/reports/vat-declaration-checks.ts @@ -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. diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index 12a90a1e..c9a17a65 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -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 = { +export const ACCOUNT_RUTA: Record = { // 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 diff --git a/lib/vat/__tests__/moms-box-mapping.test.ts b/lib/vat/__tests__/moms-box-mapping.test.ts new file mode 100644 index 00000000..db7472b1 --- /dev/null +++ b/lib/vat/__tests__/moms-box-mapping.test.ts @@ -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 = { + 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(['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([]) + }) +}) diff --git a/lib/vat/moms-box-mapping.ts b/lib/vat/moms-box-mapping.ts index c031d8cd..5d092b19 100644 --- a/lib/vat/moms-box-mapping.ts +++ b/lib/vat/moms-box-mapping.ts @@ -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 = { // 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 = { // 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 = { '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 */