Files
accounted/lib/reports/vat-revenue-accounts.ts
T
MattssonandClaude Fable 5 86f0b70fdd 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>
2026-08-15 23:45:04 +02:00

127 lines
4.9 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { ACCOUNT_TO_BOX } from '@/lib/vat/moms-box-mapping'
import {
defaultRateForVatTreatment,
isAccountVatTreatment,
resolveVatTreatmentRuta,
type AccountVatRutaMapping,
} from '@/lib/vat/account-vat-treatment'
const TAXABLE_RATES = [0.25, 0.12, 0.06]
export const RUTA_05_EXCLUDED_ACCOUNTS = new Set([
'3211', '3212', '3220', '3913',
])
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
function inferDomesticSalesRate(accountNumber: string, accountName: string): number | null {
const accountMatch = /^30\d([123])$/.exec(accountNumber)
if (!accountMatch || CONTRADICTING_ACCOUNT_NAME.test(accountName)) return null
const expectedRate = DOMESTIC_SALES_RATE_BY_SUFFIX[accountMatch[1]]
const namedRates = new Set(
[...accountName.matchAll(/\b(25|12|6)\s*%\s*moms\b/gi)].map((match) => Number(match[1]) / 100),
)
return namedRates.size === 1 && namedRates.has(expectedRate) ? expectedRate : null
}
export interface DynamicVatAccounts {
accounts: string[]
mappingByAccount: Map<string, AccountVatRutaMapping>
explicitAccounts: Set<string>
rateByAccount: Map<string, number>
staticRateByAccount: Map<string, number>
rcBasisRateByAccount: Map<string, number>
}
const emptyDynamicVatAccounts = (): DynamicVatAccounts => ({
accounts: [], mappingByAccount: new Map(), explicitAccounts: new Set(),
rateByAccount: new Map(), staticRateByAccount: new Map(),
rcBasisRateByAccount: new Map(),
})
/** Explicit account treatments win; accounts without one keep BAS fallback. */
export async function fetchDynamicVatAccounts(
supabase: SupabaseClient,
companyId: string,
): Promise<DynamicVatAccounts> {
const rows = await fetchAllRows<{
account_number: string
account_name: string
account_class: number
default_vat_rate: number | string | null
default_vat_treatment: string | null
}>(
({ from, to }) => supabase.from('chart_of_accounts')
.select('account_number, account_name, account_class, default_vat_rate, default_vat_treatment')
.eq('company_id', companyId)
.in('account_class', [3, 4, 5, 6])
.order('account_number', { ascending: true })
.range(from, to),
)
const result = emptyDynamicVatAccounts()
for (const row of rows) {
const account = row.account_number
const configuredRate = row.default_vat_rate === null ? null : Number(row.default_vat_rate)
if (isAccountVatTreatment(row.default_vat_treatment)) {
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, row.account_class)
if (mapping.box === 'ruta05' && rate !== null && TAXABLE_RATES.includes(rate)) {
const target = ACCOUNT_TO_BOX[account] ? result.staticRateByAccount : result.rateByAccount
target.set(account, rate)
}
if (
['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24'].includes(mapping.box) &&
rate !== null && TAXABLE_RATES.includes(rate)
) {
result.rcBasisRateByAccount.set(account, rate)
}
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]) {
if (RUTA_05_STATIC_RATE_ACCOUNTS.has(account)) {
result.staticRateByAccount.set(account, rate)
}
continue
}
if (RUTA_05_EXCLUDED_ACCOUNTS.has(account)) continue
result.accounts.push(account)
result.mappingByAccount.set(account, { box: 'ruta05', side: 'credit' })
result.rateByAccount.set(account, rate)
}
return result
}
export async function fetchDynamicRuta05Accounts(
supabase: SupabaseClient,
companyId: string,
): Promise<Pick<DynamicVatAccounts, 'accounts' | 'rateByAccount' | 'staticRateByAccount'>> {
const resolved = await fetchDynamicVatAccounts(supabase, companyId)
return {
accounts: [...resolved.mappingByAccount]
.filter(([account, mapping]) => mapping.box === 'ruta05' && !ACCOUNT_TO_BOX[account])
.map(([account]) => account),
rateByAccount: resolved.rateByAccount,
staticRateByAccount: resolved.staticRateByAccount,
}
}