Files
accounted/supabase/migrations/__tests__/account-default-vat-treatment.pg.test.ts
T
51c815254a 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>
2026-08-22 10:49:07 +02:00

146 lines
5.5 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { seedCompany } from '@/tests/pg/fixtures'
import { getPool } from '@/tests/pg/setup'
const NORMALIZE_SQL = readFileSync(
join(process.cwd(), 'supabase/migrations/20260815150100_normalize_legacy_account_vat_treatments.sql'),
'utf8',
)
const DROP_LEGACY_CONSTRAINT_SQL = readFileSync(
join(process.cwd(), 'supabase/migrations/20260815150000_drop_legacy_account_vat_treatment_constraint.sql'),
'utf8',
)
const ENFORCE_SQL = readFileSync(
join(process.cwd(), 'supabase/migrations/20260815150300_enforce_class_aware_account_vat_treatment.sql'),
'utf8',
)
const CLEAR_INCOMPATIBLE_SQL = readFileSync(
join(process.cwd(), 'supabase/migrations/20260815150200_clear_incompatible_account_vat_treatments.sql'),
'utf8',
)
async function setTreatment(companyId: string, accountNumber: string, treatment: string | null) {
return getPool().query(
`UPDATE public.chart_of_accounts
SET default_vat_treatment = $2
WHERE company_id = $1 AND account_number = $3`,
[companyId, treatment, accountNumber],
)
}
async function insertAccount(
companyId: string,
userId: string,
accountNumber: string,
accountClass: number,
) {
await getPool().query(
`INSERT INTO public.chart_of_accounts
(user_id, company_id, account_number, account_name, account_class,
account_group, account_type, normal_balance, plan_type, is_system_account)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'full_bas', false)`,
[
userId,
companyId,
accountNumber,
`Test account ${accountNumber}`,
accountClass,
accountNumber.slice(0, 2),
accountClass === 3 ? 'revenue' : 'expense',
accountClass === 3 ? 'credit' : 'debit',
],
)
}
describe('chart_of_accounts.default_vat_treatment', () => {
it('accepts every supported treatment and NULL', async () => {
const { companyId, userId } = await seedCompany()
await insertAccount(companyId, userId, '3001', 3)
await insertAccount(companyId, userId, '4010', 4)
const revenueTreatments = [
'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', null,
]
for (const treatment of revenueTreatments) {
await expect(setTreatment(companyId, '3001', treatment)).resolves.toBeDefined()
}
const purchaseTreatments = [
'reverse_charge_domestic', 'reverse_charge_eu_goods',
'reverse_charge_eu_services', 'reverse_charge_non_eu_services', null,
]
for (const treatment of purchaseTreatments) {
await expect(setTreatment(companyId, '4010', treatment)).resolves.toBeDefined()
}
})
it('rejects unknown treatments', async () => {
const { companyId, userId } = await seedCompany()
await insertAccount(companyId, userId, '3001', 3)
await expect(setTreatment(companyId, '3001', 'unknown')).rejects.toThrow()
})
it('rejects a treatment that is incompatible with the account class', async () => {
const { companyId, userId } = await seedCompany()
await insertAccount(companyId, userId, '4010', 4)
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)
await insertAccount(companyId, userId, '4010', 4)
await insertAccount(companyId, userId, '4011', 4)
await insertAccount(companyId, userId, '4012', 4)
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(DROP_LEGACY_CONSTRAINT_SQL)
await client.query(`
UPDATE public.chart_of_accounts
SET default_vat_treatment = CASE account_number
WHEN '1010' THEN 'standard_25'
WHEN '4010' THEN 'export_services'
WHEN '4011' THEN 'export_goods'
WHEN '4012' THEN 'standard_25'
END
WHERE company_id = $1 AND account_number IN ('1010', '4010', '4011', '4012')
`, [companyId])
await client.query(NORMALIZE_SQL)
await client.query(CLEAR_INCOMPATIBLE_SQL)
await client.query(ENFORCE_SQL)
const result = await client.query<{
account_number: string
default_vat_treatment: string | null
}>(`
SELECT account_number, default_vat_treatment
FROM public.chart_of_accounts
WHERE company_id = $1 AND account_number IN ('1010', '4010', '4011', '4012')
ORDER BY account_number
`, [companyId])
expect(result.rows).toEqual([
{ account_number: '1010', default_vat_treatment: null },
{ account_number: '4010', default_vat_treatment: 'reverse_charge_non_eu_services' },
{ account_number: '4011', default_vat_treatment: null },
{ account_number: '4012', default_vat_treatment: null },
])
} finally {
await client.query('ROLLBACK').catch(() => {})
client.release()
}
})
})