feat(vat): oss momskod keeps unionsordningen sales out of the momsdeklaration (#1797)

A Fortnox user with OSS sales hit the SIE import mapping step and found no
way to map OSS accounts: the momskod picker had no OSS option, 3106-style
labels ("Försäljning varor till annat EU-land, momspliktig") were suggested
as EU-varor (ruta 35), and an OSS revenue account with a sats set leaked into
ruta 05. Skatteverket: "Den försäljning som du redovisar i OSS ska du inte
redovisa i den vanliga momsdeklarationen."

- add the 'oss' revenue treatment: allowed for class 3 only, mapped to no
  ruta, default rate null (destination-country rate is not a Swedish sats);
  explicit 'oss' also overrides static BAS mappings such as 3001
- REVENUE_RUTA becomes a partial map where null = allowed but off the
  declaration, so the class gate no longer conflates "no ruta" with
  "purchase-only"
- SIE label suggestion: OSS/unionsordningen labels suggest 'oss';
  momspliktig EU-varor labels are left for review instead of ruta 35
- AccountVatTreatmentSchema derives from ACCOUNT_VAT_TREATMENTS instead of a
  second literal list
- migration widens the class-aware CHECK with 'oss' for class 3 (superset;
  NOT VALID + VALIDATE like its predecessor); pg test extended
- sv/en labels; unit tests for resolver, suggestion, declaration exclusion

Per-country VAT rates on invoices and the quarterly EUR/ECB OSS underlag
remain unbuilt (DECISIONS.md).


Claude-Session: https://claude.ai/code/session_01E3QB8GxJ9tS217agHjLRk7

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-22 10:49:07 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 1f7acbf144
commit 51c815254a
11 changed files with 156 additions and 12 deletions
@@ -3,6 +3,7 @@ import {
defaultRateForVatTreatment,
resolveVatTreatmentRuta,
suggestVatTreatment,
vatTreatmentsForAccountClass,
} from '../account-vat-treatment'
describe('resolveVatTreatmentRuta', () => {
@@ -28,6 +29,16 @@ describe('resolveVatTreatmentRuta', () => {
expect(resolveVatTreatmentRuta('export_goods', 4)).toBeNull()
expect(resolveVatTreatmentRuta('exempt', 4)).toBeNull()
})
it('keeps OSS revenue off the declaration and offers it only for revenue accounts', () => {
// Unionsordningen: declared in the OSS declaration, never in a ruta.
expect(resolveVatTreatmentRuta('oss', 3)).toBeNull()
expect(resolveVatTreatmentRuta('oss', 4)).toBeNull()
expect(vatTreatmentsForAccountClass(3)).toContain('oss')
expect(vatTreatmentsForAccountClass(3)).not.toContain('reverse_charge_non_eu_services')
expect(vatTreatmentsForAccountClass(4)).not.toContain('oss')
expect(defaultRateForVatTreatment('oss', 3)).toBeNull()
})
})
describe('suggestVatTreatment', () => {
@@ -61,6 +72,20 @@ describe('suggestVatTreatment', () => {
})
})
it('recognises OSS labels and leaves momspliktig EU-försäljning for review', () => {
// Fortnox has no OSS accounts in its base chart; users name their own
// per country and rate ("Försäljning enl. OSS (Spanien 21%)").
expect(suggestVatTreatment('3111', 'Försäljning enl. OSS (Spanien 21%)')).toEqual({
treatment: 'oss', rate: null,
})
expect(suggestVatTreatment('3112', 'Försäljning varor unionsordningen Tyskland')).toEqual({
treatment: 'oss', rate: null,
})
// BAS 3106 is Swedish moms below the OSS threshold or OSS above it; a
// ruta 35 (momsfri EU-leverans) suggestion is wrong either way.
expect(suggestVatTreatment('3106', 'Försäljning varor till annat EU-land, momspliktig')).toBeNull()
})
it('does not match EU inside an unrelated word', () => {
expect(suggestVatTreatment('4010', 'Reumatologiska varor')).toBeNull()
})
+25 -4
View File
@@ -5,6 +5,7 @@ export const ACCOUNT_VAT_TREATMENTS = [
'reverse_charge_domestic', 'reverse_charge_eu_goods',
'reverse_charge_eu_services', 'reverse_charge_non_eu_services',
'export_goods', 'export_services', 'vmb', 'rental_voluntary',
'oss',
] as const
export type AccountVatTreatment = typeof ACCOUNT_VAT_TREATMENTS[number]
@@ -15,13 +16,22 @@ export interface AccountVatRutaMapping {
side: 'credit' | 'debit'
}
const REVENUE_RUTA: Record<AccountVatTreatment, keyof VatDeclarationRutor | null> = {
/**
* Revenue (class 3) treatments and the momsdeklaration box they feed. A key
* mapped to `null` is a valid revenue treatment whose amounts are deliberately
* kept out of every ruta: OSS sales (unionsordningen) are declared only in the
* quarterly OSS declaration, never in the Swedish momsdeklaration
* (Skatteverket: "Den försäljning som du redovisar i OSS ska du inte redovisa
* i den vanliga momsdeklarationen"). Treatments missing from the map are
* purchase-only.
*/
const REVENUE_RUTA: Partial<Record<AccountVatTreatment, keyof VatDeclarationRutor | null>> = {
standard_25: 'ruta05', reduced_12: 'ruta05', reduced_6: 'ruta05',
exempt: 'ruta42', reverse_charge_domestic: 'ruta41',
reverse_charge_eu_goods: 'ruta35', reverse_charge_eu_services: 'ruta39',
reverse_charge_non_eu_services: null,
export_goods: 'ruta36', export_services: 'ruta40', vmb: 'ruta07',
rental_voluntary: 'ruta08',
oss: null,
}
export function resolveVatTreatmentRuta(
@@ -48,6 +58,7 @@ export function isVatTreatmentAllowedForAccountClass(
treatment: AccountVatTreatment,
accountClass: number,
): boolean {
if (accountClass === 3) return treatment in REVENUE_RUTA
return resolveVatTreatmentRuta(treatment, accountClass) !== null
}
@@ -66,7 +77,9 @@ export function defaultRateForVatTreatment(
if (treatment === 'reduced_12') return 0.12
if (treatment === 'reduced_6') return 0.06
if (treatment === 'exempt') return 0
if (treatment === 'vmb') return null
// VMB has no single sats; OSS accounts carry the destination country's
// rate, which is not a Swedish sats and never drives ruta 05 arithmetic.
if (treatment === 'vmb' || treatment === 'oss') return null
if (treatment === 'rental_voluntary') return 0.25
if (treatment === 'export_goods' || treatment === 'export_services') return 0
return accountClass >= 4 && accountClass <= 6 ? 0.25 : 0
@@ -98,12 +111,20 @@ export function suggestVatTreatment(
const rate = percent ? Number(percent[1]) / 100 : 0.25
if (accountClass === 3) {
if (/\boss\b|one stop shop|unionsordning/.test(name)) return { treatment: 'oss', rate: null }
if (/vmb|vinstmarginal/.test(name)) return { treatment: 'vmb', rate: null }
if (/hyra|uthyrning/.test(name) && /frivillig/.test(name)) return { treatment: 'rental_voluntary', rate }
if (/omvänd/.test(name)) return { treatment: 'reverse_charge_domestic', rate: 0 }
if (/export|utanför eu/.test(name) && /var/.test(name)) return { treatment: 'export_goods', rate: 0 }
if (/export|utanför eu/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'export_services', rate: 0 }
if (/\beu\b/.test(name) && /var/.test(name)) return { treatment: 'reverse_charge_eu_goods', rate: 0 }
if (/\beu\b/.test(name) && /var/.test(name)) {
// BAS 3106 "Försäljning varor till annat EU-land, momspliktig" carries
// Swedish moms below the OSS threshold and destination-country moms
// (OSS) above it. The label cannot tell which, so leave the row for
// review instead of suggesting the momsfri ruta 35 treatment.
if (/momspliktig/.test(name)) return null
return { treatment: 'reverse_charge_eu_goods', rate: 0 }
}
if (/\beu\b/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'reverse_charge_eu_services', rate: 0 }
if (/momsfri|utan moms/.test(name)) return { treatment: 'exempt', rate: 0 }
if (/försälj|forsalj|intäkt|intakt/.test(name) && percent) {