fix(vat): complete account treatment enforcement (#1593)
* fix(vat): complete account treatment enforcement * docs(api): refresh account endpoint skill * fix(mcp): preserve ruta 05 compatibility * test(vat): seed migration constraint fixtures * docs(vat): clarify treatment precedence --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
edfdbe2d2a
commit
86f0b70fdd
@@ -1996,23 +1996,6 @@ describe('CreateAccountSchema', () => {
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a VAT treatment that does not apply to the account class', () => {
|
||||
expect(CreateAccountSchema.safeParse({
|
||||
account_number: '5010',
|
||||
account_name: 'Consulting costs',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
default_vat_treatment: 'standard_25',
|
||||
}).success).toBe(false)
|
||||
expect(CreateAccountSchema.safeParse({
|
||||
account_number: '5010',
|
||||
account_name: 'EU services',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
default_vat_treatment: 'reverse_charge_eu_services',
|
||||
}).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
@@ -2327,6 +2310,9 @@ describe('UpdateAccountSchema', () => {
|
||||
expect(UpdateAccountSchema.safeParse({
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
}).success).toBe(true)
|
||||
expect(UpdateAccountSchema.safeParse({
|
||||
default_vat_treatment: 'reverse_charge_non_eu_services',
|
||||
}).success).toBe(true)
|
||||
expect(UpdateAccountSchema.safeParse({ default_vat_treatment: null }).success).toBe(true)
|
||||
expect(UpdateAccountSchema.safeParse({ default_vat_treatment: 'eu_purchase' }).success).toBe(false)
|
||||
})
|
||||
|
||||
+2
-17
@@ -16,7 +16,6 @@ import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
|
||||
import { PERSONAL_NUMBER_INPUT_RE } from '@/lib/customers/mask-personal-number'
|
||||
import type { AuditAction } from '@/types'
|
||||
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
|
||||
import { isVatTreatmentValidForAccountClass } from '@/lib/vat/account-vat-treatment'
|
||||
|
||||
// ============================================================
|
||||
// Shared primitives
|
||||
@@ -2124,8 +2123,8 @@ const defaultVatRate = z
|
||||
export const AccountVatTreatmentSchema = z.enum([
|
||||
'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',
|
||||
'reverse_charge_eu_services', 'reverse_charge_non_eu_services',
|
||||
'export_goods', 'export_services', 'vmb', 'rental_voluntary',
|
||||
])
|
||||
|
||||
const defaultVatTreatment = AccountVatTreatmentSchema.nullable().optional()
|
||||
@@ -2141,20 +2140,6 @@ export const CreateAccountSchema = z.object({
|
||||
default_vat_rate: defaultVatRate,
|
||||
default_vat_treatment: defaultVatTreatment,
|
||||
sru_code: z.string().nullable().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (
|
||||
value.default_vat_treatment &&
|
||||
!isVatTreatmentValidForAccountClass(
|
||||
value.default_vat_treatment,
|
||||
Number(value.account_number.charAt(0)),
|
||||
)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['default_vat_treatment'],
|
||||
message: 'VAT treatment is not valid for the account class',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const UpdateAccountSchema = z.object({
|
||||
|
||||
@@ -121,6 +121,22 @@ describe('syncMappedAccounts: create pass', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the booking rate for a confirmed treatment when the rate is unset', async () => {
|
||||
const { supabase, inserts } = buildCapturingSupabase()
|
||||
const result = await run(supabase, [
|
||||
mapping({
|
||||
sourceAccount: '4056',
|
||||
targetAccount: '4056',
|
||||
sourceName: 'Inköp varor EU',
|
||||
defaultVatTreatment: 'reverse_charge_eu_goods',
|
||||
defaultVatRate: null,
|
||||
vatTreatmentReviewed: true,
|
||||
}),
|
||||
])
|
||||
expect(result.error).toBeNull()
|
||||
expect(inserts[0].default_vat_rate).toBe(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()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyVatTreatmentReview,
|
||||
enrichChangedAccountMappingWithVat,
|
||||
enrichAccountMappingsWithVat,
|
||||
} from '../account-vat-treatment'
|
||||
import type { AccountMapping } from '../types'
|
||||
@@ -32,28 +33,6 @@ describe('enrichAccountMappingsWithVat', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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')],
|
||||
@@ -70,25 +49,99 @@ describe('enrichAccountMappingsWithVat', () => {
|
||||
requiresVatTreatmentReview: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves an existing booking rate when suggesting a missing treatment', () => {
|
||||
const [result] = enrichAccountMappingsWithVat(
|
||||
[mapping('3041', 'Försäljning tjänst 25% sv')],
|
||||
[{
|
||||
account_number: '3041',
|
||||
default_vat_treatment: null,
|
||||
default_vat_rate: 0.12,
|
||||
} as never],
|
||||
)
|
||||
expect(result.defaultVatTreatment).toBe('standard_25')
|
||||
expect(result.defaultVatRate).toBe(0.12)
|
||||
})
|
||||
|
||||
it('requires review for a suggested class 6 service treatment', () => {
|
||||
const [result] = enrichAccountMappingsWithVat(
|
||||
[mapping('6545', 'Inköp tjänster utanför EU 25%')],
|
||||
[],
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
defaultVatTreatment: 'reverse_charge_non_eu_services',
|
||||
requiresVatTreatmentReview: true,
|
||||
vatTreatmentReviewed: 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(
|
||||
it('persists a suggestion only after an explicit row confirmation', () => {
|
||||
const mappings = enrichAccountMappingsWithVat([
|
||||
mapping('4056', 'Inköp varor 25% EU'),
|
||||
], [])
|
||||
|
||||
expect(mappings[0].vatTreatmentReviewed).toBe(false)
|
||||
const reviewed = applyVatTreatmentReview(
|
||||
mappings,
|
||||
'5010',
|
||||
'reverse_charge_eu_services',
|
||||
0.25,
|
||||
'4056',
|
||||
mappings[0].defaultVatTreatment ?? null,
|
||||
mappings[0].defaultVatRate ?? null,
|
||||
)
|
||||
expect(result[0]).toMatchObject({
|
||||
defaultVatTreatment: 'reverse_charge_eu_services',
|
||||
defaultVatRate: 0.25,
|
||||
expect(reviewed[0]).toMatchObject({
|
||||
defaultVatTreatment: 'reverse_charge_eu_goods',
|
||||
vatTreatmentReviewed: true,
|
||||
vatTreatmentSuggested: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('clears hidden review state on remap and restores it on identity mapping', () => {
|
||||
const [suggested] = enrichAccountMappingsWithVat([
|
||||
mapping('4056', 'Inköp varor 25% EU'),
|
||||
], [])
|
||||
|
||||
const [remapped] = enrichAccountMappingsWithVat([{
|
||||
...suggested,
|
||||
targetAccount: '4010',
|
||||
targetName: 'Inköp material',
|
||||
}], [])
|
||||
expect(remapped).toMatchObject({
|
||||
defaultVatTreatment: null,
|
||||
requiresVatTreatmentReview: false,
|
||||
vatTreatmentReviewed: true,
|
||||
})
|
||||
|
||||
const [identity] = enrichAccountMappingsWithVat([{
|
||||
...remapped,
|
||||
targetAccount: '4056',
|
||||
targetName: 'Inköp varor 25% EU',
|
||||
}], [])
|
||||
expect(identity).toMatchObject({
|
||||
defaultVatTreatment: 'reverse_charge_eu_goods',
|
||||
requiresVatTreatmentReview: true,
|
||||
vatTreatmentReviewed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves another row review when one mapping changes', () => {
|
||||
const initial = enrichAccountMappingsWithVat([
|
||||
mapping('3041', 'Försäljning tjänst 25% sv'),
|
||||
mapping('4056', 'Inköp varor 25% EU'),
|
||||
], [])
|
||||
const reviewed = applyVatTreatmentReview(initial, '3041', 'exempt', 0)
|
||||
const remapped = enrichChangedAccountMappingWithVat(
|
||||
reviewed.map((item) => item.sourceAccount === '4056'
|
||||
? { ...item, targetAccount: '4010', targetName: 'Inköp material' }
|
||||
: item),
|
||||
'4056',
|
||||
[],
|
||||
)
|
||||
|
||||
expect(remapped[0]).toMatchObject({
|
||||
defaultVatTreatment: 'exempt',
|
||||
defaultVatRate: 0,
|
||||
vatTreatmentReviewed: true,
|
||||
})
|
||||
expect(result[1].vatTreatmentReviewed).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,12 @@ 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'
|
||||
import {
|
||||
defaultRateForVatTreatment,
|
||||
isAccountVatTreatment,
|
||||
isVatTreatmentAllowedForAccountClass,
|
||||
type AccountVatTreatment,
|
||||
} from '@/lib/vat/account-vat-treatment'
|
||||
|
||||
export interface AccountSyncResult {
|
||||
/** Accounts inserted into chart_of_accounts */
|
||||
@@ -164,9 +169,19 @@ export async function syncMappedAccounts(
|
||||
result.error = `Invalid VAT treatment for account ${mapping.sourceAccount}`
|
||||
return result
|
||||
}
|
||||
const accountClass = Number(mapping.targetAccount.charAt(0))
|
||||
if (
|
||||
mapping.defaultVatTreatment &&
|
||||
!isVatTreatmentAllowedForAccountClass(mapping.defaultVatTreatment, accountClass)
|
||||
) {
|
||||
result.error = `VAT treatment is not valid for account ${mapping.sourceAccount}`
|
||||
return result
|
||||
}
|
||||
vatDefaults.set(mapping.targetAccount, {
|
||||
treatment: mapping.defaultVatTreatment ?? null,
|
||||
rate: mapping.defaultVatRate ?? null,
|
||||
rate: mapping.defaultVatTreatment && mapping.defaultVatRate == null
|
||||
? defaultRateForVatTreatment(mapping.defaultVatTreatment, accountClass)
|
||||
: mapping.defaultVatRate ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,16 @@ export function enrichAccountMappingsWithVat(
|
||||
)
|
||||
|
||||
return mappings.map((mapping) => {
|
||||
if (!mapping.targetAccount || mapping.sourceAccount !== mapping.targetAccount) return mapping
|
||||
if (!mapping.targetAccount || mapping.sourceAccount !== mapping.targetAccount) {
|
||||
return {
|
||||
...mapping,
|
||||
defaultVatTreatment: null,
|
||||
defaultVatRate: null,
|
||||
vatTreatmentSuggested: false,
|
||||
vatTreatmentReviewed: true,
|
||||
requiresVatTreatmentReview: false,
|
||||
}
|
||||
}
|
||||
const accountClass = Number(mapping.sourceAccount.charAt(0))
|
||||
if (accountClass < 3 || accountClass > 6) return mapping
|
||||
|
||||
@@ -39,10 +48,10 @@ export function enrichAccountMappingsWithVat(
|
||||
return {
|
||||
...mapping,
|
||||
defaultVatTreatment: suggestion?.treatment ?? null,
|
||||
defaultVatRate: suggestion?.rate ?? existing?.default_vat_rate ?? null,
|
||||
defaultVatRate: existing?.default_vat_rate ?? suggestion?.rate ?? null,
|
||||
vatTreatmentSuggested: Boolean(suggestion),
|
||||
vatTreatmentReviewed: false,
|
||||
requiresVatTreatmentReview: accountClass >= 3 && accountClass <= 6,
|
||||
requiresVatTreatmentReview: accountClass === 3 || accountClass === 4 || Boolean(suggestion),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -65,3 +74,15 @@ export function applyVatTreatmentReview(
|
||||
: mapping
|
||||
)
|
||||
}
|
||||
|
||||
export function enrichChangedAccountMappingWithVat(
|
||||
mappings: AccountMapping[],
|
||||
sourceAccount: string,
|
||||
existingAccounts: BASAccount[],
|
||||
): AccountMapping[] {
|
||||
return mappings.map((mapping) =>
|
||||
mapping.sourceAccount === sourceAccount
|
||||
? enrichAccountMappingsWithVat([mapping], existingAccounts)[0]
|
||||
: mapping
|
||||
)
|
||||
}
|
||||
|
||||
@@ -189,6 +189,51 @@ describe('commitPendingOperation: update_account', () => {
|
||||
expect(result.status).toBe('committed')
|
||||
})
|
||||
|
||||
it('derives an omitted booking rate at commit when the stored rate is unset', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' } }) // CAS claim
|
||||
enqueue({ data: { default_vat_rate: null } }) // current account
|
||||
enqueue({ data: { account_number: '4056', account_name: 'EU-varor', is_active: true } }) // update
|
||||
enqueue({ data: null }) // finalize update
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'update_account',
|
||||
params: {
|
||||
account_number: '4056',
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
},
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(findCalls('chart_of_accounts', 'update')[0]?.[0]).toMatchObject({
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
default_vat_rate: 0.25,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a stored booking rate when a staged treatment omits it', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' } }) // CAS claim
|
||||
enqueue({ data: { default_vat_rate: 0.12 } }) // current account
|
||||
enqueue({ data: { account_number: '4056', account_name: 'EU-varor', is_active: true } }) // update
|
||||
enqueue({ data: null }) // finalize update
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'update_account',
|
||||
params: {
|
||||
account_number: '4056',
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
},
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(findCalls('chart_of_accounts', 'update')[0]?.[0]).toEqual({
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
})
|
||||
})
|
||||
|
||||
it('unknown account (PGRST116) auto-rejects with 404', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' } }) // CAS claim
|
||||
|
||||
@@ -128,6 +128,7 @@ import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pend
|
||||
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
|
||||
import { RetagLineDimensionsParamsSchema } from '@/lib/pending-operations/schemas/retag-line-dimensions'
|
||||
import { CreateAccountParamsSchema, UpdateAccountParamsSchema } from '@/lib/pending-operations/schemas/account'
|
||||
import { defaultRateForVatTreatment } from '@/lib/vat/account-vat-treatment'
|
||||
import { SetVoucherNoteParamsSchema } from '@/lib/pending-operations/schemas/voucher-note'
|
||||
import { UpdateCompanySettingsParamsSchema } from '@/lib/pending-operations/schemas/company-settings'
|
||||
import { UpdateCustomerParamsSchema } from '@/lib/pending-operations/schemas/customer'
|
||||
@@ -946,6 +947,13 @@ async function commitCreateAccount(
|
||||
// Same row shape as the dashboard create route
|
||||
// (app/api/bookkeeping/accounts/route.ts): class/group/sort_order derive
|
||||
// from the number so the two write paths cannot drift.
|
||||
const defaultVatRate = validated.default_vat_treatment && validated.default_vat_rate == null
|
||||
? defaultRateForVatTreatment(
|
||||
validated.default_vat_treatment,
|
||||
Number(validated.account_number[0]),
|
||||
)
|
||||
: validated.default_vat_rate ?? null
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert({
|
||||
@@ -962,7 +970,8 @@ async function commitCreateAccount(
|
||||
is_system_account: false,
|
||||
description: validated.description ?? null,
|
||||
default_vat_code: validated.default_vat_code ?? null,
|
||||
default_vat_rate: validated.default_vat_rate ?? null,
|
||||
default_vat_rate: defaultVatRate,
|
||||
default_vat_treatment: validated.default_vat_treatment ?? null,
|
||||
sru_code: validated.sru_code ?? null,
|
||||
sort_order: parseInt(validated.account_number),
|
||||
})
|
||||
@@ -1001,6 +1010,31 @@ async function commitUpdateAccount(
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
if (value !== undefined) updateData[key] = value
|
||||
}
|
||||
|
||||
if (validated.default_vat_treatment && validated.default_vat_rate == null) {
|
||||
const { data: current, error: currentError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('default_vat_rate')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_number', account_number)
|
||||
.single()
|
||||
|
||||
if (currentError) {
|
||||
if (currentError.code === 'PGRST116') {
|
||||
return { error: 'Kontot hittades inte', status: 404 }
|
||||
}
|
||||
return { error: currentError.message, status: 500 }
|
||||
}
|
||||
|
||||
if (current.default_vat_rate == null) {
|
||||
updateData.default_vat_rate = defaultRateForVatTreatment(
|
||||
validated.default_vat_treatment,
|
||||
Number(account_number.charAt(0)),
|
||||
)
|
||||
} else {
|
||||
delete updateData.default_vat_rate
|
||||
}
|
||||
}
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return { error: 'Inget att uppdatera', status: 400 }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
ACCOUNT_VAT_TREATMENTS,
|
||||
isVatTreatmentAllowedForAccountClass,
|
||||
} from '@/lib/vat/account-vat-treatment'
|
||||
|
||||
// Commit-boundary re-validation for staged chart-of-accounts operations
|
||||
// (gnubok_create_account / gnubok_update_account). A staged
|
||||
@@ -27,6 +31,8 @@ const defaultVatRate = z
|
||||
.nullable()
|
||||
.optional()
|
||||
|
||||
const defaultVatTreatment = z.enum(ACCOUNT_VAT_TREATMENTS).nullable().optional()
|
||||
|
||||
/** Empty string / null → undefined, then bounded string. */
|
||||
const optString = (max: number) =>
|
||||
z.preprocess((v) => (v == null || v === '' ? undefined : v), z.string().max(max).optional())
|
||||
@@ -84,6 +90,7 @@ export const CreateAccountParamsSchema = z
|
||||
description: optString(2000),
|
||||
default_vat_code: optString(32),
|
||||
default_vat_rate: defaultVatRate,
|
||||
default_vat_treatment: defaultVatTreatment,
|
||||
sru_code: optString(16),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
@@ -91,6 +98,19 @@ export const CreateAccountParamsSchema = z
|
||||
if (conflict) {
|
||||
ctx.addIssue({ code: 'custom', message: conflict, path: ['account_type'] })
|
||||
}
|
||||
if (
|
||||
v.default_vat_treatment &&
|
||||
!isVatTreatmentAllowedForAccountClass(
|
||||
v.default_vat_treatment,
|
||||
Number(v.account_number[0]),
|
||||
)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'VAT treatment is not valid for this account class',
|
||||
path: ['default_vat_treatment'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const UpdateAccountParamsSchema = z.object({
|
||||
@@ -99,8 +119,23 @@ export const UpdateAccountParamsSchema = z.object({
|
||||
description: clearableString(2000),
|
||||
default_vat_code: clearableString(32),
|
||||
default_vat_rate: defaultVatRate,
|
||||
default_vat_treatment: defaultVatTreatment,
|
||||
sru_code: clearableString(16),
|
||||
is_active: z.boolean().optional(),
|
||||
}).superRefine((v, ctx) => {
|
||||
if (
|
||||
v.default_vat_treatment &&
|
||||
!isVatTreatmentAllowedForAccountClass(
|
||||
v.default_vat_treatment,
|
||||
Number(v.account_number[0]),
|
||||
)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'VAT treatment is not valid for this account class',
|
||||
path: ['default_vat_treatment'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type CreateAccountParams = z.infer<typeof CreateAccountParamsSchema>
|
||||
|
||||
@@ -143,14 +143,14 @@ describe('rutorFromTotals: explicit account VAT treatments', () => {
|
||||
expect(rutor.ruta20).toBe(1000)
|
||||
})
|
||||
|
||||
it('keeps a static BAS mapping authoritative over an explicit treatment', () => {
|
||||
it('lets an explicit treatment replace a static BAS mapping', () => {
|
||||
const totals = new Map([['3001', { debit: 0, credit: 1000 }]])
|
||||
const rutor = rutorFromTotals(totals, {
|
||||
mappingByAccount: new Map([['3001', { box: 'ruta42', side: 'credit' }]]),
|
||||
explicitAccounts: new Set(['3001']),
|
||||
})
|
||||
expect(rutor.ruta05).toBe(1000)
|
||||
expect(rutor.ruta42).toBe(0)
|
||||
expect(rutor.ruta05).toBe(0)
|
||||
expect(rutor.ruta42).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -150,8 +150,9 @@ export async function findRcBasisGaps(
|
||||
|
||||
const basisByEntryAndRate = new Map<string, number>()
|
||||
for (const line of siblingLines) {
|
||||
const rate = STATIC_RC_BASIS_RATE.get(line.account_number) ??
|
||||
dynamicVatAccounts.rcBasisRateByAccount.get(line.account_number)
|
||||
const rate = dynamicVatAccounts.explicitAccounts.has(line.account_number)
|
||||
? dynamicVatAccounts.rcBasisRateByAccount.get(line.account_number)
|
||||
: STATIC_RC_BASIS_RATE.get(line.account_number)
|
||||
if (rate) {
|
||||
const key = `${line.journal_entry_id}:${rate}`
|
||||
const prev = basisByEntryAndRate.get(key) || 0
|
||||
|
||||
@@ -436,6 +436,7 @@ export function rutorFromTotals(
|
||||
}
|
||||
|
||||
for (const [account, mapping] of Object.entries(ACCOUNT_RUTA)) {
|
||||
if (dynamic?.explicitAccounts.has(account)) continue
|
||||
const t = totals.get(account)
|
||||
if (!t) continue
|
||||
const balance = mapping.side === 'credit'
|
||||
@@ -445,7 +446,6 @@ export function rutorFromTotals(
|
||||
}
|
||||
|
||||
for (const [account, mapping] of dynamic?.mappingByAccount ?? []) {
|
||||
if (ACCOUNT_RUTA[account]) continue
|
||||
const t = totals.get(account)
|
||||
if (!t) continue
|
||||
const balance = mapping.side === 'credit' ? t.credit - t.debit : t.debit - t.credit
|
||||
@@ -561,6 +561,7 @@ export async function calculateVatDeclaration(
|
||||
}
|
||||
const RATE_BUCKET = { 0.25: 'base25', 0.12: 'base12', 0.06: 'base6' } as const
|
||||
for (const [account, rate] of [['3001', 'base25'], ['3002', 'base12'], ['3003', 'base6']] as const) {
|
||||
if (dynamicVatAccounts.explicitAccounts.has(account)) continue
|
||||
const t = totals.get(account)
|
||||
if (t) revenueByRate[rate] = round(t.credit - t.debit)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import type {
|
||||
VatDeclarationCheck,
|
||||
VatCheckAccountTotals,
|
||||
} from './vat-declaration-checks'
|
||||
import type { VatDeclarationRutor } from '@/types'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { VatDeclarationRutor } from '@/types'
|
||||
|
||||
/**
|
||||
* The filing gate for the momsdeklaration: ONE derived value that the
|
||||
@@ -94,12 +94,6 @@ export const RC_BASIS_ACCOUNTS_BY_RATE = {
|
||||
r6: ['4517', '4537', '4533', '4417', '4427'],
|
||||
} as const
|
||||
|
||||
const STATIC_RC_BASIS_ACCOUNTS = new Set<string>([
|
||||
...RC_BASIS_ACCOUNTS_BY_RATE.r25,
|
||||
...RC_BASIS_ACCOUNTS_BY_RATE.r12,
|
||||
...RC_BASIS_ACCOUNTS_BY_RATE.r6,
|
||||
])
|
||||
|
||||
/** Net debit balance of the RC basis accounts, one figure per momssats. */
|
||||
export interface RcBasisTotalsByRate {
|
||||
r25: number
|
||||
@@ -120,6 +114,7 @@ export function rcBasisTotalsByRate(
|
||||
const sumGroup = (accounts: readonly string[]): number => {
|
||||
let sum = 0
|
||||
for (const account of accounts) {
|
||||
if (dynamic?.explicitAccounts.has(account)) continue
|
||||
const t = totals.get(account)
|
||||
if (t) sum += t.debit - t.credit
|
||||
}
|
||||
@@ -131,7 +126,6 @@ export function rcBasisTotalsByRate(
|
||||
r6: sumGroup(RC_BASIS_ACCOUNTS_BY_RATE.r6),
|
||||
}
|
||||
for (const [account, rate] of dynamic?.rcBasisRateByAccount ?? []) {
|
||||
if (STATIC_RC_BASIS_ACCOUNTS.has(account)) continue
|
||||
const total = totals.get(account)
|
||||
if (!total) continue
|
||||
const key = rate === 0.25 ? 'r25' : rate === 0.12 ? 'r12' : 'r6'
|
||||
|
||||
@@ -42,7 +42,7 @@ const emptyDynamicVatAccounts = (): DynamicVatAccounts => ({
|
||||
rcBasisRateByAccount: new Map(),
|
||||
})
|
||||
|
||||
/** Explicit treatments extend custom accounts; fixed BAS mappings stay authoritative. */
|
||||
/** Explicit account treatments win; accounts without one keep BAS fallback. */
|
||||
export async function fetchDynamicVatAccounts(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
@@ -58,7 +58,6 @@ export async function fetchDynamicVatAccounts(
|
||||
.select('account_number, account_name, account_class, default_vat_rate, default_vat_treatment')
|
||||
.eq('company_id', companyId)
|
||||
.in('account_class', [3, 4, 5, 6])
|
||||
.not('is_active', 'is', false)
|
||||
.order('account_number', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
@@ -66,27 +65,25 @@ export async function fetchDynamicVatAccounts(
|
||||
const result = emptyDynamicVatAccounts()
|
||||
for (const row of rows) {
|
||||
const account = row.account_number
|
||||
const accountClass = Number(row.account_class ?? account.charAt(0))
|
||||
const configuredRate = row.default_vat_rate === null ? null : Number(row.default_vat_rate)
|
||||
|
||||
if (isAccountVatTreatment(row.default_vat_treatment)) {
|
||||
if (ACCOUNT_TO_BOX[account]) {
|
||||
if (
|
||||
RUTA_05_STATIC_RATE_ACCOUNTS.has(account) &&
|
||||
configuredRate !== null && TAXABLE_RATES.includes(configuredRate)
|
||||
) {
|
||||
result.staticRateByAccount.set(account, configuredRate)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const mapping = resolveVatTreatmentRuta(row.default_vat_treatment, accountClass)
|
||||
if (!mapping) continue
|
||||
result.explicitAccounts.add(account)
|
||||
const mapping = resolveVatTreatmentRuta(
|
||||
row.default_vat_treatment,
|
||||
row.account_class,
|
||||
row.account_number,
|
||||
)
|
||||
if (!mapping) continue
|
||||
result.mappingByAccount.set(account, mapping)
|
||||
// Always include an explicit account in the ledger filter. The Set in
|
||||
// fetchVatAccountTotals deduplicates static BAS accounts, while this also
|
||||
// covers accounts that exist only in the separate moms-box mirror.
|
||||
result.accounts.push(account)
|
||||
const rate = configuredRate ?? defaultRateForVatTreatment(row.default_vat_treatment, accountClass)
|
||||
const rate = configuredRate ?? defaultRateForVatTreatment(row.default_vat_treatment, row.account_class)
|
||||
if (mapping.box === 'ruta05' && rate !== null && TAXABLE_RATES.includes(rate)) {
|
||||
result.rateByAccount.set(account, rate)
|
||||
const target = ACCOUNT_TO_BOX[account] ? result.staticRateByAccount : result.rateByAccount
|
||||
target.set(account, rate)
|
||||
}
|
||||
if (
|
||||
['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24'].includes(mapping.box) &&
|
||||
@@ -97,7 +94,7 @@ export async function fetchDynamicVatAccounts(
|
||||
continue
|
||||
}
|
||||
|
||||
if (accountClass !== 3) continue
|
||||
if (row.account_class !== 3) continue
|
||||
const rate = configuredRate ?? inferDomesticSalesRate(account, row.account_name)
|
||||
if (rate === null || !TAXABLE_RATES.includes(rate)) continue
|
||||
if (ACCOUNT_TO_BOX[account]) {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
defaultRateForVatTreatment,
|
||||
isVatTreatmentValidForAccountClass,
|
||||
resolveVatTreatmentRuta,
|
||||
suggestVatTreatment,
|
||||
vatTreatmentsForAccountClass,
|
||||
} from '../account-vat-treatment'
|
||||
|
||||
describe('resolveVatTreatmentRuta', () => {
|
||||
@@ -23,23 +21,15 @@ describe('resolveVatTreatmentRuta', () => {
|
||||
it('maps purchase treatments by purchase class', () => {
|
||||
expect(resolveVatTreatmentRuta('reverse_charge_eu_goods', 4)).toEqual({ box: 'ruta20', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('reverse_charge_eu_services', 4)).toEqual({ box: 'ruta21', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('export_services', 5)).toEqual({ box: 'ruta22', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('reverse_charge_non_eu_services', 5)).toEqual({ box: 'ruta22', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('reverse_charge_domestic', 4)).toEqual({ box: 'ruta23', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('reverse_charge_domestic', 4, '4425')).toEqual({ box: 'ruta24', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('reverse_charge_domestic', 5)).toEqual({ box: 'ruta24', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('export_goods', 4)).toEqual({ box: 'ruta50', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('export_goods', 4)).toBeNull()
|
||||
expect(resolveVatTreatmentRuta('exempt', 4)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('vat treatment applicability', () => {
|
||||
it('exposes only treatments that resolve for the account class', () => {
|
||||
expect(vatTreatmentsForAccountClass(3)).toContain('vmb')
|
||||
expect(vatTreatmentsForAccountClass(5)).not.toContain('vmb')
|
||||
expect(isVatTreatmentValidForAccountClass('reverse_charge_eu_services', 5)).toBe(true)
|
||||
expect(isVatTreatmentValidForAccountClass('standard_25', 5)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestVatTreatment', () => {
|
||||
it('suggests the issue examples from labels, not SIE metadata', () => {
|
||||
expect(suggestVatTreatment('3041', 'Försäljning tjänst 25% sv')).toEqual({
|
||||
@@ -55,30 +45,30 @@ describe('suggestVatTreatment', () => {
|
||||
expect(suggestVatTreatment('4056', 'Projektkostnad')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches EU as a term, not a substring inside another word', () => {
|
||||
expect(suggestVatTreatment('4056', 'Reumatologiska varor 25%')).toBeNull()
|
||||
expect(suggestVatTreatment('4056', 'Inköp EU-varor 25%')).toEqual({
|
||||
treatment: 'reverse_charge_eu_goods',
|
||||
rate: 0.25,
|
||||
it('does not suggest an unsupported purchase treatment for imports of goods', () => {
|
||||
expect(suggestVatTreatment('4545', 'Import varor utanför EU 25%')).toBeNull()
|
||||
})
|
||||
|
||||
it('checks outside-EU labels before the generic EU matcher', () => {
|
||||
expect(suggestVatTreatment('3048', 'Export tjänster utanför EU')).toEqual({
|
||||
treatment: 'export_services', rate: 0,
|
||||
})
|
||||
expect(suggestVatTreatment('3108', 'Försäljning varor till annat EU-land, momsfri')).toEqual({
|
||||
treatment: 'reverse_charge_eu_goods', rate: 0,
|
||||
})
|
||||
expect(suggestVatTreatment('6545', 'Inköp tjänster utanför EU 25%')).toEqual({
|
||||
treatment: 'reverse_charge_non_eu_services', rate: 0.25,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not assume a purchase-side reverse-charge rate', () => {
|
||||
expect(defaultRateForVatTreatment('reverse_charge_eu_goods', 4)).toBeNull()
|
||||
expect(defaultRateForVatTreatment('reverse_charge_eu_services', 5)).toBeNull()
|
||||
expect(defaultRateForVatTreatment('reverse_charge_domestic', 6)).toBeNull()
|
||||
expect(defaultRateForVatTreatment('export_goods', 4)).toBeNull()
|
||||
expect(suggestVatTreatment('4056', 'Inköp varor EU')).toEqual({
|
||||
treatment: 'reverse_charge_eu_goods',
|
||||
rate: null,
|
||||
})
|
||||
it('does not match EU inside an unrelated word', () => {
|
||||
expect(suggestVatTreatment('4010', 'Reumatologiska varor')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not use a gross account rate for VMB', () => {
|
||||
it('keeps VMB without a generic booking rate', () => {
|
||||
expect(suggestVatTreatment('3211', 'Försäljning VMB')).toEqual({
|
||||
treatment: 'vmb', rate: null,
|
||||
})
|
||||
expect(defaultRateForVatTreatment('vmb', 3)).toBeNull()
|
||||
expect(suggestVatTreatment('3021', 'Försäljning begagnat 25% VMB')).toEqual({
|
||||
treatment: 'vmb',
|
||||
rate: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,11 +3,12 @@ import type { VatDeclarationRutor } from '@/types'
|
||||
export const ACCOUNT_VAT_TREATMENTS = [
|
||||
'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',
|
||||
'reverse_charge_eu_services', 'reverse_charge_non_eu_services',
|
||||
'export_goods', 'export_services', 'vmb', 'rental_voluntary',
|
||||
] as const
|
||||
|
||||
export type AccountVatTreatment = typeof ACCOUNT_VAT_TREATMENTS[number]
|
||||
export type AccountVatRate = 0 | 0.06 | 0.12 | 0.25 | null
|
||||
|
||||
export interface AccountVatRutaMapping {
|
||||
box: keyof VatDeclarationRutor
|
||||
@@ -18,6 +19,7 @@ const REVENUE_RUTA: 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',
|
||||
}
|
||||
@@ -25,6 +27,7 @@ const REVENUE_RUTA: Record<AccountVatTreatment, keyof VatDeclarationRutor | null
|
||||
export function resolveVatTreatmentRuta(
|
||||
treatment: AccountVatTreatment,
|
||||
accountClass: number,
|
||||
accountNumber?: string,
|
||||
): AccountVatRutaMapping | null {
|
||||
if (accountClass === 3) {
|
||||
const box = REVENUE_RUTA[treatment]
|
||||
@@ -33,25 +36,40 @@ export function resolveVatTreatmentRuta(
|
||||
if (accountClass < 4 || accountClass > 6) return null
|
||||
if (treatment === 'reverse_charge_eu_goods') return { box: 'ruta20', side: 'debit' }
|
||||
if (treatment === 'reverse_charge_eu_services') return { box: 'ruta21', side: 'debit' }
|
||||
if (treatment === 'export_services') return { box: 'ruta22', side: 'debit' }
|
||||
if (treatment === 'reverse_charge_non_eu_services') return { box: 'ruta22', side: 'debit' }
|
||||
if (treatment === 'reverse_charge_domestic') {
|
||||
return { box: accountClass === 4 ? 'ruta23' : 'ruta24', side: 'debit' }
|
||||
const isKnownServiceAccount = accountNumber != null && /^442[567]$/.test(accountNumber)
|
||||
return { box: accountClass === 4 && !isKnownServiceAccount ? 'ruta23' : 'ruta24', side: 'debit' }
|
||||
}
|
||||
if (treatment === 'export_goods') return { box: 'ruta50', side: 'debit' }
|
||||
return null
|
||||
}
|
||||
|
||||
export function isVatTreatmentAllowedForAccountClass(
|
||||
treatment: AccountVatTreatment,
|
||||
accountClass: number,
|
||||
): boolean {
|
||||
return resolveVatTreatmentRuta(treatment, accountClass) !== null
|
||||
}
|
||||
|
||||
export function vatTreatmentsForAccountClass(accountClass: number | null): AccountVatTreatment[] {
|
||||
if (accountClass === null) return []
|
||||
return ACCOUNT_VAT_TREATMENTS.filter((treatment) =>
|
||||
isVatTreatmentAllowedForAccountClass(treatment, accountClass)
|
||||
)
|
||||
}
|
||||
|
||||
export function defaultRateForVatTreatment(
|
||||
treatment: AccountVatTreatment,
|
||||
accountClass: number,
|
||||
): number | null {
|
||||
): AccountVatRate {
|
||||
if (treatment === 'standard_25') return 0.25
|
||||
if (treatment === 'reduced_12') return 0.12
|
||||
if (treatment === 'reduced_6') return 0.06
|
||||
if (treatment === 'exempt') return 0
|
||||
if (treatment === 'rental_voluntary') return 0.25
|
||||
if (treatment === 'vmb') return null
|
||||
return accountClass >= 4 && accountClass <= 6 ? null : 0
|
||||
if (treatment === 'rental_voluntary') return 0.25
|
||||
if (treatment === 'export_goods' || treatment === 'export_services') return 0
|
||||
return accountClass >= 4 && accountClass <= 6 ? 0.25 : 0
|
||||
}
|
||||
|
||||
export function isAccountVatTreatment(value: unknown): value is AccountVatTreatment {
|
||||
@@ -59,19 +77,6 @@ export function isAccountVatTreatment(value: unknown): value is AccountVatTreatm
|
||||
(ACCOUNT_VAT_TREATMENTS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
export function vatTreatmentsForAccountClass(accountClass: number): AccountVatTreatment[] {
|
||||
return ACCOUNT_VAT_TREATMENTS.filter(
|
||||
(treatment) => resolveVatTreatmentRuta(treatment, accountClass) !== null,
|
||||
)
|
||||
}
|
||||
|
||||
export function isVatTreatmentValidForAccountClass(
|
||||
treatment: AccountVatTreatment,
|
||||
accountClass: number,
|
||||
): boolean {
|
||||
return resolveVatTreatmentRuta(treatment, accountClass) !== null
|
||||
}
|
||||
|
||||
export interface SuggestedVatTreatment {
|
||||
treatment: AccountVatTreatment
|
||||
rate: number | null
|
||||
@@ -90,19 +95,17 @@ export function suggestVatTreatment(
|
||||
if (accountClass < 3 || accountClass > 6) return null
|
||||
const name = accountName.toLocaleLowerCase('sv-SE')
|
||||
const percent = /\b(25|12|6)\s*%/.exec(name)
|
||||
const rate = percent ? Number(percent[1]) / 100 : null
|
||||
const rate = percent ? Number(percent[1]) / 100 : 0.25
|
||||
|
||||
if (accountClass === 3) {
|
||||
if (/vmb|vinstmarginal/.test(name)) return { treatment: 'vmb', rate: null }
|
||||
if (/hyra|uthyrning/.test(name) && /frivillig/.test(name)) {
|
||||
return { treatment: 'rental_voluntary', rate: rate ?? 0.25 }
|
||||
}
|
||||
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 (/momsfri|utan moms/.test(name)) return { treatment: 'exempt', 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) && /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) {
|
||||
return {
|
||||
treatment: rate === 0.12 ? 'reduced_12' : rate === 0.06 ? 'reduced_6' : 'standard_25',
|
||||
@@ -113,8 +116,10 @@ export function suggestVatTreatment(
|
||||
}
|
||||
|
||||
if (/omvänd/.test(name) && /sverige|svensk|inrikes/.test(name)) return { treatment: 'reverse_charge_domestic', rate }
|
||||
if (/import|utanför eu/.test(name) && /var/.test(name)) return { treatment: 'export_goods', rate }
|
||||
if (/utanför eu/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'export_services', rate }
|
||||
if (/utanför eu|import/.test(name) && /var/.test(name)) return null
|
||||
if (/utanför eu/.test(name) && /tjänst|tjanst/.test(name)) {
|
||||
return { treatment: 'reverse_charge_non_eu_services', rate }
|
||||
}
|
||||
if (/\beu\b/.test(name) && /var/.test(name)) return { treatment: 'reverse_charge_eu_goods', rate }
|
||||
if (/\beu\b/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'reverse_charge_eu_services', rate }
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user