feat(vat): add per-account declaration treatments (#1588)

Closes #1457
This commit is contained in:
Mattsson
2026-08-13 17:03:35 +02:00
committed by GitHub
parent 7881f757a9
commit d02fd82191
32 changed files with 1196 additions and 253 deletions
+19
View File
@@ -102,6 +102,25 @@ function run(
// --- Tests ---
describe('syncMappedAccounts: create pass', () => {
it('persists a reviewed VAT treatment on a created identity account', async () => {
const { supabase, inserts } = buildCapturingSupabase()
const result = await run(supabase, [
mapping({
sourceAccount: '4056',
targetAccount: '4056',
sourceName: 'Inköp varor 25% EU',
defaultVatTreatment: 'reverse_charge_eu_goods',
defaultVatRate: 0.25,
vatTreatmentReviewed: true,
}),
])
expect(result.error).toBeNull()
expect(inserts[0]).toMatchObject({
default_vat_treatment: 'reverse_charge_eu_goods',
default_vat_rate: 0.25,
})
})
it('creates a missing BAS account with the BAS default name when the file has no custom name', async () => {
const { supabase, inserts } = buildCapturingSupabase()
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import {
applyVatTreatmentReview,
enrichAccountMappingsWithVat,
} from '../account-vat-treatment'
import type { AccountMapping } from '../types'
function mapping(account: string, name: string): AccountMapping {
return {
sourceAccount: account,
sourceName: name,
targetAccount: account,
targetName: name,
confidence: 1,
matchType: 'exact',
isOverride: false,
}
}
describe('enrichAccountMappingsWithVat', () => {
it('marks label suggestions for user review', () => {
const [result] = enrichAccountMappingsWithVat(
[mapping('4056', 'Inköp varor 25% EU')],
[],
)
expect(result).toMatchObject({
defaultVatTreatment: 'reverse_charge_eu_goods',
defaultVatRate: 0.25,
vatTreatmentSuggested: true,
vatTreatmentReviewed: false,
requiresVatTreatmentReview: true,
})
})
it('requires review for suggested class 5 and 6 purchase treatments', () => {
const results = enrichAccountMappingsWithVat(
[
mapping('5010', 'Inköp tjänst EU'),
mapping('6010', 'Inköp tjänst utanför EU'),
],
[],
)
expect(results).toEqual([
expect.objectContaining({
defaultVatTreatment: 'reverse_charge_eu_services',
requiresVatTreatmentReview: true,
vatTreatmentReviewed: false,
}),
expect.objectContaining({
defaultVatTreatment: 'export_services',
requiresVatTreatmentReview: true,
vatTreatmentReviewed: false,
}),
])
})
it('keeps an existing account treatment without asking again', () => {
const [result] = enrichAccountMappingsWithVat(
[mapping('3041', 'Försäljning tjänst 25% sv')],
[{
account_number: '3041',
default_vat_treatment: 'standard_25',
default_vat_rate: 0.25,
} as never],
)
expect(result).toMatchObject({
defaultVatTreatment: 'standard_25',
vatTreatmentSuggested: false,
vatTreatmentReviewed: true,
requiresVatTreatmentReview: false,
})
})
})
describe('applyVatTreatmentReview', () => {
it('marks only the selected row reviewed, including class 5 and 6 accounts', () => {
const mappings = [
mapping('5010', 'Inköp tjänst EU'),
mapping('3041', 'Försäljning tjänst 25% sv'),
]
const result = applyVatTreatmentReview(
mappings,
'5010',
'reverse_charge_eu_services',
0.25,
)
expect(result[0]).toMatchObject({
defaultVatTreatment: 'reverse_charge_eu_services',
defaultVatRate: 0.25,
vatTreatmentReviewed: true,
})
expect(result[1].vatTreatmentReviewed).toBeUndefined()
})
})
+45 -1
View File
@@ -14,6 +14,7 @@ import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { AccountMapping } from './types'
import { isAccountVatTreatment, type AccountVatTreatment } from '@/lib/vat/account-vat-treatment'
export interface AccountSyncResult {
/** Accounts inserted into chart_of_accounts */
@@ -43,6 +44,7 @@ function buildInsertRow(
basRef: BASReferenceAccount | undefined,
companyId: string,
userId: string,
vatDefaults?: { treatment: AccountVatTreatment | null; rate: number | null },
) {
const sortOrder = /^\d+$/.test(accountNumber) ? parseInt(accountNumber, 10) : null
@@ -63,6 +65,8 @@ function buildInsertRow(
is_system_account: false,
description: basRef.description,
sort_order: sortOrder,
default_vat_treatment: vatDefaults?.treatment ?? null,
default_vat_rate: vatDefaults?.rate ?? null,
}
}
@@ -84,6 +88,8 @@ function buildInsertRow(
is_system_account: false,
description: accountName,
sort_order: sortOrder,
default_vat_treatment: vatDefaults?.treatment ?? null,
default_vat_rate: vatDefaults?.rate ?? null,
}
}
@@ -143,6 +149,27 @@ export async function syncMappedAccounts(
if (m.targetAccount && fallback) fallbackNames.set(m.targetAccount, fallback)
}
const vatDefaults = new Map<string, { treatment: AccountVatTreatment | null; rate: number | null }>()
for (const mapping of mappings) {
if (
!mapping.vatTreatmentReviewed ||
!mapping.targetAccount ||
mapping.sourceAccount !== mapping.targetAccount
) continue
if (
mapping.defaultVatTreatment !== null &&
mapping.defaultVatTreatment !== undefined &&
!isAccountVatTreatment(mapping.defaultVatTreatment)
) {
result.error = `Invalid VAT treatment for account ${mapping.sourceAccount}`
return result
}
vatDefaults.set(mapping.targetAccount, {
treatment: mapping.defaultVatTreatment ?? null,
rate: mapping.defaultVatRate ?? null,
})
}
// Fetch the company's chart once (paged) and filter in JS: avoids a huge
// .in() URL for full-chart imports and the silent 1000-row PostgREST cap.
let existingByNumber: Map<string, string>
@@ -181,7 +208,7 @@ export async function syncMappedAccounts(
basRef?.account_name ??
fallbackNames.get(num) ??
`Konto ${num}`
return buildInsertRow(num, name, basRef, companyId, userId)
return buildInsertRow(num, name, basRef, companyId, userId, vatDefaults.get(num))
})
const { error: insertError } = await supabase.from('chart_of_accounts').insert(inserts)
@@ -195,6 +222,23 @@ export async function syncMappedAccounts(
result.created = missing.length
}
const existingVatUpdates = [...vatDefaults]
.filter(([account]) => existingByNumber.has(account))
for (const [account, defaults] of existingVatUpdates) {
const { error: vatUpdateError } = await supabase
.from('chart_of_accounts')
.update({
default_vat_treatment: defaults.treatment,
default_vat_rate: defaults.rate,
})
.eq('company_id', companyId)
.eq('account_number', account)
if (vatUpdateError) {
result.error = vatUpdateError.message
return result
}
}
// Rename pass: carry the file's names into existing accounts. The diff set
// is small (only names that actually changed), so the UPDATEs run
// concurrently in bounded batches: a full-chart re-sync must not serialize
+67
View File
@@ -0,0 +1,67 @@
import type { BASAccount } from '@/types'
import {
suggestVatTreatment,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
import type { AccountMapping } from './types'
/**
* Add reviewable VAT suggestions to identity mappings. SIE itself has no VAT
* treatment record, so suggestions come only from the account label and are
* never considered reviewed until the user continues from the mapping step.
*/
export function enrichAccountMappingsWithVat(
mappings: AccountMapping[],
existingAccounts: BASAccount[],
): AccountMapping[] {
const existingByNumber = new Map(
existingAccounts.map((account) => [account.account_number, account]),
)
return mappings.map((mapping) => {
if (!mapping.targetAccount || mapping.sourceAccount !== mapping.targetAccount) return mapping
const accountClass = Number(mapping.sourceAccount.charAt(0))
if (accountClass < 3 || accountClass > 6) return mapping
const existing = existingByNumber.get(mapping.targetAccount)
if (existing?.default_vat_treatment) {
return {
...mapping,
defaultVatTreatment: existing.default_vat_treatment,
defaultVatRate: existing.default_vat_rate,
vatTreatmentReviewed: true,
vatTreatmentSuggested: false,
requiresVatTreatmentReview: false,
}
}
const suggestion = suggestVatTreatment(mapping.sourceAccount, mapping.sourceName)
return {
...mapping,
defaultVatTreatment: suggestion?.treatment ?? null,
defaultVatRate: suggestion?.rate ?? existing?.default_vat_rate ?? null,
vatTreatmentSuggested: Boolean(suggestion),
vatTreatmentReviewed: false,
requiresVatTreatmentReview: accountClass >= 3 && accountClass <= 6,
}
})
}
export function applyVatTreatmentReview(
mappings: AccountMapping[],
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
): AccountMapping[] {
return mappings.map((mapping) =>
mapping.sourceAccount === sourceAccount
? {
...mapping,
defaultVatTreatment: treatment,
defaultVatRate: rate,
vatTreatmentSuggested: false,
vatTreatmentReviewed: true,
}
: mapping
)
}
+5
View File
@@ -185,6 +185,11 @@ export interface AccountMapping {
confidence: number // 0-1
matchType: AccountMatchType
isOverride: boolean // User manually set this
defaultVatTreatment?: import('@/lib/vat/account-vat-treatment').AccountVatTreatment | null
defaultVatRate?: number | null
vatTreatmentSuggested?: boolean
vatTreatmentReviewed?: boolean
requiresVatTreatmentReview?: boolean
}
/**