From 51c815254aeac657817591ef15e429f548d3151f Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sat, 22 Aug 2026 10:49:07 +0200 Subject: [PATCH] feat(vat): oss momskod keeps unionsordningen sales out of the momsdeklaration (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DECISIONS.md | 1 + lib/api/schemas.ts | 10 ++--- .../__tests__/account-vat-treatment.test.ts | 23 +++++++++++ lib/reports/__tests__/vat-declaration.test.ts | 28 ++++++++++++++ lib/reports/vat-revenue-accounts.ts | 2 +- .../__tests__/account-vat-treatment.test.ts | 25 ++++++++++++ lib/vat/account-vat-treatment.ts | 29 ++++++++++++-- messages/en.json | 1 + messages/sv.json | 1 + ...260822093000_account_vat_treatment_oss.sql | 38 +++++++++++++++++++ .../account-default-vat-treatment.pg.test.ts | 10 ++++- 11 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 supabase/migrations/20260822093000_account_vat_treatment_oss.sql diff --git a/DECISIONS.md b/DECISIONS.md index 29cdf05d..c0a67643 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1157,3 +1157,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-21] Behandlingshistorik ships as a report over existing stores (journal_entries.committed_at + audit_log + rattelse log + import tables) rather than on processing_history: that table only carries Document/BankTransaction/System events in prod, while audit_log is complete, immutable and already the archive's revision/behandlingshistorik.json. Event labels stay Swedish in both locales (räkenskapsinformation, archived 7 years, same rule as SIE/grundbok); only the view chrome is translated. Bokföringsposter come from journal_entries (not audit COMMIT rows) so entries predating the audit log or from the July SIE-import window are never missing. [2026-08-21] Peppol access is granted per company by the operators, never self-served (peppol_access table, locked by default): every transmission is billed per document by Qvalia and every receiving identifier consumes a contracted tenant slot, so the company asks from Settings > Fakturering (request row + e-mail to support) and we enable it with scripts/peppol/access.ts, setting max_sends (null = no cap) and separately receive_enabled; the send route refuses PEPPOL_ACCESS_REQUIRED / PEPPOL_SEND_LIMIT_REACHED before touching the invoice, and registration refuses PEPPOL_RECEIVING_NOT_ENABLED. Founder call 2026-08-21 after the first open-for-all hour in prod. [2026-08-22] BoXon feedback fix: leftover assistant proposals no longer need manual cleanup in Granskning. lib/agent/pending/reject-conversation-pending.ts rejects a conversation's still-pending pending_operations in one update (guarded on status='pending' so it never stamps 'rejected' over a committing/committed verifikat; company-scoped; filtered by agent_metadata->>conversation_id). Surfaced two ways: POST /api/agent/conversations/[id]/reject-pending (the chat's "Rensa förslag som inte godkänts" button) + auto-clear when the conversation is ARCHIVED (best-effort in the PATCH; archive = "I'm done with this thread"). Kept durable-by-default (proposals still resume) — the button is explicit user intent, not an auto-reject on every panel close, so resume still works. Chat button drops all staged cards from view on click (committed ones are already booked; the card was only a confirmation). UI PR: needs founder visual sign-off on the button. +[2026-08-22] OSS support = one `oss` revenue momskod (off every ruta) + SIE-import label suggestion, not per-country invoice rates or an EUR/ECB OSS report: Skatteverket requires OSS sales out of the ordinary momsdeklaration, and the missing piece a Fortnox OSS user hits first is the mapping step (3106-style labels were pushed to ruta 35, OSS-named accounts had no option). Per-country VAT rates on invoices and the quarterly EUR underlag are a separate feature; the recommended interim workflow is one revenue account per country/rate (as Fortnox suggests) + Huvudbok per kvartal + manual ECB conversion. diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 625f230b..5b58fe51 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { normaliseSwish, isValidSwish } from '@/lib/payments/swish' import { normalizeVatNumber } from '@/lib/vat/vat-number' +import { ACCOUNT_VAT_TREATMENTS } from '@/lib/vat/account-vat-treatment' import { accountNumberSchema, isoDateSchema, @@ -2307,12 +2308,9 @@ const defaultVatRate = z .nullable() .optional() -export const AccountVatTreatmentSchema = z.enum([ - 'standard_25', 'reduced_12', 'reduced_6', 'exempt', - 'reverse_charge_domestic', 'reverse_charge_eu_goods', - 'reverse_charge_eu_services', 'reverse_charge_non_eu_services', - 'export_goods', 'export_services', 'vmb', 'rental_voluntary', -]) +// Single source of truth for treatments is lib/vat/account-vat-treatment.ts; +// the DB CHECK on chart_of_accounts.default_vat_treatment mirrors it per class. +export const AccountVatTreatmentSchema = z.enum(ACCOUNT_VAT_TREATMENTS) const defaultVatTreatment = AccountVatTreatmentSchema.nullable().optional() diff --git a/lib/import/__tests__/account-vat-treatment.test.ts b/lib/import/__tests__/account-vat-treatment.test.ts index 4778689b..969b3b24 100644 --- a/lib/import/__tests__/account-vat-treatment.test.ts +++ b/lib/import/__tests__/account-vat-treatment.test.ts @@ -34,6 +34,29 @@ describe('enrichAccountMappingsWithVat', () => { }) }) + it('suggests oss from an OSS label and leaves the BAS 3106 label for review', () => { + const [oss, b2c] = enrichAccountMappingsWithVat( + [ + mapping('3111', 'Försäljning enl. OSS (Spanien 21%)'), + mapping('3106', 'Försäljning varor till annat EU-land, momspliktig'), + ], + [], + ) + expect(oss).toMatchObject({ + defaultVatTreatment: 'oss', + defaultVatRate: null, + vatTreatmentSuggested: true, + vatTreatmentReviewed: false, + requiresVatTreatmentReview: true, + }) + expect(b2c).toMatchObject({ + defaultVatTreatment: null, + vatTreatmentSuggested: false, + vatTreatmentReviewed: false, + requiresVatTreatmentReview: true, + }) + }) + it('keeps an existing account treatment without asking again', () => { const [result] = enrichAccountMappingsWithVat( [mapping('3041', 'Försäljning tjänst 25% sv')], diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts index 387c332a..661c159a 100644 --- a/lib/reports/__tests__/vat-declaration.test.ts +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -1181,6 +1181,34 @@ describe('calculateVatDeclaration: company-specific ruta 05 accounts', () => { expect(result.breakdown.invoices.base25).toBe(2000) }) + it('keeps accounts with the oss treatment out of every ruta, static 3001 included', async () => { + // Unionsordningen: the sale is declared in the quarterly OSS declaration + // and must not appear in the Swedish momsdeklaration at all. The explicit + // treatment also overrides the static BAS mapping of a 3001-style number. + chartAccounts = [ + { account_number: '3001', default_vat_rate: null, default_vat_treatment: 'oss' }, + { + account_number: '3106', + account_name: 'Försäljning enl. OSS (Tyskland 19%)', + default_vat_rate: null, + default_vat_treatment: 'oss', + }, + ] + seedLedger([ + { account_number: '3001', debit_amount: 0, credit_amount: 7000 }, + { account_number: '3106', debit_amount: 0, credit_amount: 2000 }, + { account_number: '2670', debit_amount: 0, credit_amount: 1710 }, + ]) + + const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1) + + expect(result.rutor.ruta05).toBe(0) + expect(result.rutor.ruta10).toBe(0) + expect(result.rutor.ruta35).toBe(0) + expect(result.rutor.ruta42).toBe(0) + expect(result.breakdown.invoices.base25).toBe(0) + }) + it('ignores missing rates without matching evidence and keeps explicit 0 % authoritative', async () => { // A number or a free-text label alone is not enough, and an explicit // "Ingen moms" always wins over the fallback convention. diff --git a/lib/reports/vat-revenue-accounts.ts b/lib/reports/vat-revenue-accounts.ts index d3bfc751..7549dd95 100644 --- a/lib/reports/vat-revenue-accounts.ts +++ b/lib/reports/vat-revenue-accounts.ts @@ -15,7 +15,7 @@ export const RUTA_05_EXCLUDED_ACCOUNTS = new Set([ const RUTA_05_STATIC_RATE_ACCOUNTS = new Set(['3000']) const DOMESTIC_SALES_RATE_BY_SUFFIX: Record = { '1': 0.25, '2': 0.12, '3': 0.06 } const CONTRADICTING_ACCOUNT_NAME = - /momsfri|momsfritt|utan moms|omvänd|\bvmb\b|vinstmarginal|export|utanför|eu-land|unionsintern|\b0\s*%/i + /momsfri|momsfritt|utan moms|omvänd|\bvmb\b|vinstmarginal|export|utanför|eu-land|unionsintern|\boss\b|\b0\s*%/i function inferDomesticSalesRate(accountNumber: string, accountName: string): number | null { const accountMatch = /^30\d([123])$/.exec(accountNumber) diff --git a/lib/vat/__tests__/account-vat-treatment.test.ts b/lib/vat/__tests__/account-vat-treatment.test.ts index 404e2654..18ba80a0 100644 --- a/lib/vat/__tests__/account-vat-treatment.test.ts +++ b/lib/vat/__tests__/account-vat-treatment.test.ts @@ -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() }) diff --git a/lib/vat/account-vat-treatment.ts b/lib/vat/account-vat-treatment.ts index b96f59f2..c5e47ae8 100644 --- a/lib/vat/account-vat-treatment.ts +++ b/lib/vat/account-vat-treatment.ts @@ -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 = { +/** + * 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> = { 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) { diff --git a/messages/en.json b/messages/en.json index 07bdc881..787432db 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5312,6 +5312,7 @@ "vat_treatment_export_services": "Services outside the EU", "vat_treatment_vmb": "Margin scheme (box 07)", "vat_treatment_rental_voluntary": "Voluntary VAT on rental income (box 08)", + "vat_treatment_oss": "OSS sales to EU consumers (reported in OSS, not on the VAT return)", "vat_rate_label": "VAT rate", "vat_rate_none": "None", "vat_review_confirm_all": "Confirm all suggested ({count})", diff --git a/messages/sv.json b/messages/sv.json index c6c408ee..8a975c60 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -5312,6 +5312,7 @@ "vat_treatment_export_services": "Tjänster utanför EU", "vat_treatment_vmb": "Vinstmarginalbeskattning (ruta 07)", "vat_treatment_rental_voluntary": "Frivillig moms på uthyrning (ruta 08)", + "vat_treatment_oss": "OSS-försäljning till EU-konsumenter (redovisas i OSS, inte i momsdeklarationen)", "vat_rate_label": "Momssats", "vat_rate_none": "Ingen", "vat_review_confirm_all": "Bekräfta alla föreslagna ({count})", diff --git a/supabase/migrations/20260822093000_account_vat_treatment_oss.sql b/supabase/migrations/20260822093000_account_vat_treatment_oss.sql new file mode 100644 index 00000000..3a401cd6 --- /dev/null +++ b/supabase/migrations/20260822093000_account_vat_treatment_oss.sql @@ -0,0 +1,38 @@ +-- OSS (unionsordningen): sales to EU consumers above the EUR 10 000 threshold +-- carry the destination country's VAT and are declared only in the quarterly +-- OSS declaration, never in the Swedish momsdeklaration. The 'oss' revenue +-- treatment keeps such accounts out of every ruta; the resolver lives in +-- lib/vat/account-vat-treatment.ts. Purchase classes are unchanged. +ALTER TABLE public.chart_of_accounts + DROP CONSTRAINT IF EXISTS chart_of_accounts_default_vat_treatment_check; + +ALTER TABLE public.chart_of_accounts + ADD CONSTRAINT chart_of_accounts_default_vat_treatment_check + CHECK ( + default_vat_treatment IS NULL + OR ( + account_class = 3 + AND default_vat_treatment IN ( + 'standard_25', 'reduced_12', 'reduced_6', 'exempt', + 'reverse_charge_domestic', 'reverse_charge_eu_goods', + 'reverse_charge_eu_services', 'export_goods', 'export_services', + 'vmb', 'rental_voluntary', 'oss' + ) + ) + OR ( + account_class BETWEEN 4 AND 6 + AND default_vat_treatment IN ( + 'reverse_charge_domestic', 'reverse_charge_eu_goods', + 'reverse_charge_eu_services', 'reverse_charge_non_eu_services' + ) + ) + ) NOT VALID; + +-- Superset of the previous constraint: validation cannot fail on existing rows. +ALTER TABLE public.chart_of_accounts + VALIDATE CONSTRAINT chart_of_accounts_default_vat_treatment_check; + +COMMENT ON COLUMN public.chart_of_accounts.default_vat_treatment IS + 'Per-account momsdeklaration treatment. Explicit values override the built-in BAS account mapping; ''oss'' keeps revenue out of the Swedish declaration (declared in OSS).'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/__tests__/account-default-vat-treatment.pg.test.ts b/supabase/migrations/__tests__/account-default-vat-treatment.pg.test.ts index 92218a1d..6885a383 100644 --- a/supabase/migrations/__tests__/account-default-vat-treatment.pg.test.ts +++ b/supabase/migrations/__tests__/account-default-vat-treatment.pg.test.ts @@ -63,7 +63,7 @@ describe('chart_of_accounts.default_vat_treatment', () => { 'standard_25', 'reduced_12', 'reduced_6', 'exempt', 'reverse_charge_domestic', 'reverse_charge_eu_goods', 'reverse_charge_eu_services', 'export_goods', 'export_services', - 'vmb', 'rental_voluntary', null, + 'vmb', 'rental_voluntary', 'oss', null, ] for (const treatment of revenueTreatments) { await expect(setTreatment(companyId, '3001', treatment)).resolves.toBeDefined() @@ -89,6 +89,14 @@ describe('chart_of_accounts.default_vat_treatment', () => { await expect(setTreatment(companyId, '4010', 'standard_25')).rejects.toThrow() }) + it('accepts oss only on revenue accounts (20260822093000_account_vat_treatment_oss)', async () => { + const { companyId, userId } = await seedCompany() + await insertAccount(companyId, userId, '3106', 3) + await insertAccount(companyId, userId, '4010', 4) + await expect(setTreatment(companyId, '3106', 'oss')).resolves.toBeDefined() + await expect(setTreatment(companyId, '4010', 'oss')).rejects.toThrow() + }) + it('normalizes values accepted by the predecessor before enforcing classes', async () => { const { companyId, userId } = await seedCompany() await insertAccount(companyId, userId, '1010', 1)