Files
accounted/lib/reports/vat-revenue-accounts.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

179 lines
7.0 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { ACCOUNT_TO_BOX, BOX_LABELS, type MomsBox } 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|\boss\b|\b0\s*%/i
/**
* Infer the domestic-sales VAT rate a class 3 account represents from its
* number pattern (30x1/30x2/30x3) and a rate-naming account name. Exported
* for the webshop bulk sweep's revenue-template guard, which must accept an
* account for a rate exactly when this report logic would count it toward
* ruta 05 for that rate.
*/
export 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
}
/**
* The chart_of_accounts columns that classify an account for VAT purposes.
* account_class is optional so callers holding only the number can pass a
* row straight from a narrower select; it then falls back to the number's
* leading digit (BAS class).
*/
export interface VatAccountClassificationRow {
account_number: string
account_name: string
account_class?: number
default_vat_rate: number | string | null
default_vat_treatment: string | null
}
function accountClassOf(row: VatAccountClassificationRow): number {
return row.account_class ?? Number(row.account_number.charAt(0))
}
/**
* The single source of truth for an account's effective VAT rate, precedence
* included: an explicit momssats always wins, then the rate implied by a
* configured treatment, and number+name inference (class 3 only) when
* nothing is configured. fetchDynamicVatAccounts (ruta 05 arithmetic) and
* the webshop bulk sweep's revenue-template guard both call this, so an
* account is accepted for a rate exactly when the declaration would count
* it toward that rate (#1912). Returns null when no rate can be resolved.
*/
export function resolveEffectiveVatRate(row: VatAccountClassificationRow): number | null {
const accountClass = accountClassOf(row)
const configured = row.default_vat_rate === null ? null : Number(row.default_vat_rate)
if (isAccountVatTreatment(row.default_vat_treatment)) {
return configured ?? defaultRateForVatTreatment(row.default_vat_treatment, accountClass)
}
if (accountClass === 3) {
return configured ?? inferDomesticSalesRate(row.account_number, row.account_name)
}
return configured
}
/**
* The momsdeklaration box a revenue (class 3) account feeds: a configured
* treatment wins (its ruta, or null for OSS which is declared outside the
* momsdeklaration), otherwise the static BAS map. Null when neither
* classifies the account, which is the common case for company-specific
* momsfri/export accounts that were never configured.
*/
export function resolveRevenueVatBox(row: VatAccountClassificationRow): MomsBox | null {
if (isAccountVatTreatment(row.default_vat_treatment)) {
const mapping = resolveVatTreatmentRuta(
row.default_vat_treatment,
accountClassOf(row),
row.account_number,
)
if (!mapping) return null
const code = mapping.box.replace(/^ruta/, '')
return code in BOX_LABELS ? (code as MomsBox) : null
}
return ACCOUNT_TO_BOX[row.account_number] ?? 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
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 = resolveEffectiveVatRate(row)
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 = resolveEffectiveVatRate(row)
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
}