fix(vat): make ruta 41 reachable (omvänd skattskyldighet sales, 3231-3233) (#1456)

* fix(vat): map 3231-3233 to ruta 41 in the momsdeklaration

Ruta 41 (försäljning när köparen är betalningsskyldig i Sverige) existed
in the type, the eSKD file and the Skatteverket mapper, but no account
could ever reach it: 3231-3233 were deliberately parked in
RUTA_05_EXCLUDED_ACCOUNTS, so byggmoms/omvänd-skattskyldighet sales
vanished from the declaration entirely. Map them statically in
ACCOUNT_RUTA and ACCOUNT_TO_BOX; the ACCOUNT_TO_BOX guard now keeps them
out of the dynamic ruta 05 set instead of the exclusion list. RC sales
carry no output VAT, so they stay out of the ruta 05-08 vs 10-12
pairing checks, pinned by test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record the ruta 41 static-mapping decision

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-08 10:40:36 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent c912fc5633
commit cf373e9dd1
6 changed files with 48 additions and 3 deletions
+1
View File
@@ -826,3 +826,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-06] Review-workflow triage on the Bucket A branch (13 confirmed findings): fixed 10, incl. the branch-killing one (setActiveCompany's cookie write throws in Server Component render, so the /select-company auto-forward silently never fired: the cookie set is now best-effort because the gnubok-company-id cookie is write-only compat nothing reads). Batch "Ingen moms" now goes over the wire as 'exempt' instead of collapsing to undefined, which had an explicit no-VAT choice booking the derived 25%; the same pre-existing collapse in QuickReviewDialog/CategoryExpandedDialog is left for a follow-up. Skipped by choice: generalizing AiFilledIndicator for history provenance (the note's copy already names the source) and converting BulkBookInboxDialog's hardcoded-Swedish option lists to i18n (whole-file migration, not this branch's divergence). Monthly momsdeklaration default is deadline-aware (M-2 until the 12th/17th, M-1 after; over-40M always M-1) mirroring deadline-config, not just calendar-ended.
[2026-08-07] Sandbox cleanup repaired via a dedicated gnubok.sandbox_cleanup transaction-local flag (respected by write_audit_log, audit_log_immutable, enforce_dimension_registry_guards, enforce_pending_operations_no_delete) instead of tombstoning sandbox users like the real account-deletion flow: sandbox data is synthetic demo content, not rakenskapsinformation, so full deletion is the correct GDPR/BFL posture and the audit-log DELETE bypass re-verifies per row that the company is a sandbox before letting anything through. Also revoked the default PUBLIC EXECUTE both cleanup RPCs had carried on prod since March (anon could call them via PostgREST), and validated the whole delete chain by replaying it against prod inside aborted transactions (21 sampled users across all seed eras) plus a committed staging run.
[2026-08-07] QuickReviewDialog's 'none'-collapse closed with a deviation test, not a blanket map: 'none' as the SEEDED default (exempt categories) stays off the wire so untouched bank-fee bookings remain byte-identical, while 'none' as a user deviation goes as explicit 'exempt' (books no VAT line, records the classification the momsdeklaration should see, lands income on 3004). The batch dialog's blanket 'none'→'exempt' map from #1443 is correct THERE because its select has no seeded-per-category default. CategoryExpandedDialog carries the same collapse but has zero references (dead code): left untouched, deletion is hygiene for a separate sweep.
[2026-08-08] Ruta 41 unblocked with a static ACCOUNT_RUTA mapping of 3231/3232/3233 (incl. 3232/3233, in the SKV 4700 mapping though not in our shipped BAS 2026 catalog) instead of starting the per-account VAT-treatment picker: the ElvaSmultron/Spiris support case needed omvänd-moms sales in the declaration now, and RC sales deliberately stay out of the ruta 05-08 vs 10-12 pairing checks because the buyer accounts for the VAT. The picker (any custom konto to any ruta, incl. purchase-side 4056-style Visma accounts to ruta 20) is #1457.
@@ -98,6 +98,7 @@ import {
getVatDeclarationSummary,
calculateVatDeclaration,
rcInputTotalsFromDeclaration,
rutorFromTotals,
} from '../vat-declaration'
import { runVatDeclarationChecks } from '../vat-declaration-checks'
import type { VatDeclaration } from '@/types'
@@ -116,6 +117,30 @@ beforeEach(() => {
// Pure function tests: no mocks needed
// ============================================================
describe('rutorFromTotals: ruta 41 (omvänd skattskyldighet, sales side)', () => {
it('projects 3231/3232/3233 credit balances into ruta 41', () => {
const totals = new Map([
['3231', { debit: 0, credit: 100_000 }],
['3232', { debit: 500, credit: 10_500 }],
['3233', { debit: 0, credit: 0 }],
])
const rutor = rutorFromTotals(totals)
expect(rutor.ruta41).toBe(110_000)
// Buyer accounts for the VAT: an RC sale must not leak into the
// taxable-sales pairing (rutor 05-08) nor into the net (ruta 49).
expect(rutor.ruta05).toBe(0)
expect(rutor.ruta49).toBe(0)
})
it('a pure ruta 41 declaration passes the sales/output pairing checks', () => {
const totals = new Map([['3231', { debit: 0, credit: 50_000 }]])
const rutor = rutorFromTotals(totals)
const findings = runVatDeclarationChecks(rutor)
expect(findings.map((f) => f.code)).not.toContain('TAXABLE_SALES_WITHOUT_OUTPUT')
expect(findings.map((f) => f.code)).not.toContain('OUTPUT_VAT_WITHOUT_SALES_BASE')
})
})
describe('calculatePeriodDates', () => {
it('returns correct dates for monthly period', () => {
const { start, end } = calculatePeriodDates('monthly', 2024, 1)
+6
View File
@@ -108,6 +108,12 @@ export const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side
'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
// Revenue: omvänd skattskyldighet inom Sverige → ruta 41. The seller books
// NO output VAT (the buyer accounts for it via rutor 23-24/30-32), so these
// deliberately stay OUT of the ruta 05-08 vs 10-12 pairing checks.
'3231': { box: 'ruta41', side: 'credit' }, // Försäljning byggsektorn, omvänd betalningsskyldighet
'3232': { box: 'ruta41', side: 'credit' }, // Omvänd betalningsskyldighet, övriga (skrot m.m.)
'3233': { box: 'ruta41', side: 'credit' }, // Omvänd betalningsskyldighet, övriga
// 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%
+5 -3
View File
@@ -30,17 +30,19 @@ import { ACCOUNT_TO_BOX } from '@/lib/vat/moms-box-mapping'
* .claude/skills/swedish-vat/references/vat-compliance-reference.md §7:
*
* 3211/3212/3220 → ruta 07 (vinstmarginalbeskattning)
* 3231/3232/3233 → ruta 41 (försäljning där köparen är betalningsskyldig)
* 3913 → ruta 08 (hyresinkomster, frivillig skattskyldighet)
*
* Rutor 07, 08 and 41 are not mappable yet (see the note in
* Rutor 07 and 08 are not mappable yet (see the note in
* vat-declaration-checks.ts). Until they are, these accounts stay out of the
* declaration entirely: that understates one ruta, whereas sweeping them into
* ruta 05 would file the amount in the wrong box.
*
* 3231/3232/3233 (ruta 41, omvänd skattskyldighet) used to sit here for the
* same reason but are now statically mapped in ACCOUNT_RUTA/ACCOUNT_TO_BOX,
* so the ACCOUNT_TO_BOX guard below excludes them from ruta 05.
*/
export const RUTA_05_EXCLUDED_ACCOUNTS = new Set([
'3211', '3212', '3220',
'3231', '3232', '3233',
'3913',
])
@@ -26,6 +26,12 @@ describe('ACCOUNT_TO_BOX', () => {
expect(ACCOUNT_TO_BOX['3305']).toBe('40')
})
it('maps domestic reverse-charge sales accounts to ruta 41', () => {
expect(ACCOUNT_TO_BOX['3231']).toBe('41')
expect(ACCOUNT_TO_BOX['3232']).toBe('41')
expect(ACCOUNT_TO_BOX['3233']).toBe('41')
})
it('maps all output VAT accounts including parent/summary and vilande', () => {
expect(ACCOUNT_TO_BOX['2610']).toBe('10')
expect(ACCOUNT_TO_BOX['2611']).toBe('10')
+5
View File
@@ -82,6 +82,11 @@ export const ACCOUNT_TO_BOX: Record<string, MomsBox> = {
// Non-EU services → Box 40
'3305': '40', // Försäljning tjänster export utanför EU
// Domestic reverse-charge sales (buyer liable for VAT) → Box 41
'3231': '41', // Försäljning byggsektorn, omvänd betalningsskyldighet
'3232': '41', // Omvänd betalningsskyldighet, övriga (skrot m.m.)
'3233': '41', // Omvänd betalningsskyldighet, övriga
// VAT-exempt sales → Box 42
'3004': '42', // Momsfri försäljning (AB)
'3100': '42', // Momsfria intäkter (EF)