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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
1f7acbf144
commit
51c815254a
@@ -1157,3 +1157,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
+4
-6
@@ -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()
|
||||
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, number> = { '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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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';
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user