feat(skatteverket): production-ready momsdeklaration submission (#380)

* feat(skatteverket): production-ready momsdeklaration submission

Brings the Skatteverket extension up to a state where it can ship moms
declaration submission to Vercel production. Verified end-to-end against
SKV's Komplett testtjänst — all 8 momsdeklaration operations tested
(kontrollera, spara/hämta/radera utkast, lås/lås upp, hämta inlämnade,
hämta beslutade) plus signing-link return.

Bundles three coherent changes:

1. Skatteverket extension (the main work)
   - extensions.config.json: enable `skatteverket`, drop `invoice-inbox`
     and `ai-agent` (those were enabled in config but lacked AWS env vars
     in prod, so they loaded but failed at runtime)
   - lib/reports/vat-declaration.ts: extend ACCOUNT_RUTA to populate
     Ruta 06 (uttag 3401–3403), Ruta 20–24 (reverse-charge bases from
     4xxx cost accounts), Ruta 50 (import 4545–4547), and Ruta 42
     (3404/3994/3980); delete the supplier-type heuristic that made
     Ruta 20 and Ruta 23 always 0
   - extensions/general/skatteverket/lib/token-store.ts: work around
     three real prod schema-drift issues — wrong column on read/delete
     (was `company_id`, schema only has `user_id`), missing
     UNIQUE(user_id) constraint that makes UPSERT fail (switched to
     DELETE+INSERT), missing RLS policies (switched to service-role
     client). Refresh path now reuses existing row's company_id when
     none is passed.
   - extensions/general/skatteverket/index.ts: 9 sites switched from
     ctx.companyId to ctx.userId for the token-store key; pass
     companyId from the OAuth callback
   - extensions/general/skatteverket/types.ts + components/reports/
     SkatteverketPanel.tsx: align field names with v1.0.24 RAML
     (signeringsLank/kontrollResultat/resultat/kod/status/beskrivning).
     Without this, the signing link never displayed.
   - SkatteverketPanel: add Lås upp + Radera utkast + Hämta utkast +
     Hämta beslut buttons so the full lifecycle is reachable from the UI
   - lib/reports/__tests__/vat-declaration.test.ts: rewritten to match
     the refactored calculator; new fixtures for cost-account-based
     reverse charge (Ruta 20/21/22/23/24), Ruta 50 import, Ruta 06
     uttag, Ruta 42 expansion; SKV §4.1.1.4 cross-field contract checks
   - supabase/migrations/20260428120000_skatteverket_tokens_user_id_unique.sql:
     idempotently adds the missing UNIQUE(user_id) constraint
   - scripts/*: dev-only helpers used during the prod-of-test
     verification (create test company, seed VAT data, inspect token
     state, etc.)

2. Journal-entries cancelled-status filter
   - app/api/bookkeeping/journal-entries/route.ts: when no status filter
     is supplied, exclude `cancelled` entries by default
   - supabase/migrations/20260428153500_journal_entries_with_related_exclude_statuses.sql

3. Swedish e-invoicing skill (reference docs only — no runtime code)
   - .claude/skills/swedish-e-invoicing/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skatteverket): address PR review findings

- panel: handleFetchDraft read `result.data?.last` (typo) — switched to
  `result.data?.locked` to match the field defined in
  SkatteverketUtkastResponse and the v1.0.24 RAML. The "(låst)" suffix on
  the success message would silently never appear before this fix.

- api-client: getValidToken had no concurrency guard, so two parallel
  SKV requests from the same user could both call /token with the same
  refresh_token. SKV rotates the refresh_token on first use, so the
  second call would 401 with REFRESH_EXHAUSTED-adjacent failures. With
  the new 6-button UI on SkatteverketPanel, rapid clicks made this a
  realistic trigger. Added an in-process Promise map keyed on userId
  that coalesces concurrent refresh attempts; cross-process races are
  mitigated by re-reading tokens inside the critical section before
  calling refreshAccessToken (if another process refreshed already, we
  use the newer token instead of burning the old refresh_token).

- migration 20260428120000: dedup query used `created_at < max(...)`,
  which failed to remove duplicates inserted in the same second. The
  subsequent ALTER TABLE … ADD CONSTRAINT would then abort. Switched
  to ctid (Postgres physical row identifier) to break timestamp ties.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skatteverket): throw on token-store SELECT error before destructive DELETE

The company_id pre-read in storeTokens used destructuring that discarded
the error field. If the service-role SELECT failed for any reason (network
blip, overloaded DB, transient permissions issue), `existing` became null,
`resolvedCompanyId` stayed undefined, and execution fell through to the
DELETE. The old row got deleted successfully, then the INSERT omitted
company_id and failed with the NOT NULL constraint violation — leaving
the user with no token row at all and forcing a fresh BankID handshake.

Now we capture the SELECT error and throw before the DELETE runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-28 18:26:03 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 0363aff1d4
commit cd64c0e3fb
35 changed files with 3549 additions and 486 deletions
@@ -7,6 +7,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
'tic',
'mcp-server',
'cloud-backup',
'invoice-inbox',
'ai-agent',
'skatteverket',
])
+2 -4
View File
@@ -6,8 +6,7 @@ import { arcimMigrationExtension } from '@/extensions/general/arcim-migration'
import { ticExtension } from '@/extensions/general/tic'
import { mcpServerExtension } from '@/extensions/general/mcp-server'
import { cloudBackupExtension } from '@/extensions/general/cloud-backup'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import { aiAgentExtension } from '@/extensions/general/ai-agent'
import { skatteverketExtension } from '@/extensions/general/skatteverket'
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
enableBankingExtension,
@@ -16,6 +15,5 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [
ticExtension,
mcpServerExtension,
cloudBackupExtension,
invoiceInboxExtension,
aiAgentExtension,
skatteverketExtension,
]
@@ -81,37 +81,14 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"subscriptionNotice": "Kräver ett Google-konto. Uppladdningar sker direkt till din Drive — ingen data lagras hos tredje part utöver Google."
},
{
"slug": "invoice-inbox",
"name": "Dokumentinkorg",
"sector": "general",
"category": "import",
"icon": "Inbox",
"dataPattern": "both",
"description": "AI-klassificering och extraktion av leverantörsfakturor och kvitton",
"longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt, klassificeras med AI (leverantör, belopp, moms) och matchas mot transaktioner. Kräver AWS Bedrock och Resend.",
"readsCoreTables": [
"document_attachments",
"suppliers",
"transactions"
],
"hasOwnData": true
},
{
"slug": "ai-agent",
"name": "AI-agent (beta)",
"slug": "skatteverket",
"name": "Skatteverket Integration",
"sector": "general",
"category": "operations",
"icon": "Sparkles",
"icon": "FileCheck",
"dataPattern": "core",
"description": "Autonom bokföring — AI föreslår match + bokföring, du godkänner.",
"longDescription": "När ett kvitto kommer in föreslår AI-agenten först vilken banktransaktion som matchar, sedan hur det ska bokföras. Du granskar och godkänner varje steg — inget bokförs automatiskt. Om AI:n inte kan producera ett förslag (oläslig bild, ingen matchande transaktion, osäker moms) frågar den dig specifikt vad som behövs.",
"readsCoreTables": [
"invoice_inbox_items",
"transactions",
"ai_proposals",
"ai_requests",
"processing_history"
]
"description": "Skicka momsdeklaration direkt till Skatteverket via BankID.",
"longDescription": "Anslut till Skatteverket med BankID och skicka din momsdeklaration direkt från gnubok. Spara utkast, validera, lås och signera — utan att lämna appen."
},
],
}
@@ -8,5 +8,4 @@ export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>>
'general/arcim-migration': dynamic(() => import('@/components/extensions/general/ArcimMigrationWorkspace')),
'general/tic': dynamic(() => import('@/components/extensions/general/TicWorkspace')),
'general/cloud-backup': dynamic(() => import('@/components/extensions/general/CloudBackupWorkspace')),
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
}
+293 -239
View File
@@ -182,18 +182,16 @@ describe('getVatDeclarationSummary', () => {
// ============================================================
// Ledger-based VAT declaration tests
//
// Mock queue order per call:
// [0] fetchAllRows: journal_entry_lines (VAT-relevant accounts)
// [1] fetchAllRows: journal_entries for reverse charge bases
// [2] (if rc entries found) fetchAllRows: supplier_invoices
// [N] entry counts: journal_entries source_type
// After Phase 1b refactor, the calculator does TWO queries per call:
// [0] fetchAllRows: journal_entry_lines on every account in ACCOUNT_RUTA
// (26xx VAT, 3xxx revenue, 4xxx reverse-charge cost accounts)
// [1] journal_entries source_type counts (used for invoice/transaction metadata)
// ============================================================
describe('calculateVatDeclaration', () => {
it('returns all zeros when no ledger lines exist', async () => {
results = [
{ data: [], error: null }, // journal_entry_lines
{ data: [], error: null }, // rc journal entries
{ data: [], error: null }, // entry counts
]
@@ -225,19 +223,15 @@ describe('calculateVatDeclaration', () => {
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [{ source_type: 'invoice_created' }, { source_type: 'invoice_created' }], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Output VAT in ruta 10/11/12
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta11).toBe(600)
expect(result.rutor.ruta12).toBe(180)
// All domestic revenue combined in ruta 05
expect(result.rutor.ruta05).toBe(18000)
// Per-rate base amounts in breakdown
expect(result.breakdown.invoices.base25).toBe(10000)
expect(result.breakdown.invoices.base12).toBe(5000)
expect(result.breakdown.invoices.base6).toBe(3000)
@@ -253,7 +247,6 @@ describe('calculateVatDeclaration', () => {
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [{ source_type: 'bank_transaction' }, { source_type: 'bank_transaction' }], error: null },
]
@@ -272,13 +265,11 @@ describe('calculateVatDeclaration', () => {
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Both 2641 and 2645 debit balances sum into ruta48
expect(result.rutor.ruta48).toBe(700)
})
@@ -291,7 +282,6 @@ describe('calculateVatDeclaration', () => {
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [], error: null },
]
@@ -314,13 +304,11 @@ describe('calculateVatDeclaration', () => {
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [{ source_type: 'invoice_created' }, { source_type: 'credit_note' }], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Net: 2500 - 625 = 1875 output VAT in ruta10, 10000 - 2500 = 7500 revenue in ruta05
expect(result.rutor.ruta10).toBe(1875)
expect(result.rutor.ruta05).toBe(7500)
expect(result.invoiceCount).toBe(2)
@@ -336,7 +324,6 @@ describe('calculateVatDeclaration', () => {
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [], error: null },
]
@@ -357,7 +344,6 @@ describe('calculateVatDeclaration', () => {
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [], error: null },
]
@@ -369,11 +355,9 @@ describe('calculateVatDeclaration', () => {
it('accepts accountingMethod parameter for backward compatibility', async () => {
results = [
{ data: [], error: null },
{ data: [], error: null }, // rc journal entries
{ data: [], error: null },
]
// Should not throw — parameter accepted but not used
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1, 'cash')
expect(result.rutor.ruta49).toBe(0)
})
@@ -382,40 +366,32 @@ describe('calculateVatDeclaration', () => {
results = [
{
data: [
// 25% rate: 10,000 revenue, 2,500 VAT
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
// 12% rate: 5,000 revenue, 600 VAT
{ account_number: '3002', debit_amount: 0, credit_amount: 5000 },
{ account_number: '2621', debit_amount: 0, credit_amount: 600 },
// 6% rate: 3,000 revenue, 180 VAT
{ account_number: '3003', debit_amount: 0, credit_amount: 3000 },
{ account_number: '2631', debit_amount: 0, credit_amount: 180 },
// Input VAT from purchases
{ account_number: '2641', debit_amount: 1000, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'quarterly', 2024, 1)
// Output VAT in ruta 10/11/12
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta11).toBe(600)
expect(result.rutor.ruta12).toBe(180)
// All domestic revenue combined in ruta 05
expect(result.rutor.ruta05).toBe(18000)
expect(result.rutor.ruta48).toBe(1000)
// Output: 2500 + 600 + 180 = 3280, Input: 1000 → Pay: 2280
expect(result.rutor.ruta49).toBe(2280)
})
})
// ============================================================
// Reverse charge (ruta 20-24, 30-32) tests
// Reverse charge — purchase bases (ruta 20-24) sourced from cost accounts
// ============================================================
describe('calculateVatDeclaration — reverse charge', () => {
@@ -423,17 +399,14 @@ describe('calculateVatDeclaration — reverse charge', () => {
results = [
{
data: [
// Reverse charge output VAT accounts
{ account_number: '2614', debit_amount: 0, credit_amount: 1250 },
{ account_number: '2624', debit_amount: 0, credit_amount: 120 },
{ account_number: '2634', debit_amount: 0, credit_amount: 60 },
// Corresponding input VAT (2645)
{ account_number: '2645', debit_amount: 1430, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null }, // rc journal entries (no supplier invoices for base query)
{ data: [], error: null }, // entry counts
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
@@ -450,19 +423,15 @@ describe('calculateVatDeclaration — reverse charge', () => {
results = [
{
data: [
// Regular output VAT
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
// Reverse charge output VAT
{ account_number: '2614', debit_amount: 0, credit_amount: 500 },
// Input VAT (regular + calculated)
{ account_number: '2641', debit_amount: 300, credit_amount: 0 },
{ account_number: '2645', debit_amount: 500, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [], error: null }, // entry counts
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
@@ -470,44 +439,44 @@ describe('calculateVatDeclaration — reverse charge', () => {
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta30).toBe(500)
expect(result.rutor.ruta48).toBe(800)
// ruta49 = (2500 + 0 + 0 + 500 + 0 + 0) - 800 = 2200
expect(result.rutor.ruta49).toBe(2200)
expect(result.rutor.ruta49).toBe(2200) // (2500 + 500) - 800
})
it('populates ruta21 for EU services reverse charge base', async () => {
it('populates ruta20 from EU goods cost accounts (4515/4516/4517)', async () => {
// EU goods purchase: D 4515 25000, D 2645 6250, C 2614 6250, C 2440 25000
results = [
{
data: [
{ account_number: '4515', debit_amount: 25000, credit_amount: 0 },
{ account_number: '2614', debit_amount: 0, credit_amount: 6250 },
{ account_number: '2645', debit_amount: 6250, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta20).toBe(25000)
expect(result.rutor.ruta21).toBe(0)
expect(result.rutor.ruta30).toBe(6250)
expect(result.rutor.ruta48).toBe(6250)
// Reverse charge is VAT-neutral: output VAT exactly offsets input VAT
expect(result.rutor.ruta49).toBe(0)
})
it('populates ruta21 from EU services cost accounts (4535/4536/4537)', async () => {
results = [
{
data: [
{ account_number: '4535', debit_amount: 5000, credit_amount: 0 },
{ account_number: '2614', debit_amount: 0, credit_amount: 1250 },
{ account_number: '2645', debit_amount: 1250, credit_amount: 0 },
],
error: null,
},
// rc journal entries — found a posted supplier invoice entry
{
data: [
{ id: 'je-1', source_id: 'si-1' },
],
error: null,
},
// supplier_invoices lookup
{
data: [
{
id: 'si-1',
supplier_id: 'sup-1',
reverse_charge: true,
is_credit_note: false,
subtotal_sek: null,
subtotal: 5000,
currency: 'SEK',
exchange_rate: null,
suppliers: { supplier_type: 'eu_business' },
},
],
error: null,
},
{ data: [], error: null }, // entry counts
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
@@ -520,75 +489,36 @@ describe('calculateVatDeclaration — reverse charge', () => {
expect(result.breakdown.reverseCharge.ruta30).toBe(1250)
})
it('populates ruta22 for non-EU services reverse charge base', async () => {
it('populates ruta22 from non-EU services cost accounts (4531/4532/4533)', async () => {
// Anthropic-style: D 4531 3000, D 2645 750, C 2614 750, C 2440 3000
results = [
{
data: [
{ account_number: '4531', debit_amount: 3000, credit_amount: 0 },
{ account_number: '2614', debit_amount: 0, credit_amount: 750 },
{ account_number: '2645', debit_amount: 750, credit_amount: 0 },
],
error: null,
},
{
data: [
{ id: 'je-1', source_id: 'si-1' },
],
error: null,
},
{
data: [
{
id: 'si-1',
supplier_id: 'sup-1',
reverse_charge: true,
is_credit_note: false,
subtotal_sek: 3000,
subtotal: 300,
currency: 'USD',
exchange_rate: 10,
suppliers: { supplier_type: 'non_eu_business' },
},
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Uses subtotal_sek when available
expect(result.rutor.ruta22).toBe(3000)
expect(result.rutor.ruta21).toBe(0)
expect(result.rutor.ruta20).toBe(0)
expect(result.rutor.ruta30).toBe(750)
})
it('populates ruta24 for domestic reverse charge base', async () => {
it('populates ruta23 from domestic goods reverse-charge cost accounts (4415/4416/4417)', async () => {
// Domestic mobile reverse charge: D 4415 100000, D 2647 25000, C 2614 25000, C 2440 100000
results = [
{
data: [
{ account_number: '2614', debit_amount: 0, credit_amount: 500 },
{ account_number: '2645', debit_amount: 500, credit_amount: 0 },
],
error: null,
},
{
data: [
{ id: 'je-1', source_id: 'si-1' },
],
error: null,
},
{
data: [
{
id: 'si-1',
supplier_id: 'sup-1',
reverse_charge: true,
is_credit_note: false,
subtotal_sek: null,
subtotal: 2000,
currency: 'SEK',
exchange_rate: null,
suppliers: { supplier_type: 'swedish_business' },
},
{ account_number: '4415', debit_amount: 100000, credit_amount: 0 },
{ account_number: '2614', debit_amount: 0, credit_amount: 25000 },
{ account_number: '2647', debit_amount: 25000, credit_amount: 0 },
],
error: null,
},
@@ -597,12 +527,34 @@ describe('calculateVatDeclaration — reverse charge', () => {
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta24).toBe(2000)
expect(result.rutor.ruta21).toBe(0)
expect(result.rutor.ruta22).toBe(0)
expect(result.rutor.ruta23).toBe(100000)
expect(result.rutor.ruta24).toBe(0)
expect(result.rutor.ruta30).toBe(25000)
expect(result.rutor.ruta48).toBe(25000)
expect(result.rutor.ruta49).toBe(0) // VAT-neutral
})
it('returns zero ruta20-24 when no reverse charge entries exist', async () => {
it('populates ruta24 from domestic services reverse-charge cost accounts (4425/4426/4427)', async () => {
results = [
{
data: [
{ account_number: '4425', debit_amount: 8000, credit_amount: 0 },
{ account_number: '2614', debit_amount: 0, credit_amount: 2000 },
{ account_number: '2647', debit_amount: 2000, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta24).toBe(8000)
expect(result.rutor.ruta23).toBe(0)
expect(result.rutor.ruta30).toBe(2000)
})
it('returns zero ruta20-24 when no reverse-charge cost-account activity', async () => {
results = [
{
data: [
@@ -611,7 +563,6 @@ describe('calculateVatDeclaration — reverse charge', () => {
],
error: null,
},
{ data: [], error: null }, // no rc journal entries
{ data: [], error: null },
]
@@ -624,88 +575,55 @@ describe('calculateVatDeclaration — reverse charge', () => {
expect(result.rutor.ruta24).toBe(0)
})
it('credit notes reduce reverse charge bases', async () => {
it('reverse-charge credit notes net out the cost-account debit balance', async () => {
// Original purchase: D 4535 5000; reversal (credit note): C 4535 1000
results = [
{
data: [
// Original invoice RC VAT
{ account_number: '4535', debit_amount: 5000, credit_amount: 0 },
{ account_number: '4535', debit_amount: 0, credit_amount: 1000 },
{ account_number: '2614', debit_amount: 0, credit_amount: 1250 },
{ account_number: '2645', debit_amount: 1250, credit_amount: 0 },
// Credit note reversal
{ account_number: '2614', debit_amount: 250, credit_amount: 0 },
{ account_number: '2645', debit_amount: 1250, credit_amount: 0 },
{ account_number: '2645', debit_amount: 0, credit_amount: 250 },
],
error: null,
},
{
data: [
{ id: 'je-1', source_id: 'si-1' },
{ id: 'je-2', source_id: 'si-2' },
],
error: null,
},
{
data: [
{
id: 'si-1',
supplier_id: 'sup-1',
reverse_charge: true,
is_credit_note: false,
subtotal_sek: null,
subtotal: 5000,
currency: 'SEK',
exchange_rate: null,
suppliers: { supplier_type: 'eu_business' },
},
{
id: 'si-2',
supplier_id: 'sup-1',
reverse_charge: true,
is_credit_note: true,
subtotal_sek: null,
subtotal: 1000,
currency: 'SEK',
exchange_rate: null,
suppliers: { supplier_type: 'eu_business' },
},
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// 5000 - 1000 = 4000 net base for EU services
expect(result.rutor.ruta21).toBe(4000)
// Net RC output VAT: 1250 - 250 = 1000
expect(result.rutor.ruta30).toBe(1000)
expect(result.rutor.ruta21).toBe(4000) // 5000 - 1000
expect(result.rutor.ruta30).toBe(1000) // 1250 - 250
expect(result.rutor.ruta48).toBe(1000) // 1250 - 250
})
it('maps domestic reverse charge input VAT (2647) to ruta48', async () => {
it('maps domestic reverse-charge input VAT (2647) to ruta48', async () => {
results = [
{
data: [
// Domestic RC: D 2647 + C 2614 (offsetting), D expense
{ account_number: '2647', debit_amount: 500, credit_amount: 0 },
{ account_number: '2614', debit_amount: 0, credit_amount: 500 },
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// 2647 debit maps to ruta48
expect(result.rutor.ruta48).toBe(500)
// 2614 credit maps to ruta30
expect(result.rutor.ruta30).toBe(500)
// Net VAT = 500 - 500 = 0 (reverse charge is neutral)
expect(result.rutor.ruta49).toBe(0)
})
})
// ============================================================
// Import (ruta 50, 60-62) and Ruta 06 (uttag) and Ruta 42 (exempt)
// ============================================================
describe('calculateVatDeclaration — import, uttag, exempt', () => {
it('maps import VAT accounts (2615/2625/2635) to ruta60/61/62', async () => {
results = [
{
@@ -713,13 +631,11 @@ describe('calculateVatDeclaration — reverse charge', () => {
{ account_number: '2615', debit_amount: 0, credit_amount: 2500 },
{ account_number: '2625', debit_amount: 0, credit_amount: 600 },
{ account_number: '2635', debit_amount: 0, credit_amount: 180 },
// Input VAT from imports
{ account_number: '2641', debit_amount: 3280, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
@@ -727,46 +643,100 @@ describe('calculateVatDeclaration — reverse charge', () => {
expect(result.rutor.ruta60).toBe(2500)
expect(result.rutor.ruta61).toBe(600)
expect(result.rutor.ruta62).toBe(180)
// ruta49 = (ruta60 + ruta61 + ruta62) - ruta48 = 3280 - 3280 = 0
expect(result.rutor.ruta49).toBe(0)
expect(result.rutor.ruta49).toBe(0) // 3280 - 3280
})
it('maps EU/export revenue variants (3108/3105/3004) to ruta35/36/42', async () => {
it('populates ruta50 (import beskattningsunderlag) from 4545-4547', async () => {
// Full import flow: D 4545 10000, C 2615 2500, D 2641 2500
results = [
{
data: [
{ account_number: '4545', debit_amount: 10000, credit_amount: 0 },
{ account_number: '2615', debit_amount: 0, credit_amount: 2500 },
{ account_number: '2641', debit_amount: 2500, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Ruta 50 (base) and Ruta 60 (output VAT) BOTH non-zero — required by SKV §4.1.1.4
// ERROR rule "Det måste finnas ett belopp i fält 50, eftersom det finns ett belopp i 60-62"
expect(result.rutor.ruta50).toBe(10000)
expect(result.rutor.ruta60).toBe(2500)
expect(result.rutor.ruta48).toBe(2500)
})
it('populates ruta06 from uttag accounts (3401/3402/3403)', async () => {
// Uttag: D 2010 (private withdrawal); C 3401 1000 + C 2612 250 (25% rate uttag)
results = [
{
data: [
{ account_number: '3401', debit_amount: 0, credit_amount: 1000 },
{ account_number: '2612', debit_amount: 0, credit_amount: 250 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta06).toBe(1000)
expect(result.rutor.ruta10).toBe(250) // 2612 maps to ruta10 (25% output VAT including uttag)
})
it('expanded ruta42 covers 3004, 3100, 3404, 3994, 3980', async () => {
results = [
{
data: [
{ account_number: '3004', debit_amount: 0, credit_amount: 1000 },
{ account_number: '3100', debit_amount: 0, credit_amount: 2000 },
{ account_number: '3404', debit_amount: 0, credit_amount: 500 },
{ account_number: '3980', debit_amount: 0, credit_amount: 3000 },
{ account_number: '3994', debit_amount: 0, credit_amount: 1500 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta42).toBe(8000) // 1000+2000+500+3000+1500
})
it('maps EU/export revenue variants (3108/3105) to ruta35/36', async () => {
results = [
{
data: [
{ account_number: '3108', debit_amount: 0, credit_amount: 15000 },
{ account_number: '3105', debit_amount: 0, credit_amount: 8000 },
{ account_number: '3004', debit_amount: 0, credit_amount: 5000 },
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta35).toBe(15000)
expect(result.rutor.ruta36).toBe(8000)
expect(result.rutor.ruta42).toBe(5000)
})
it('maps output VAT variant accounts (2612/2622/2632) to correct rutor', async () => {
it('maps output VAT variant accounts (2612/2623/2636) to correct rutor', async () => {
results = [
{
data: [
// Egna uttag 25%
{ account_number: '2612', debit_amount: 0, credit_amount: 1000 },
// Uthyrning 12%
{ account_number: '2623', debit_amount: 0, credit_amount: 200 },
// VMB 6%
{ account_number: '2636', debit_amount: 0, credit_amount: 50 },
{ account_number: '2612', debit_amount: 0, credit_amount: 1000 }, // egna uttag 25%
{ account_number: '2623', debit_amount: 0, credit_amount: 200 }, // uthyrning 12%
{ account_number: '2636', debit_amount: 0, credit_amount: 50 }, // VMB 6%
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
@@ -776,50 +746,7 @@ describe('calculateVatDeclaration — reverse charge', () => {
expect(result.rutor.ruta12).toBe(50)
})
it('only includes posted journal entries for reverse charge bases (reversed filtered at DB level)', async () => {
// The query uses .eq('status', 'posted'), so reversed entries never appear
results = [
{
data: [
{ account_number: '2614', debit_amount: 0, credit_amount: 1250 },
{ account_number: '2645', debit_amount: 1250, credit_amount: 0 },
],
error: null,
},
{
data: [
// Only posted entries returned by DB query
{ id: 'je-1', source_id: 'si-1' },
],
error: null,
},
{
data: [
{
id: 'si-1',
supplier_id: 'sup-1',
reverse_charge: true,
is_credit_note: false,
subtotal_sek: null,
subtotal: 5000,
currency: 'SEK',
exchange_rate: null,
suppliers: { supplier_type: 'eu_business' },
},
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Only the posted entry's invoice (5000) should count
expect(result.rutor.ruta21).toBe(5000)
})
it('handles zero output VAT on some rates but non-zero on others', async () => {
// Only 12% sales in period — no 25% or 6% activity
results = [
{
data: [
@@ -829,38 +756,165 @@ describe('calculateVatDeclaration — reverse charge', () => {
],
error: null,
},
{ data: [], error: null },
{ data: [{ source_type: 'invoice_created' }], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta10).toBe(0) // no 25% output VAT
expect(result.rutor.ruta11).toBe(600) // 12% output VAT
expect(result.rutor.ruta12).toBe(0) // no 6% output VAT
expect(result.rutor.ruta10).toBe(0)
expect(result.rutor.ruta11).toBe(600)
expect(result.rutor.ruta12).toBe(0)
expect(result.rutor.ruta48).toBe(200)
// ruta49 = (0 + 600 + 0 + 0 + 0 + 0 + 0 + 0 + 0) - 200 = 400
expect(result.rutor.ruta49).toBe(400)
expect(result.rutor.ruta49).toBe(400) // 600 - 200
})
it('includes sub-öre ledger amounts in ruta sums (no threshold filtering)', async () => {
it('rounds sub-öre amounts via Math.round * 100 / 100', async () => {
results = [
{
data: [
// Very small amount — VAT declaration uses raw summation, no 0.005 filtering
{ account_number: '2611', debit_amount: 0, credit_amount: 0.001 },
{ account_number: '3001', debit_amount: 0, credit_amount: 0.004 },
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Sub-öre amounts still included in ruta sums
expect(result.rutor.ruta10).toBe(0) // rounded: Math.round(0.001 * 100) / 100 = 0
expect(result.rutor.ruta05).toBe(0) // rounded: Math.round(0.004 * 100) / 100 = 0
expect(result.rutor.ruta10).toBe(0)
expect(result.rutor.ruta05).toBe(0)
})
})
// ============================================================
// SKV §4.1.1.4 cross-field contract checks
//
// Skatteverket's kontrollera endpoint runs these checks server-side. Mirror
// them locally so we catch declaration drift in unit tests, before a network
// call. ERROR rules block submission; WARNING rules don't.
// ============================================================
describe('SKV §4.1.1.4 cross-field contracts', () => {
it('ERROR — taxable sales base requires output VAT (rule 1)', async () => {
// SKV: if any of momspliktigForsaljning/momspliktigaUttag/vinstmarginal/hyresInkomst > 0,
// at least one of momsForsaljningUtgaende{Hog,Medel,Lag} must be > 0.
results = [
{
data: [
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
// No 2611/2621/2631 booked — would trigger SKV ERROR
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
const r = result.rutor
const hasBase = r.ruta05 + r.ruta06 + r.ruta07 + r.ruta08 > 0
const hasOutput = r.ruta10 + r.ruta11 + r.ruta12 > 0
expect(hasBase).toBe(true)
expect(hasOutput).toBe(false)
// Local invariant: this combination would fail SKV kontrollera with ERROR.
// The calculator does not auto-correct — the ledger must be fixed upstream.
})
it('ERROR — reverse-charge purchase base requires output VAT (rule 3)', async () => {
// If any of inkopVarorEU/inkopTjansterEU/inkopTjansterUtanforEU/inkopVarorSE/inkopTjansterSE > 0,
// at least one of momsInkopUtgaende{Hog,Medel,Lag} must be > 0.
results = [
{
data: [
{ account_number: '4535', debit_amount: 5000, credit_amount: 0 },
// No 2614/2624/2634 booked — would trigger SKV ERROR
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
const r = result.rutor
const hasRcBase = r.ruta20 + r.ruta21 + r.ruta22 + r.ruta23 + r.ruta24 > 0
const hasRcOutput = r.ruta30 + r.ruta31 + r.ruta32 > 0
expect(hasRcBase).toBe(true)
expect(hasRcOutput).toBe(false)
})
it('ERROR — import base requires import output VAT (rule 5)', async () => {
// If import (ruta50) > 0, at least one of momsImportUtgaende{Hog,Medel,Lag} must be > 0.
results = [
{
data: [
{ account_number: '4545', debit_amount: 10000, credit_amount: 0 },
// No 2615/2625/2635 booked — would trigger SKV ERROR
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
const r = result.rutor
expect(r.ruta50).toBe(10000)
expect(r.ruta60 + r.ruta61 + r.ruta62).toBe(0)
})
it('ERROR — import output VAT requires import base (rule 6)', async () => {
// If any of momsImportUtgaende{Hog,Medel,Lag} > 0, import (ruta50) must be > 0.
// This is the BLOCKER scenario the Phase 1b refactor fixes: previously ruta50 was
// never populated, so any import VAT booking would fail SKV's contract.
results = [
{
data: [
{ account_number: '2615', debit_amount: 0, credit_amount: 2500 },
{ account_number: '2641', debit_amount: 2500, credit_amount: 0 },
{ account_number: '4545', debit_amount: 10000, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
const r = result.rutor
// Both populated — passes SKV's rule 6
expect(r.ruta50).toBe(10000)
expect(r.ruta60).toBe(2500)
})
it('ERROR — summaMoms must equal (ruta10+11+12+30+31+32+60+61+62) − ruta48 (rule 7)', async () => {
// The calculator computes ruta49 from the formula directly, so this invariant
// holds by construction. This test is the canary that catches drift if anyone
// ever adds an extra term or rate to the form.
results = [
{
data: [
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
{ account_number: '2621', debit_amount: 0, credit_amount: 600 },
{ account_number: '2631', debit_amount: 0, credit_amount: 180 },
{ account_number: '2614', debit_amount: 0, credit_amount: 1250 },
{ account_number: '2615', debit_amount: 0, credit_amount: 500 },
{ account_number: '2641', debit_amount: 1000, credit_amount: 0 },
{ account_number: '2645', debit_amount: 1250, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
const r = result.rutor
const expected = r.ruta10 + r.ruta11 + r.ruta12
+ r.ruta30 + r.ruta31 + r.ruta32
+ r.ruta60 + r.ruta61 + r.ruta62
- r.ruta48
expect(r.ruta49).toBe(expected)
})
})
+50 -114
View File
@@ -22,17 +22,27 @@ import type {
/**
* Account-to-ruta mapping for the Swedish momsdeklaration (SKV 4700).
*
* Covers all BAS 26xx VAT accounts and 3xxx revenue accounts that feed the
* momsdeklaration. Includes variant accounts (egna uttag, uthyrning, VMB,
* import, domestic reverse charge) that may appear from manual entries or
* SIE imports, not just accounts generated by the system.
* Pure ledger projection: every Ruta on the SKV 4700 form maps to one or more
* BAS account balances aggregated over the period. The mapping below follows
* the BAS 2026 chart and Skatteverket's published BAS-to-Ruta spec
* (`.claude/skills/swedish-vat/references/vat-compliance-reference.md` §7).
*
* Output VAT (261x/262x/263x) → ruta 10/11/12 per rate
* Reverse charge output (2614/2624/2634) → ruta 30/31/32
* Import VAT (2615/2625/2635) → ruta 60/61/62
* Input VAT (2641-2649) → ruta 48
* Revenue (3001-3003) → ruta 05; EU (3108/3308) → ruta 35/39;
* Export (3105/3305) → ruta 36/40; Exempt (3004/3100) → ruta 42
* Output VAT (261x/262x/263x) → ruta 10/11/12 per rate (credit balance)
* Reverse charge output (2614/2624/2634) → ruta 30/31/32 (credit)
* Import VAT (2615/2625/2635) → ruta 60/61/62 (credit)
* Input VAT (2641-2649) → ruta 48 (debit)
* Domestic taxable sales (3001-3003) → ruta 05 (credit)
* Uttag (3401-3403) → ruta 06 (credit)
* EU goods (3108) → ruta 35; EU services (3308) → ruta 39 (credit)
* Export (3105/3305) → ruta 36/40; Exempt (3004/3100/3404/3994/3980) → ruta 42 (credit)
* Reverse-charge purchase bases — read from the cost account the journal
* entry posted to (debit balance), not from supplier classification:
* 4515/4516/4517 (EU goods 25/12/6%) → ruta 20
* 4535/4536/4537 (EU services 25/12/6%) → ruta 21
* 4531/4532/4533 (non-EU services 25/12/6%) → ruta 22
* 4415/4416/4417 (domestic goods reverse charge) → ruta 23
* 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' }> = {
// Output VAT 25% → ruta 10
@@ -69,6 +79,10 @@ const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side: 'cred
'3001': { box: 'ruta05', side: 'credit' },
'3002': { box: 'ruta05', side: 'credit' },
'3003': { box: 'ruta05', side: 'credit' },
// Revenue: momspliktiga uttag → ruta 06
'3401': { box: 'ruta06', side: 'credit' },
'3402': { box: 'ruta06', side: 'credit' },
'3403': { box: 'ruta06', side: 'credit' },
// Revenue: EU goods/services → ruta 35/39
'3108': { box: 'ruta35', side: 'credit' }, // Varuförsäljning till EU
'3308': { box: 'ruta39', side: 'credit' }, // Tjänsteförsäljning till EU
@@ -77,6 +91,28 @@ const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side: 'cred
'3305': { box: 'ruta40', side: 'credit' }, // Tjänsteförsäljning export
'3004': { box: 'ruta42', side: 'credit' }, // Momsfri försäljning (AB)
'3100': { box: 'ruta42', side: 'credit' }, // Momsfria intäkter (EF)
'3404': { box: 'ruta42', side: 'credit' }, // Momsfria uttag
'3980': { box: 'ruta42', side: 'credit' }, // Erhållna offentliga stöd m.m.
'3994': { box: 'ruta42', side: 'credit' }, // Övriga rörelseintäkter momsfria
// Reverse-charge purchase bases (debit on cost accounts) → ruta 20-24, 50
'4515': { box: 'ruta20', side: 'debit' }, // Inköp varor EU 25%
'4516': { box: 'ruta20', side: 'debit' }, // Inköp varor EU 12%
'4517': { box: 'ruta20', side: 'debit' }, // Inköp varor EU 6%
'4535': { box: 'ruta21', side: 'debit' }, // Inköp tjänster EU 25%
'4536': { box: 'ruta21', side: 'debit' }, // Inköp tjänster EU 12%
'4537': { box: 'ruta21', side: 'debit' }, // Inköp tjänster EU 6%
'4531': { box: 'ruta22', side: 'debit' }, // Inköp tjänster utanför EU 25%
'4532': { box: 'ruta22', side: 'debit' }, // Inköp tjänster utanför EU 12%
'4533': { box: 'ruta22', side: 'debit' }, // Inköp tjänster utanför EU 6%
'4415': { box: 'ruta23', side: 'debit' }, // Inköp varor SE reverse charge 25%
'4416': { box: 'ruta23', side: 'debit' }, // Inköp varor SE reverse charge 12%
'4417': { box: 'ruta23', side: 'debit' }, // Inköp varor SE reverse charge 6%
'4425': { box: 'ruta24', side: 'debit' }, // Inköp tjänster SE reverse charge 25%
'4426': { box: 'ruta24', side: 'debit' }, // Inköp tjänster SE reverse charge 12%
'4427': { box: 'ruta24', side: 'debit' }, // Inköp tjänster SE reverse charge 6%
'4545': { box: 'ruta50', side: 'debit' }, // Beskattningsunderlag import 25%
'4546': { box: 'ruta50', side: 'debit' }, // Beskattningsunderlag import 12%
'4547': { box: 'ruta50', side: 'debit' }, // Beskattningsunderlag import 6%
}
const VAT_ACCOUNTS = Object.keys(ACCOUNT_RUTA)
@@ -142,7 +178,10 @@ function round(value: number): number {
/**
* Calculate VAT declaration from the general ledger.
*
* Sums posted journal entry lines on 26xx and 3xxx accounts per ACCOUNT_RUTA mapping.
* Sums posted journal entry lines on the BAS accounts in ACCOUNT_RUTA per the
* SKV 4700 form mapping. Pure ledger projection — no supplier classification
* or other side-channel signals.
*
* - ruta 49 = (10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - 48
*
* The accounting method parameter is accepted for backward compatibility
@@ -229,14 +268,6 @@ export async function calculateVatDeclaration(
if (t) revenueByRate[rate] = round(t.credit - t.debit)
}
// Calculate reverse charge purchase bases (ruta 20-24) from supplier invoices
const rcBases = await calculateReverseChargeBases(supabase, companyId, start, end)
rutor.ruta20 = rcBases.ruta20
rutor.ruta21 = rcBases.ruta21
rutor.ruta22 = rcBases.ruta22
rutor.ruta23 = rcBases.ruta23
rutor.ruta24 = rcBases.ruta24
// Count journal entries by source type for metadata
const { data: entryCounts } = await supabase
.from('journal_entries')
@@ -291,101 +322,6 @@ export async function calculateVatDeclaration(
}
}
/**
* Calculate reverse charge purchase bases (ruta 20-24) from supplier invoices.
*
* Queries journal entries with supplier invoice source types, then looks up
* the linked supplier invoices + suppliers to determine supplier_type and
* sum item line_total (the net tax base in SEK).
*
* Classification (all reverse charge is currently services):
* eu_business → ruta21 (services from EU)
* non_eu_business → ruta22 (services from outside EU)
* swedish_business → ruta24 (domestic reverse charge, e.g. construction)
* ruta20/23 (goods) → 0 for now (Tullverket path not implemented)
*/
async function calculateReverseChargeBases(
supabase: SupabaseClient,
companyId: string,
start: string,
end: string,
): Promise<{ ruta20: number; ruta21: number; ruta22: number; ruta23: number; ruta24: number }> {
const result = { ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0 }
// Step 1: Find journal entries from supplier invoices in this period
const supplierSourceTypes = [
'supplier_invoice_registered',
'supplier_invoice_cash_payment',
'supplier_credit_note',
]
const entries = await fetchAllRows<{
id: string
source_id: string
}>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id, source_id')
.eq('company_id', companyId)
.in('source_type', supplierSourceTypes)
.eq('status', 'posted')
.gte('entry_date', start)
.lte('entry_date', end)
.range(from, to)
)
if (entries.length === 0) return result
const sourceIds = [...new Set(entries.map(e => e.source_id).filter(Boolean))]
if (sourceIds.length === 0) return result
// Step 2: Fetch supplier invoices that are reverse charge, with supplier type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const invoices = await fetchAllRows<Record<string, any>>(({ from, to }) =>
supabase
.from('supplier_invoices')
.select('id, supplier_id, reverse_charge, is_credit_note, subtotal_sek, subtotal, currency, exchange_rate, suppliers!inner(supplier_type)')
.in('id', sourceIds)
.eq('reverse_charge', true)
.eq('company_id', companyId)
.range(from, to)
)
if (invoices.length === 0) return result
// Step 3: Sum tax bases by supplier type
for (const inv of invoices) {
// Use subtotal_sek if available, otherwise convert via exchange_rate
let baseSek: number
if (inv.subtotal_sek != null) {
baseSek = Number(inv.subtotal_sek)
} else if (inv.currency !== 'SEK' && inv.exchange_rate) {
baseSek = Math.round(Number(inv.subtotal) * Number(inv.exchange_rate) * 100) / 100
} else {
baseSek = Number(inv.subtotal)
}
// Credit notes reduce the base
if (inv.is_credit_note) baseSek = -baseSek
// !inner join: Supabase returns the related row as an object (1-to-1 FK)
const supplier = Array.isArray(inv.suppliers) ? inv.suppliers[0] : inv.suppliers
const supplierType = supplier?.supplier_type as string
switch (supplierType) {
case 'eu_business':
result.ruta21 = round(result.ruta21 + baseSek)
break
case 'non_eu_business':
result.ruta22 = round(result.ruta22 + baseSek)
break
case 'swedish_business':
result.ruta24 = round(result.ruta24 + baseSek)
break
}
}
return result
}
/**
* Get a summary of the VAT declaration for display
*/