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:
co-authored by
Claude Opus 4.7
parent
0363aff1d4
commit
cd64c0e3fb
@@ -7,6 +7,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
|
||||
'tic',
|
||||
'mcp-server',
|
||||
'cloud-backup',
|
||||
'invoice-inbox',
|
||||
'ai-agent',
|
||||
'skatteverket',
|
||||
])
|
||||
|
||||
@@ -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')),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user