perf(bundle): drop the BAS chart and the Node crypto polyfill from the shared client baseline (#1942)

* perf(bundle): drop the BAS chart and the Node crypto polyfill from the shared client baseline

Two chunks rode along in the first-load JS of almost every dashboard route:
the full BAS 2026 chart (315 KB uncompressed, in 81 route manifests) and
the browser polyfill for Node's crypto/vm/Buffer (327 KB, in 26 routes
incl. login and register). Neither was needed on first paint; both got
there through static imports of helpers that happen to live next to code
that needs the data or the builtin.

Node polyfill (4 pure splits, behaviour unchanged, re-exported from the
original modules for server callers):
- lib/auth/bankid-flags.ts: isBankIdEnabled (login, register, security
  settings imported it from bankid.ts, which imports crypto).
- lib/import/bank-file/formats.ts: the format registry + detection (the
  import history imported getFormat from parser.ts, which hashes).
- lib/salary/personnummer-format.ts: parsing/validation/formatting (the
  employee forms reached the encrypting personnummer.ts via tax-column).
- lib/auth/api-key-scopes.ts: scope catalogue, groups, tool map, helpers
  (the API key panel imported STAGING_SCOPES from the key generator).

BAS chart:
- lib/bookkeeping/bas-lazy.ts + use-bas-reference.ts: the chart becomes a
  dynamic import, fetched once per session after first paint; components
  that show BAS names/descriptions call useBasReference() and re-render
  when it lands. Until then (and on the server) only the hardcoded
  account-descriptions answer, so SSR and hydration agree.
- lib/bookkeeping/bas-labels.ts: class/group labels out of bas-reference.ts
  (account-descriptions needed a label and paid for the whole chart).
- lib/bookkeeping/bas-account-numbers.ts (generated, ~11 KB) +
  scripts/generate-bas-account-numbers.ts (--check) + parity test:
  isStandardBASAccountNumber for AddAccountDialog/ChartOfAccountsManager.
- lib/bookkeeping/account-classifier-{heuristic,client}.ts: the BAS-aligned
  heuristic shared by the server classifier and a client variant that uses
  the lazy chart.
- lib/bookkeeping/invoice-accounts.ts: INVOICE_FX_RATE_MISSING,
  InvoiceFxRateMissingError, getRevenueAccount, getOutputVatAccount out of
  invoice-entries.ts, whose engine import pulled account-backfill and the
  chart into SendInvoiceDialog/PaymentBookingDialog.
- CorrectOpeningBalanceDialog re-seeds names when the chart lands;
  OpeningBalanceRowEditor builds its Fuse indexes lazily; the
  ChartOfAccountsManager BAS-katalog tab awaits the chunk.

Tooling:
- scripts/perf/client-import-closure.mjs: static import closure of every
  'use client' module with the shortest chain to a target (file or bare
  specifier); found every path above without a build.
- scripts/checks/client-node-builtin.mjs wired into check:guards: a client
  module reaching a Node builtin is a hard failure (0 today).

Left as is: invoices/[id], its credit page and SendInvoiceDialog still
reach the chart through lib/invoices/issue-credit-note -> invoice-entries
-> engine -> account-backfill; splitting the engine is out of scope here.

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

* fix(perf): unambiguous import-edge regex in the closure walker (CodeQL js/redos)

One quantifier per span: a greedy [^'"]* up to the specifier quote, which it
cannot cross, so a run of whitespace has a single parse. Same edges as
before (multi-line named imports, re-exports, side-effect imports; type-only
imports still skipped).

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 15:07:49 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent e7e4efbfbc
commit 1a41119682
38 changed files with 1235 additions and 533 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ import {
} from 'lucide-react'
import { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid'
import { isBankIdEnabled } from '@/lib/auth/bankid-flags'
import { getBranding } from '@/lib/branding/service'
import { detectWebmailHint } from '@/lib/auth/webmail-search'
import { safeReturnTo } from '@/lib/auth/safe-return-to'
+1 -1
View File
@@ -14,7 +14,7 @@ import { useToast } from '@/components/ui/use-toast'
import { Check, Loader2, Mail, ArrowLeft, ExternalLink, Eye, EyeOff } from 'lucide-react'
import { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid'
import { isBankIdEnabled } from '@/lib/auth/bankid-flags'
import type { BankIdResult } from '@/components/auth/BankIdAuth'
import { getBranding } from '@/lib/branding/service'
import { detectWebmailHint } from '@/lib/auth/webmail-search'
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import { motion, AnimatePresence, useReducedMotion, animate } from 'framer-motion'
import { RotateCw } from 'lucide-react'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import { useTranslations } from 'next-intl'
import { formatCurrency } from '@/lib/utils'
import type { DeepEntity, DeepLedgerContext } from '@/lib/agent-context/ledger-deep'
@@ -240,6 +241,9 @@ const PULSE_DUR: Record<Cadence, number> = { weekly: 1.15, monthly: 2.7, irregul
export function LedgerGraph({ deep, companyName }: { deep: DeepLedgerContext; companyName: string }) {
const t = useTranslations('agentKnowledge')
// Loads the BAS chart chunk after mount and re-renders once names and
// descriptions for non-hardcoded accounts are available.
useBasReference()
const reduce = useReducedMotion() ?? false
const model = useMemo(() => buildModel(deep), [deep])
+7 -3
View File
@@ -15,8 +15,9 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2, AlertTriangle } from 'lucide-react'
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import { isStandardBASAccountNumber } from '@/lib/bookkeeping/bas-account-numbers'
import { classifyAccountClient as classifyAccount } from '@/lib/bookkeeping/account-classifier-client'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import type { BASAccount } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect'
@@ -49,6 +50,9 @@ export function AddAccountDialog({
initialAccountNumber,
initialAccountName,
}: AddAccountDialogProps) {
// Loads the BAS chart chunk after mount so classification and the
// standard-account check get the authoritative answer once it lands.
useBasReference()
const [accountNumber, setAccountNumber] = useState('')
const [accountName, setAccountName] = useState('')
const [description, setDescription] = useState('')
@@ -82,7 +86,7 @@ export function AddAccountDialog({
}
}, [open, initialAccountNumber, initialAccountName])
const isBASMatch = accountNumber.length === 4 && isStandardBASAccount(accountNumber)
const isBASMatch = accountNumber.length === 4 && isStandardBASAccountNumber(accountNumber)
const derived = accountNumber.length === 4 ? classifyAccount(accountNumber) : null
async function handleCreate() {
@@ -29,7 +29,9 @@ import {
import { cn } from '@/lib/utils'
import type { BASAccount } from '@/types'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { BAS_REFERENCE, isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
import { isStandardBASAccountNumber } from '@/lib/bookkeeping/bas-account-numbers'
import { ensureBasLoaded } from '@/lib/bookkeeping/bas-lazy'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// ---------------------------------------------------------------------------
@@ -134,6 +136,9 @@ export default function ChartOfAccountsManager() {
(a: { account_number: string; is_active: boolean; is_system_account: boolean }) => [a.account_number, a],
),
)
// The full chart is a lazily loaded chunk: only the BAS-katalog tab pays
// for it, not every route that renders this component's parent.
const BAS_REFERENCE = await ensureBasLoaded()
const merged: ReferenceAccount[] = BAS_REFERENCE.map((ref) => {
const userAccount = userMap.get(ref.account_number)
return {
@@ -561,7 +566,7 @@ export default function ChartOfAccountsManager() {
{t('system_badge')}
</span>
)}
{!isStandardBASAccount(account.account_number) && (
{!isStandardBASAccountNumber(account.account_number) && (
<span className="shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">
{t('own_badge')}
</span>
@@ -14,7 +14,8 @@ import { AlertTriangle } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import { getBasLoadedByNumber } from '@/lib/bookkeeping/bas-lazy'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import OpeningBalanceRowEditor, {
type EditableRow,
type OpeningBalanceEditorState,
@@ -33,14 +34,15 @@ let seedIdCounter = 0
// Map the booked IB's lines into editable rows. account_name isn't stored on
// the line, so resolve it from BAS for display (cosmetic: only account_number
// + amounts are sent on save).
// + amounts are sent on save). The chart is a lazily loaded chunk: the
// caller re-seeds once it has arrived.
function seedRowsFromEntry(entry: JournalEntry): EditableRow[] {
const lines = ((entry.lines || []) as JournalEntryLine[])
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
return lines.map((l) => {
const bas = BAS_REFERENCE.find((a) => a.account_number === l.account_number)
const bas = getBasLoadedByNumber(l.account_number)
return {
id: l.id || `seed_${++seedIdCounter}`,
account_number: l.account_number,
@@ -67,7 +69,10 @@ export default function CorrectOpeningBalanceDialog({
onCorrected,
}: Props) {
const { toast } = useToast()
const initialRows = useMemo(() => seedRowsFromEntry(entry), [entry])
const basReady = useBasReference()
// basReady is a re-seed trigger: names fill in once the chart chunk lands.
// eslint-disable-next-line react-hooks/exhaustive-deps
const initialRows = useMemo(() => seedRowsFromEntry(entry), [entry, basReady])
const [state, setState] = useState<OpeningBalanceEditorState | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -50,6 +50,7 @@ import { useFiscalPeriods } from '@/lib/reference-data/hooks'
import { Input } from '@/components/ui/input'
import { AccountNumber } from '@/components/ui/account-number'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import NoDocRequiredToggle from '@/components/bookkeeping/NoDocRequiredToggle'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
@@ -219,6 +220,9 @@ export default function JournalEntryList({
const { canWrite } = useCanWrite()
const company = useCompanyOptional()?.company ?? null
const t = useTranslations('journal_list')
// Loads the BAS chart chunk after mount and re-renders once names and
// descriptions for non-hardcoded accounts are available.
useBasReference()
const [entries, setEntries] = useState<JournalEntry[]>([])
const [committingId, setCommittingId] = useState<string | null>(null)
// Confirm-before-posting (convention 10): the draft the user is about to
+1 -1
View File
@@ -10,7 +10,7 @@ import { useToast } from '@/components/ui/use-toast'
import { DestructiveConfirmDialog } from '@/components/ui/destructive-confirm-dialog'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { getFormat } from '@/lib/import/bank-file/parser'
import { getFormat } from '@/lib/import/bank-file/formats'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
import { cn, formatDate } from '@/lib/utils'
+31 -25
View File
@@ -1,12 +1,14 @@
'use client'
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import Fuse from 'fuse.js'
import Fuse, { type IFuseOptions } from 'fuse.js'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, AlertTriangle, Scale } from 'lucide-react'
import { cn } from '@/lib/utils'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import { getBasLoaded } from '@/lib/bookkeeping/bas-lazy'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
export interface EditableRow {
id: string
@@ -36,32 +38,32 @@ interface OpeningBalanceRowEditorProps {
}
// Balance-sheet accounts (class 1-2) drive the primary suggestions; numeric
// queries fall back to the full chart.
const BALANCE_SHEET_ACCOUNTS = BAS_REFERENCE.filter(
(a) => a.account_class === 1 || a.account_class === 2,
)
const ALL_BAS_ACCOUNTS = BAS_REFERENCE
// queries fall back to the full chart. The chart is a lazily loaded chunk
// (lib/bookkeeping/bas-lazy.ts): the indexes are built on first use after
// it has arrived, and the suggestion list is empty until then.
const FUSE_OPTIONS: IFuseOptions<BASReferenceAccount> = {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
}
let fuseInstance: Fuse<(typeof BAS_REFERENCE)[0]> | null = null
function getFuse() {
if (!fuseInstance) {
fuseInstance = new Fuse(ALL_BAS_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
let fuseInstance: Fuse<BASReferenceAccount> | null = null
function getFuse(): Fuse<BASReferenceAccount> | null {
const chart = getBasLoaded()
if (!chart) return null
if (!fuseInstance) fuseInstance = new Fuse(chart, FUSE_OPTIONS)
return fuseInstance
}
let balanceFuseInstance: Fuse<(typeof BAS_REFERENCE)[0]> | null = null
function getBalanceFuse() {
let balanceFuseInstance: Fuse<BASReferenceAccount> | null = null
function getBalanceFuse(): Fuse<BASReferenceAccount> | null {
const chart = getBasLoaded()
if (!chart) return null
if (!balanceFuseInstance) {
balanceFuseInstance = new Fuse(BALANCE_SHEET_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
balanceFuseInstance = new Fuse(
chart.filter((a) => a.account_class === 1 || a.account_class === 2),
FUSE_OPTIONS,
)
}
return balanceFuseInstance
}
@@ -159,12 +161,16 @@ export default function OpeningBalanceRowEditor({
onChangeRef.current({ rows, totals, canSubmit })
}, [rows, totals, canSubmit])
const basReady = useBasReference()
const autocompleteResults = useMemo(() => {
if (!autocompleteQuery || autocompleteQuery.length < 1) return []
const isNumeric = /^\d+$/.test(autocompleteQuery)
const fuse = isNumeric ? getFuse() : getBalanceFuse()
if (!fuse) return []
return fuse.search(autocompleteQuery, { limit: 8 }).map((r) => r.item)
}, [autocompleteQuery])
// basReady re-runs the search once the chart chunk has arrived.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autocompleteQuery, basReady])
const updateRow = useCallback((id: string, updates: Partial<EditableRow>) => {
setRows((prev) =>
@@ -208,7 +214,7 @@ export default function OpeningBalanceRowEditor({
}, [])
const selectAutocompleteItem = useCallback(
(rowId: string, account: (typeof BAS_REFERENCE)[0]) => {
(rowId: string, account: BASReferenceAccount) => {
updateRow(rowId, {
account_number: account.account_number,
account_name: account.account_name,
+1 -1
View File
@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, ShieldCheck, ShieldOff } from 'lucide-react'
import { isMfaRequired } from '@/lib/auth/mfa'
import { isBankIdEnabled } from '@/lib/auth/bankid'
import { isBankIdEnabled } from '@/lib/auth/bankid-flags'
import { isSelfHosted as readSelfHostedFlag } from '@/lib/env/public-flags'
import { AutoLogoutToggle } from '@/components/settings/AutoLogoutToggle'
import { BankIdSettings } from '@/components/settings/BankIdSettings'
@@ -26,6 +26,7 @@ import { HOVER_REVEAL_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
@@ -162,6 +163,9 @@ export default function NewSupplierInvoiceForm({
const { canWrite } = useCanWrite()
const { toast } = useToast()
const t = useTranslations('supplier_invoice_editor')
// Loads the BAS chart chunk after mount and re-renders once names and
// descriptions for non-hardcoded accounts are available.
useBasReference()
const ta = useTranslations('accruals')
// When opened from an invoice-inbox item, every redirect should land the
+4
View File
@@ -1,6 +1,7 @@
'use client'
import { getAccountDescription, type AccountType } from '@/lib/bookkeeping/account-descriptions'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import {
Tooltip,
TooltipTrigger,
@@ -41,6 +42,9 @@ export function AccountNumber({
size = 'default',
className,
}: AccountNumberProps) {
// Loads the BAS chart chunk after mount and re-renders once names and
// descriptions for non-hardcoded accounts are available.
useBasReference()
const desc = getAccountDescription(number)
const displayName = desc?.name ?? name
+19
View File
@@ -0,0 +1,19 @@
/**
* BankID feature flag, kept free of Node imports.
*
* lib/auth/bankid.ts imports `crypto` for personnummer hashing and token
* encryption; the flag alone is what the login, register and security
* settings client components need. Importing it from there dragged
* crypto-browserify, vm-browserify and Buffer (~327 KB uncompressed) into
* those bundles.
*
* BankID is only available on the hosted deployment (requires TIC Identity
* API). Self-hosted deployments never show the BankID option.
*/
import { flagEnabled, isSelfHosted } from '@/lib/env/public-flags'
export function isBankIdEnabled(): boolean {
if (isSelfHosted()) return false
return flagEnabled(process.env.NEXT_PUBLIC_BANKID_ENABLED)
}
+5 -5
View File
@@ -6,7 +6,6 @@
*/
import crypto from 'crypto'
import { flagEnabled, isSelfHosted } from '@/lib/env/public-flags'
const ALGORITHM = 'aes-256-gcm'
@@ -14,10 +13,11 @@ const ALGORITHM = 'aes-256-gcm'
// Feature flag
// ---------------------------------------------------------------------------
export function isBankIdEnabled(): boolean {
if (isSelfHosted()) return false
return flagEnabled(process.env.NEXT_PUBLIC_BANKID_ENABLED)
}
// isBankIdEnabled lives in ./bankid-flags (no Node imports) so the login,
// register and security-settings client components can read the flag
// without pulling this module's `crypto` import, and with it the browser
// crypto polyfill, into their bundles. Re-exported here for server callers.
export { isBankIdEnabled } from './bankid-flags'
// ---------------------------------------------------------------------------
// Personnummer hashing (for lookup)
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { BAS_ACCOUNT_NUMBERS, isStandardBASAccountNumber } from '../bas-account-numbers'
import { BAS_REFERENCE } from '../bas-data'
import { isStandardBASAccount } from '../bas-reference'
describe('bas-account-numbers (generated)', () => {
it('matches the BAS chart exactly, so the light module never drifts from the data', () => {
const fromChart = [...new Set(BAS_REFERENCE.map((a) => a.account_number))].sort()
expect([...BAS_ACCOUNT_NUMBERS]).toEqual(fromChart)
})
it('answers isStandardBASAccount identically', () => {
for (const n of ['1930', '2440', '3001', '6110', '9999', '0000', '19300']) {
expect(isStandardBASAccountNumber(n)).toBe(isStandardBASAccount(n))
}
})
})
@@ -0,0 +1,15 @@
/**
* Client-side account classification: the lazily loaded BAS chart when it
* has arrived (see lib/bookkeeping/bas-lazy.ts), the BAS-aligned heuristic
* until then. Components call useBasReference() to re-render once the chart
* lands so an authoritative answer replaces the heuristic one.
*/
import { getBasLoadedByNumber } from './bas-lazy'
import { classifyAccountHeuristic, type ClassifiedAccount } from './account-classifier-heuristic'
export function classifyAccountClient(accountNumber: string): ClassifiedAccount {
const ref = getBasLoadedByNumber(accountNumber)
if (ref) return { account_type: ref.account_type, normal_balance: ref.normal_balance }
return classifyAccountHeuristic(accountNumber)
}
@@ -0,0 +1,58 @@
/**
* Group-based account classification aligned with BAS 2026. Pure, no data
* import: the server classifier (account-classifier.ts) consults the BAS
* chart first and falls back to this; the client classifier
* (account-classifier-client.ts) uses the lazily loaded chart the same way.
*/
export type AccountType =
| 'asset'
| 'liability'
| 'equity'
| 'revenue'
| 'expense'
| 'untaxed_reserves'
export type NormalBalance = 'debit' | 'credit'
export interface ClassifiedAccount {
account_type: AccountType
normal_balance: NormalBalance
}
/**
* Class-8 groups are subtle: 80/81/82/83/87/88 are intäkter (revenue), 84/89
* are kostnader (expense). The legacy heuristic defaulted everything not in
* 83/84 to expense, which silently misclassified dividends, capital gains and
* bokslutsdispositioner.
*/
export function classifyAccountHeuristic(accountNumber: string): ClassifiedAccount {
const cls = parseInt(accountNumber[0], 10)
const group = parseInt(accountNumber.substring(0, 2), 10)
switch (cls) {
case 1:
return { account_type: 'asset', normal_balance: 'debit' }
case 2:
if (group === 20) return { account_type: 'equity', normal_balance: 'credit' }
if (group === 21) return { account_type: 'untaxed_reserves', normal_balance: 'credit' }
return { account_type: 'liability', normal_balance: 'credit' }
case 3:
return { account_type: 'revenue', normal_balance: 'credit' }
case 4:
case 5:
case 6:
case 7:
return { account_type: 'expense', normal_balance: 'debit' }
case 8:
if (group >= 80 && group <= 83) return { account_type: 'revenue', normal_balance: 'credit' }
if (group === 84) return { account_type: 'expense', normal_balance: 'debit' }
if (group === 85) return { account_type: 'revenue', normal_balance: 'credit' }
if (group === 86) return { account_type: 'expense', normal_balance: 'debit' }
if (group === 87 || group === 88) return { account_type: 'revenue', normal_balance: 'credit' }
if (group === 89) return { account_type: 'expense', normal_balance: 'debit' }
return { account_type: 'expense', normal_balance: 'debit' }
default:
return { account_type: 'expense', normal_balance: 'debit' }
}
}
+3 -42
View File
@@ -1,19 +1,8 @@
import { getBASReference } from './bas-reference'
import { classifyAccountHeuristic, type ClassifiedAccount } from './account-classifier-heuristic'
export type AccountType =
| 'asset'
| 'liability'
| 'equity'
| 'revenue'
| 'expense'
| 'untaxed_reserves'
export type { AccountType, ClassifiedAccount, NormalBalance } from './account-classifier-heuristic'
export type NormalBalance = 'debit' | 'credit'
export interface ClassifiedAccount {
account_type: AccountType
normal_balance: NormalBalance
}
/**
* Map a 4-digit BAS account number to its account_type and normal_balance.
@@ -32,33 +21,5 @@ export function classifyAccount(accountNumber: string): ClassifiedAccount {
if (ref) {
return { account_type: ref.account_type, normal_balance: ref.normal_balance }
}
const cls = parseInt(accountNumber[0], 10)
const group = parseInt(accountNumber.substring(0, 2), 10)
switch (cls) {
case 1:
return { account_type: 'asset', normal_balance: 'debit' }
case 2:
if (group === 20) return { account_type: 'equity', normal_balance: 'credit' }
if (group === 21) return { account_type: 'untaxed_reserves', normal_balance: 'credit' }
return { account_type: 'liability', normal_balance: 'credit' }
case 3:
return { account_type: 'revenue', normal_balance: 'credit' }
case 4:
case 5:
case 6:
case 7:
return { account_type: 'expense', normal_balance: 'debit' }
case 8:
if (group >= 80 && group <= 83) return { account_type: 'revenue', normal_balance: 'credit' }
if (group === 84) return { account_type: 'expense', normal_balance: 'debit' }
if (group === 85) return { account_type: 'revenue', normal_balance: 'credit' }
if (group === 86) return { account_type: 'expense', normal_balance: 'debit' }
if (group === 87 || group === 88) return { account_type: 'revenue', normal_balance: 'credit' }
if (group === 89) return { account_type: 'expense', normal_balance: 'debit' }
return { account_type: 'expense', normal_balance: 'debit' }
default:
return { account_type: 'expense', normal_balance: 'debit' }
}
return classifyAccountHeuristic(accountNumber)
}
+15 -14
View File
@@ -1,4 +1,5 @@
import { getBASReference, ACCOUNT_CLASS_LABELS } from './bas-reference'
import { ACCOUNT_CLASS_LABELS } from './bas-labels'
import { getBasLoadedByNumber } from './bas-lazy'
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' | 'untaxed_reserves'
@@ -333,20 +334,20 @@ export function getAccountDescription(accountNumber: string): AccountDescription
const hardcoded = ACCOUNT_DESCRIPTIONS[accountNumber]
if (hardcoded) return hardcoded
// Fall back to BAS reference data for accounts not in the hardcoded list
try {
const ref = getBASReference(accountNumber)
if (ref) {
const classLabel = ACCOUNT_CLASS_LABELS[ref.account_class] || ''
return {
name: ref.account_name,
classLabel,
type: ref.account_type,
explanation: ref.description,
}
// Fall back to the BAS chart for accounts not in the hardcoded list. The
// chart is a lazily loaded chunk (lib/bookkeeping/bas-lazy.ts): callers
// that want this fallback call useBasReference() so they re-render once
// it has arrived; until then (and on the server) only the hardcoded set
// answers, which keeps SSR and hydration in agreement.
const ref = getBasLoadedByNumber(accountNumber)
if (ref) {
const classLabel = ACCOUNT_CLASS_LABELS[ref.account_class] || ''
return {
name: ref.account_name,
classLabel,
type: ref.account_type,
explanation: ref.description,
}
} catch {
// BAS reference not available: that's fine
}
return undefined
+124
View File
@@ -0,0 +1,124 @@
// GENERATED by scripts/generate-bas-account-numbers.ts from lib/bookkeeping/bas-data.
// Do not edit by hand: run `npx tsx scripts/generate-bas-account-numbers.ts`.
//
// The sorted list of standard BAS account numbers (~9 KB) so client code can
// check membership without importing the full chart (315 KB uncompressed).
// lib/bookkeeping/__tests__/bas-account-numbers.test.ts pins parity.
export const BAS_ACCOUNT_NUMBERS: readonly string[] = [
'1010', '1011', '1012', '1018', '1019', '1020', '1028', '1029', '1030', '1038', '1039', '1040',
'1048', '1049', '1050', '1058', '1059', '1060', '1068', '1069', '1070', '1078', '1079', '1080',
'1081', '1088', '1090', '1092', '1098', '1099', '1110', '1111', '1112', '1118', '1119', '1120',
'1129', '1130', '1140', '1150', '1158', '1159', '1180', '1181', '1188', '1210', '1211', '1212',
'1214', '1216', '1217', '1218', '1219', '1220', '1221', '1222', '1224', '1226', '1227', '1228',
'1229', '1230', '1240', '1241', '1242', '1249', '1250', '1251', '1259', '1260', '1261', '1269',
'1280', '1281', '1288', '1290', '1291', '1292', '1298', '1299', '1310', '1311', '1312', '1313',
'1314', '1316', '1317', '1318', '1320', '1321', '1322', '1323', '1328', '1330', '1331', '1332',
'1333', '1334', '1336', '1337', '1340', '1341', '1342', '1343', '1344', '1346', '1347', '1350',
'1351', '1352', '1353', '1354', '1356', '1357', '1358', '1360', '1369', '1370', '1380', '1381',
'1382', '1383', '1384', '1385', '1387', '1388', '1389', '1410', '1419', '1420', '1429', '1440',
'1449', '1450', '1459', '1460', '1465', '1466', '1467', '1469', '1470', '1471', '1478', '1479',
'1480', '1481', '1489', '1490', '1491', '1492', '1493', '1510', '1511', '1512', '1513', '1516',
'1518', '1519', '1520', '1525', '1529', '1530', '1531', '1532', '1536', '1539', '1550', '1560',
'1561', '1562', '1563', '1568', '1569', '1570', '1571', '1572', '1573', '1610', '1611', '1612',
'1613', '1614', '1619', '1620', '1630', '1640', '1650', '1660', '1661', '1662', '1663', '1670',
'1671', '1672', '1673', '1680', '1681', '1682', '1683', '1684', '1685', '1686', '1687', '1688',
'1689', '1690', '1710', '1720', '1730', '1740', '1750', '1760', '1770', '1780', '1790', '1810',
'1820', '1830', '1860', '1880', '1886', '1889', '1890', '1910', '1911', '1912', '1913', '1920',
'1930', '1940', '1950', '1960', '1970', '1972', '1973', '1974', '1979', '1980', '1990', '2010',
'2011', '2013', '2017', '2018', '2019', '2020', '2021', '2023', '2027', '2028', '2029', '2030',
'2031', '2033', '2037', '2038', '2039', '2040', '2041', '2043', '2047', '2048', '2049', '2050',
'2060', '2061', '2064', '2065', '2066', '2067', '2068', '2069', '2070', '2071', '2072', '2080',
'2081', '2082', '2083', '2084', '2085', '2086', '2087', '2088', '2089', '2090', '2091', '2092',
'2093', '2094', '2095', '2096', '2097', '2098', '2099', '2110', '2120', '2121', '2122', '2123',
'2124', '2125', '2126', '2127', '2129', '2130', '2131', '2132', '2133', '2134', '2135', '2136',
'2137', '2139', '2150', '2151', '2152', '2153', '2160', '2161', '2162', '2164', '2190', '2196',
'2199', '2210', '2220', '2230', '2240', '2250', '2252', '2253', '2290', '2310', '2320', '2321',
'2322', '2323', '2324', '2330', '2340', '2350', '2351', '2355', '2359', '2360', '2361', '2362',
'2363', '2370', '2371', '2372', '2373', '2390', '2391', '2392', '2393', '2394', '2395', '2396',
'2397', '2399', '2410', '2411', '2412', '2417', '2419', '2420', '2421', '2429', '2430', '2431',
'2438', '2439', '2440', '2441', '2443', '2445', '2448', '2450', '2460', '2461', '2462', '2463',
'2470', '2471', '2472', '2473', '2480', '2490', '2491', '2492', '2499', '2510', '2512', '2513',
'2514', '2515', '2517', '2518', '2610', '2611', '2612', '2613', '2614', '2615', '2616', '2618',
'2620', '2621', '2622', '2623', '2624', '2625', '2626', '2628', '2630', '2631', '2632', '2633',
'2634', '2635', '2636', '2638', '2640', '2641', '2642', '2645', '2646', '2647', '2648', '2649',
'2650', '2660', '2670', '2710', '2730', '2731', '2732', '2740', '2750', '2760', '2761', '2762',
'2790', '2791', '2792', '2793', '2794', '2795', '2799', '2810', '2811', '2812', '2820', '2821',
'2822', '2823', '2829', '2830', '2840', '2841', '2849', '2850', '2852', '2860', '2861', '2862',
'2863', '2870', '2871', '2872', '2873', '2880', '2890', '2891', '2892', '2893', '2895', '2897',
'2898', '2899', '2910', '2911', '2912', '2919', '2920', '2930', '2931', '2940', '2941', '2942',
'2943', '2944', '2950', '2951', '2959', '2960', '2970', '2971', '2972', '2979', '2980', '2990',
'2991', '2992', '2993', '2995', '2998', '2999', '3000', '3001', '3002', '3003', '3004', '3100',
'3105', '3106', '3108', '3200', '3211', '3212', '3231', '3300', '3305', '3308', '3400', '3401',
'3402', '3403', '3404', '3500', '3510', '3511', '3518', '3520', '3521', '3522', '3530', '3540',
'3541', '3542', '3550', '3560', '3561', '3562', '3563', '3570', '3590', '3600', '3610', '3611',
'3612', '3613', '3619', '3620', '3630', '3670', '3671', '3672', '3679', '3680', '3690', '3700',
'3710', '3730', '3731', '3732', '3740', '3750', '3751', '3752', '3790', '3800', '3840', '3850',
'3870', '3900', '3910', '3911', '3912', '3913', '3914', '3920', '3921', '3922', '3925', '3940',
'3950', '3960', '3970', '3971', '3972', '3973', '3980', '3981', '3985', '3987', '3988', '3989',
'3990', '3991', '3992', '3993', '3994', '3995', '3996', '3997', '3998', '3999', '4000', '4010',
'4060', '4065', '4066', '4067', '4070', '4075', '4076', '4077', '4078', '4080', '4085', '4086',
'4087', '4090', '4091', '4092', '4099', '4200', '4210', '4211', '4212', '4300', '4310', '4400',
'4410', '4415', '4416', '4417', '4420', '4425', '4426', '4427', '4500', '4510', '4515', '4516',
'4517', '4518', '4530', '4531', '4532', '4533', '4535', '4536', '4537', '4538', '4540', '4545',
'4546', '4547', '4598', '4600', '4610', '4670', '4700', '4730', '4731', '4732', '4739', '4800',
'4810', '4820', '4830', '4840', '4890', '4900', '4910', '4920', '4940', '4944', '4945', '4947',
'4950', '4960', '4970', '4974', '4975', '4977', '4980', '4981', '4987', '4988', '5000', '5010',
'5011', '5012', '5013', '5019', '5020', '5030', '5040', '5050', '5060', '5061', '5062', '5064',
'5065', '5069', '5070', '5090', '5100', '5110', '5120', '5130', '5131', '5132', '5139', '5140',
'5160', '5161', '5162', '5164', '5165', '5169', '5170', '5190', '5191', '5192', '5193', '5198',
'5200', '5210', '5220', '5250', '5290', '5300', '5310', '5320', '5330', '5340', '5350', '5360',
'5370', '5380', '5390', '5400', '5410', '5411', '5412', '5420', '5430', '5440', '5460', '5480',
'5500', '5510', '5520', '5530', '5550', '5580', '5590', '5600', '5610', '5611', '5612', '5613',
'5615', '5616', '5619', '5620', '5621', '5622', '5623', '5625', '5626', '5629', '5630', '5631',
'5632', '5633', '5635', '5639', '5640', '5641', '5642', '5643', '5645', '5646', '5649', '5650',
'5651', '5652', '5653', '5655', '5656', '5659', '5670', '5671', '5672', '5673', '5675', '5679',
'5680', '5681', '5682', '5683', '5685', '5689', '5690', '5691', '5692', '5693', '5695', '5696',
'5699', '5700', '5710', '5711', '5712', '5720', '5721', '5722', '5729', '5730', '5790', '5800',
'5810', '5820', '5830', '5831', '5832', '5890', '5900', '5910', '5920', '5930', '5940', '5950',
'5960', '5970', '5980', '5981', '5982', '5990', '6000', '6010', '6020', '6030', '6040', '6050',
'6055', '6059', '6060', '6061', '6062', '6063', '6064', '6069', '6070', '6071', '6072', '6080',
'6090', '6100', '6110', '6150', '6200', '6210', '6211', '6212', '6219', '6230', '6250', '6290',
'6300', '6310', '6320', '6330', '6340', '6341', '6342', '6350', '6351', '6352', '6360', '6361',
'6362', '6370', '6380', '6390', '6391', '6392', '6400', '6420', '6421', '6422', '6423', '6424',
'6430', '6440', '6450', '6490', '6500', '6510', '6520', '6530', '6540', '6550', '6551', '6552',
'6553', '6554', '6555', '6556', '6559', '6560', '6570', '6580', '6590', '6700', '6710', '6800',
'6810', '6820', '6830', '6840', '6850', '6860', '6870', '6880', '6890', '6900', '6910', '6920',
'6930', '6940', '6950', '6970', '6980', '6981', '6982', '6990', '6991', '6992', '6993', '6996',
'6997', '6998', '6999', '7000', '7010', '7011', '7012', '7013', '7017', '7018', '7019', '7030',
'7031', '7032', '7037', '7038', '7039', '7080', '7081', '7082', '7083', '7089', '7090', '7200',
'7210', '7211', '7212', '7213', '7217', '7218', '7219', '7220', '7221', '7222', '7227', '7228',
'7229', '7230', '7231', '7232', '7237', '7238', '7239', '7240', '7280', '7281', '7282', '7283',
'7284', '7285', '7286', '7288', '7289', '7290', '7291', '7292', '7300', '7310', '7311', '7312',
'7313', '7314', '7315', '7316', '7317', '7318', '7319', '7320', '7321', '7322', '7323', '7324',
'7330', '7331', '7332', '7333', '7350', '7370', '7380', '7381', '7382', '7383', '7384', '7385',
'7386', '7387', '7388', '7389', '7390', '7391', '7392', '7400', '7410', '7411', '7412', '7420',
'7430', '7440', '7441', '7448', '7460', '7461', '7462', '7463', '7470', '7490', '7500', '7510',
'7511', '7512', '7515', '7516', '7518', '7519', '7530', '7531', '7532', '7533', '7550', '7551',
'7552', '7553', '7554', '7570', '7571', '7572', '7580', '7581', '7582', '7583', '7589', '7590',
'7600', '7610', '7620', '7621', '7622', '7623', '7630', '7631', '7632', '7650', '7670', '7671',
'7678', '7690', '7691', '7692', '7693', '7699', '7710', '7720', '7730', '7731', '7732', '7733',
'7740', '7760', '7770', '7780', '7781', '7782', '7783', '7790', '7810', '7811', '7812', '7813',
'7814', '7815', '7816', '7817', '7819', '7820', '7821', '7824', '7829', '7830', '7831', '7832',
'7836', '7839', '7840', '7940', '7960', '7970', '7971', '7972', '7973', '7990', '8010', '8012',
'8016', '8020', '8030', '8070', '8072', '8076', '8077', '8080', '8082', '8086', '8087', '8110',
'8111', '8112', '8113', '8116', '8117', '8118', '8120', '8121', '8122', '8123', '8130', '8131',
'8132', '8133', '8170', '8171', '8172', '8173', '8174', '8176', '8177', '8180', '8181', '8182',
'8183', '8184', '8186', '8187', '8210', '8212', '8216', '8220', '8221', '8222', '8223', '8230',
'8231', '8236', '8240', '8250', '8251', '8252', '8254', '8255', '8260', '8261', '8262', '8263',
'8270', '8271', '8272', '8273', '8280', '8281', '8282', '8283', '8290', '8291', '8295', '8310',
'8311', '8312', '8313', '8314', '8317', '8319', '8320', '8321', '8325', '8330', '8331', '8336',
'8340', '8350', '8360', '8361', '8362', '8363', '8370', '8380', '8390', '8400', '8410', '8411',
'8412', '8413', '8415', '8417', '8418', '8419', '8420', '8421', '8422', '8423', '8424', '8429',
'8430', '8431', '8436', '8440', '8450', '8451', '8455', '8460', '8461', '8462', '8463', '8480',
'8490', '8491', '8810', '8811', '8819', '8820', '8830', '8840', '8850', '8851', '8852', '8853',
'8860', '8861', '8862', '8864', '8865', '8866', '8869', '8890', '8892', '8896', '8899', '8910',
'8920', '8930', '8940', '8980', '8990', '8999',
]
const BAS_ACCOUNT_NUMBER_SET: ReadonlySet<string> = new Set(BAS_ACCOUNT_NUMBERS)
/** Whether the number exists in the BAS chart (same answer as isStandardBASAccount). */
export function isStandardBASAccountNumber(accountNumber: string): boolean {
return BAS_ACCOUNT_NUMBER_SET.has(accountNumber)
}
+116
View File
@@ -0,0 +1,116 @@
/**
* BAS class and group labels. Split from bas-reference.ts, whose index
* helpers import the full ~1,276-account chart (a 315 KB chunk): client
* components that only need a label must not pay for the data.
*/
/** Swedish labels for each BAS account class (1-8) */
export const ACCOUNT_CLASS_LABELS: Record<number, string> = {
1: 'Tillgångar',
2: 'Eget kapital och skulder',
3: 'Rörelseintäkter',
4: 'Varuinköp och material',
5: 'Övriga externa kostnader',
6: 'Övriga externa kostnader',
7: 'Personalkostnader och avskrivningar',
8: 'Finansiella poster och resultat',
}
/** Swedish labels for BAS account groups (first two digits) */
export const ACCOUNT_GROUP_LABELS: Record<string, string> = {
// Class 1 - Assets
'10': 'Immateriella anläggningstillgångar',
'11': 'Byggnader och mark',
'12': 'Maskiner respektive inventarier',
'13': 'Finansiella anläggningstillgångar',
'14': 'Lager, produkter i arbete och pågående arbeten',
'15': 'Kundfordringar',
'16': 'Övriga kortfristiga fordringar',
'17': 'Förutbetalda kostnader och upplupna intäkter',
'18': 'Kortfristiga placeringar',
'19': 'Kassa och bank',
// Class 2 - Equity & Liabilities
'20': 'Eget kapital',
'21': 'Obeskattade reserver',
'22': 'Avsättningar',
'23': 'Långfristiga skulder',
'24': 'Kortfristiga skulder till kreditinstitut, kunder och leverantörer',
'25': 'Skatteskulder',
'26': 'Moms och punktskatter',
'27': 'Personalens skatter, avgifter och löneavdrag',
'28': 'Övriga kortfristiga skulder',
'29': 'Upplupna kostnader och förutbetalda intäkter',
// Class 3 - Revenue
'30': 'Huvudintäkter',
'31': 'Försäljning av varor utanför Sverige',
'32': 'Försäljning VMB och omvänd moms',
'33': 'Försäljning av tjänster utanför Sverige',
'34': 'Försäljning, egna uttag',
'35': 'Fakturerade kostnader',
'36': 'Rörelsens sidointäkter',
'37': 'Intäktskorrigeringar',
'38': 'Aktiverat arbete för egen räkning',
'39': 'Övriga rörelseintäkter',
// Class 4 - Cost of goods
'40': 'Inköp av handelsvaror',
'41': 'Inköp av varor och material',
'42': 'Sålda handelsvaror VMB',
'43': 'Inköp av råvaror och material i Sverige',
'44': 'Inköp av råvaror m.m., omvänd betalningsskyldighet',
'45': 'Inköp av råvaror m.m. från utlandet',
'46': 'Inköp av tjänster, underentreprenader och legoarbeten',
'47': 'Reduktion av inköpspriser',
'48': 'Andra produktionskostnader',
'49': 'Förändring av lager, produkter i arbete och pågående arbeten',
// Class 5 - External expenses
'50': 'Lokalkostnader',
'51': 'Fastighetskostnader',
'52': 'Hyra av anläggningstillgångar',
'53': 'Energikostnader för drift',
'54': 'Förbrukningsinventarier och förbrukningsmaterial',
'55': 'Reparation och underhåll',
'56': 'Kostnader för transportmedel',
'57': 'Frakter och transporter',
'58': 'Resekostnader',
'59': 'Reklam och PR',
// Class 6 - Other external expenses
'60': 'Övriga försäljningskostnader',
'61': 'Kontorsmateriel och trycksaker',
'62': 'Tele, data och post',
'63': 'Företagsförsäkringar och övriga riskkostnader',
'64': 'Förvaltningskostnader',
'65': 'Övriga externa tjänster',
'66': 'Franchisingavgifter',
'67': 'Särskilt för ideella föreningar och stiftelser',
'68': 'Inhyrd personal',
'69': 'Övriga externa kostnader',
// Class 7 - Personnel
'70': 'Löner till kollektivanställda',
'71': 'Löner till anställda',
'72': 'Löner till tjänstemän och företagsledare',
'73': 'Kostnadsersättningar och förmåner',
'74': 'Pensionskostnader',
'75': 'Sociala och andra avgifter enligt lag och avtal',
'76': 'Övriga personalkostnader',
'77': 'Nedskrivningar och återföring av nedskrivningar',
'78': 'Avskrivningar enligt plan',
'79': 'Övriga rörelsekostnader',
// Class 8 - Financial
'80': 'Resultat från andelar i koncernföretag',
'81': 'Resultat från andelar i intresseföretag',
'82': 'Resultat från övriga värdepapper och långfristiga fordringar',
'83': 'Övriga ränteintäkter och liknande resultatposter',
'84': 'Räntekostnader och liknande resultatposter',
'85': 'Extraordinära intäkter',
'86': 'Extraordinära kostnader',
'87': 'Bokslutsdispositioner (intäkter)',
'88': 'Bokslutsdispositioner',
'89': 'Skatter och årets resultat',
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Lazily loaded BAS chart for client code.
*
* The full chart (lib/bookkeeping/bas-data, ~1,276 accounts, 315 KB
* uncompressed) used to be statically imported by lib/bookkeeping/
* account-descriptions.ts and, through AccountCombobox and ui/account-number,
* ended up in the shared client bundle of 81 dashboard routes. Here it is a
* dynamic import: one code-split chunk, fetched once per session after first
* paint, and only by surfaces that actually show BAS names or descriptions.
*
* Server code keeps importing bas-reference / bas-data statically.
*/
import type { BASReferenceAccount } from './bas-reference'
let loaded: BASReferenceAccount[] | null = null
let byNumber: Map<string, BASReferenceAccount> | null = null
let loading: Promise<BASReferenceAccount[]> | null = null
const listeners = new Set<() => void>()
/** Start (or join) the chunk load. Resolves with the full chart. */
export function ensureBasLoaded(): Promise<BASReferenceAccount[]> {
if (loaded) return Promise.resolve(loaded)
if (!loading) {
loading = import('./bas-data')
.then(({ BAS_REFERENCE }) => {
loaded = BAS_REFERENCE
byNumber = new Map(BAS_REFERENCE.map((a) => [a.account_number, a]))
for (const listener of listeners) listener()
return BAS_REFERENCE
})
.catch((err) => {
loading = null
throw err
})
}
return loading
}
/** The chart if the chunk has arrived, else null (never blocks). */
export function getBasLoaded(): BASReferenceAccount[] | null {
return loaded
}
export function getBasLoadedByNumber(accountNumber: string): BASReferenceAccount | undefined {
return byNumber?.get(accountNumber)
}
export function isBasLoaded(): boolean {
return loaded !== null
}
/** Subscribe to "the chunk arrived" (useSyncExternalStore contract). */
export function subscribeBasLoaded(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
+3 -110
View File
@@ -34,116 +34,9 @@ export { BAS_REFERENCE } from './bas-data'
// Class & Group Labels
// ---------------------------------------------------------------------------
/** Swedish labels for each BAS account class (1-8) */
export const ACCOUNT_CLASS_LABELS: Record<number, string> = {
1: 'Tillgångar',
2: 'Eget kapital och skulder',
3: 'Rörelseintäkter',
4: 'Varuinköp och material',
5: 'Övriga externa kostnader',
6: 'Övriga externa kostnader',
7: 'Personalkostnader och avskrivningar',
8: 'Finansiella poster och resultat',
}
/** Swedish labels for BAS account groups (first two digits) */
export const ACCOUNT_GROUP_LABELS: Record<string, string> = {
// Class 1 - Assets
'10': 'Immateriella anläggningstillgångar',
'11': 'Byggnader och mark',
'12': 'Maskiner respektive inventarier',
'13': 'Finansiella anläggningstillgångar',
'14': 'Lager, produkter i arbete och pågående arbeten',
'15': 'Kundfordringar',
'16': 'Övriga kortfristiga fordringar',
'17': 'Förutbetalda kostnader och upplupna intäkter',
'18': 'Kortfristiga placeringar',
'19': 'Kassa och bank',
// Class 2 - Equity & Liabilities
'20': 'Eget kapital',
'21': 'Obeskattade reserver',
'22': 'Avsättningar',
'23': 'Långfristiga skulder',
'24': 'Kortfristiga skulder till kreditinstitut, kunder och leverantörer',
'25': 'Skatteskulder',
'26': 'Moms och punktskatter',
'27': 'Personalens skatter, avgifter och löneavdrag',
'28': 'Övriga kortfristiga skulder',
'29': 'Upplupna kostnader och förutbetalda intäkter',
// Class 3 - Revenue
'30': 'Huvudintäkter',
'31': 'Försäljning av varor utanför Sverige',
'32': 'Försäljning VMB och omvänd moms',
'33': 'Försäljning av tjänster utanför Sverige',
'34': 'Försäljning, egna uttag',
'35': 'Fakturerade kostnader',
'36': 'Rörelsens sidointäkter',
'37': 'Intäktskorrigeringar',
'38': 'Aktiverat arbete för egen räkning',
'39': 'Övriga rörelseintäkter',
// Class 4 - Cost of goods
'40': 'Inköp av handelsvaror',
'41': 'Inköp av varor och material',
'42': 'Sålda handelsvaror VMB',
'43': 'Inköp av råvaror och material i Sverige',
'44': 'Inköp av råvaror m.m., omvänd betalningsskyldighet',
'45': 'Inköp av råvaror m.m. från utlandet',
'46': 'Inköp av tjänster, underentreprenader och legoarbeten',
'47': 'Reduktion av inköpspriser',
'48': 'Andra produktionskostnader',
'49': 'Förändring av lager, produkter i arbete och pågående arbeten',
// Class 5 - External expenses
'50': 'Lokalkostnader',
'51': 'Fastighetskostnader',
'52': 'Hyra av anläggningstillgångar',
'53': 'Energikostnader för drift',
'54': 'Förbrukningsinventarier och förbrukningsmaterial',
'55': 'Reparation och underhåll',
'56': 'Kostnader för transportmedel',
'57': 'Frakter och transporter',
'58': 'Resekostnader',
'59': 'Reklam och PR',
// Class 6 - Other external expenses
'60': 'Övriga försäljningskostnader',
'61': 'Kontorsmateriel och trycksaker',
'62': 'Tele, data och post',
'63': 'Företagsförsäkringar och övriga riskkostnader',
'64': 'Förvaltningskostnader',
'65': 'Övriga externa tjänster',
'66': 'Franchisingavgifter',
'67': 'Särskilt för ideella föreningar och stiftelser',
'68': 'Inhyrd personal',
'69': 'Övriga externa kostnader',
// Class 7 - Personnel
'70': 'Löner till kollektivanställda',
'71': 'Löner till anställda',
'72': 'Löner till tjänstemän och företagsledare',
'73': 'Kostnadsersättningar och förmåner',
'74': 'Pensionskostnader',
'75': 'Sociala och andra avgifter enligt lag och avtal',
'76': 'Övriga personalkostnader',
'77': 'Nedskrivningar och återföring av nedskrivningar',
'78': 'Avskrivningar enligt plan',
'79': 'Övriga rörelsekostnader',
// Class 8 - Financial
'80': 'Resultat från andelar i koncernföretag',
'81': 'Resultat från andelar i intresseföretag',
'82': 'Resultat från övriga värdepapper och långfristiga fordringar',
'83': 'Övriga ränteintäkter och liknande resultatposter',
'84': 'Räntekostnader och liknande resultatposter',
'85': 'Extraordinära intäkter',
'86': 'Extraordinära kostnader',
'87': 'Bokslutsdispositioner (intäkter)',
'88': 'Bokslutsdispositioner',
'89': 'Skatter och årets resultat',
}
// Labels live in ./bas-labels (a few KB, no data) so client code can import
// them without this module's index over the full chart. Re-exported here.
export { ACCOUNT_CLASS_LABELS, ACCOUNT_GROUP_LABELS } from './bas-labels'
// ---------------------------------------------------------------------------
// Lookup indexes (lazy-initialized for performance)
+90
View File
@@ -0,0 +1,90 @@
/**
* Pure invoice booking constants and account resolvers.
*
* Split from invoice-entries.ts (whose generators import the bookkeeping
* engine, and through it the account backfill and the full BAS chart) so
* the client-side proposal helpers can import them without that closure.
*/
import type { EntityType, VatTreatment } from '@/types'
/**
* Stable code for the "foreign-currency customer invoice without a rate"
* refusal. Registered in lib/errors/structured-errors.ts so REST routes, the
* MCP server and getErrorMessage() all translate it the same way.
*
* Sales-side twin of SI_FX_RATE_MISSING (supplier-invoice-entries.ts).
*/
export const INVOICE_FX_RATE_MISSING = 'INVOICE_FX_RATE_MISSING' as const
/**
* Raised when an invoice booking path is asked to translate a foreign-currency
* amount that has no usable exchange rate.
*
* The generators below derive every FX leg from the per-item amounts, and items
* carry no `*_sek` column: `exchange_rate` is the only SEK source they have. The
* old per-file fallback returned the RAW foreign amount, and because the 1510
* debit is derived from the sum of the credits on the FX branch, every leg was
* scaled by the same wrong factor: the verifikation still balanced, no DB
* trigger fired and nothing errored. A 1 000 EUR sale posted 1 000 kr to 3001
* and 250 kr to 2611 instead of 11 500 kr and 2 875 kr at 11,50 SEK/EUR,
* understating ruta 05 and ruta 10 of the momsdeklaration by the same amount:
* an oriktig uppgift exposed to skattetillägg under SFL 49 kap 4 §.
*
* Refusing instead of guessing follows the `match_batch_allocate` RPC
* (BATCH_FX_RATE_MISSING) and `toSekOrThrow()` in supplier-invoice-entries.ts.
*
* The same refusal covers the header-level fallbacks (no-items bookings and
* the payment entry) via `headerToSekOrThrow` below: those paths DO honour a
* populated `*_sek` column, so only rows with no SEK source at all refuse.
*/
export class InvoiceFxRateMissingError extends Error {
readonly code = INVOICE_FX_RATE_MISSING
constructor(public readonly currency: string) {
super(
`Invoice is in ${currency} but has no exchange rate on file; refusing to post it as if 1 ${currency} = 1 SEK.`
)
this.name = 'InvoiceFxRateMissingError'
}
}
/**
* Get the appropriate revenue account based on VAT treatment
*
* For 'exempt': AB uses 3004 (Försäljning inom Sverige, momsfri),
* EF uses 3100 (Momsfria intäkter, mapped to R2 in NE engine).
*/
export function getRevenueAccount(vatTreatment: VatTreatment, entityType: EntityType = 'enskild_firma'): string {
switch (vatTreatment) {
case 'standard_25':
return '3001' // Försäljning 25%
case 'reduced_12':
return '3002' // Försäljning 12%
case 'reduced_6':
return '3003' // Försäljning 6%
case 'reverse_charge':
return '3308' // Försäljning tjänst EU
case 'export':
return '3305' // Försäljning tjänst Export
case 'exempt':
return entityType === 'aktiebolag' ? '3004' : '3100'
default:
return '3001'
}
}
/**
* Get the output VAT account based on VAT treatment
*/
export function getOutputVatAccount(vatTreatment: VatTreatment): string {
switch (vatTreatment) {
case 'standard_25':
return '2611'
case 'reduced_12':
return '2621'
case 'reduced_6':
return '2631'
default:
return '2611'
}
}
+12 -79
View File
@@ -25,45 +25,18 @@ import type {
const log = createLogger('invoice-entries')
/**
* Stable code for the "foreign-currency customer invoice without a rate"
* refusal. Registered in lib/errors/structured-errors.ts so REST routes, the
* MCP server and getErrorMessage() all translate it the same way.
*
* Sales-side twin of SI_FX_RATE_MISSING (supplier-invoice-entries.ts).
*/
export const INVOICE_FX_RATE_MISSING = 'INVOICE_FX_RATE_MISSING' as const
/**
* Raised when an invoice booking path is asked to translate a foreign-currency
* amount that has no usable exchange rate.
*
* The generators below derive every FX leg from the per-item amounts, and items
* carry no `*_sek` column: `exchange_rate` is the only SEK source they have. The
* old per-file fallback returned the RAW foreign amount, and because the 1510
* debit is derived from the sum of the credits on the FX branch, every leg was
* scaled by the same wrong factor: the verifikation still balanced, no DB
* trigger fired and nothing errored. A 1 000 EUR sale posted 1 000 kr to 3001
* and 250 kr to 2611 instead of 11 500 kr and 2 875 kr at 11,50 SEK/EUR,
* understating ruta 05 and ruta 10 of the momsdeklaration by the same amount:
* an oriktig uppgift exposed to skattetillägg under SFL 49 kap 4 §.
*
* Refusing instead of guessing follows the `match_batch_allocate` RPC
* (BATCH_FX_RATE_MISSING) and `toSekOrThrow()` in supplier-invoice-entries.ts.
*
* The same refusal covers the header-level fallbacks (no-items bookings and
* the payment entry) via `headerToSekOrThrow` below: those paths DO honour a
* populated `*_sek` column, so only rows with no SEK source at all refuse.
*/
export class InvoiceFxRateMissingError extends Error {
readonly code = INVOICE_FX_RATE_MISSING
constructor(public readonly currency: string) {
super(
`Invoice is in ${currency} but has no exchange rate on file; refusing to post it as if 1 ${currency} = 1 SEK.`
)
this.name = 'InvoiceFxRateMissingError'
}
}
// INVOICE_FX_RATE_MISSING, InvoiceFxRateMissingError, getRevenueAccount and
// getOutputVatAccount live in ./invoice-accounts (pure, no engine import):
// the client-side proposal helpers (propose-send-lines, propose-payment-lines)
// need only those, and importing them from here dragged the engine, the
// account backfill and with it the full BAS chart into the browser bundle.
export {
INVOICE_FX_RATE_MISSING,
InvoiceFxRateMissingError,
getOutputVatAccount,
getRevenueAccount,
} from './invoice-accounts'
import { InvoiceFxRateMissingError, getOutputVatAccount, getRevenueAccount } from './invoice-accounts'
/**
* Convert an invoice-currency item amount to SEK for a journal entry line.
@@ -944,43 +917,3 @@ export async function createInvoiceCashEntry(
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Get the appropriate revenue account based on VAT treatment
*
* For 'exempt': AB uses 3004 (Försäljning inom Sverige, momsfri),
* EF uses 3100 (Momsfria intäkter, mapped to R2 in NE engine).
*/
export function getRevenueAccount(vatTreatment: VatTreatment, entityType: EntityType = 'enskild_firma'): string {
switch (vatTreatment) {
case 'standard_25':
return '3001' // Försäljning 25%
case 'reduced_12':
return '3002' // Försäljning 12%
case 'reduced_6':
return '3003' // Försäljning 6%
case 'reverse_charge':
return '3308' // Försäljning tjänst EU
case 'export':
return '3305' // Försäljning tjänst Export
case 'exempt':
return entityType === 'aktiebolag' ? '3004' : '3100'
default:
return '3001'
}
}
/**
* Get the output VAT account based on VAT treatment
*/
export function getOutputVatAccount(vatTreatment: VatTreatment): string {
switch (vatTreatment) {
case 'standard_25':
return '2611'
case 'reduced_12':
return '2621'
case 'reduced_6':
return '2631'
default:
return '2611'
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import {
getRevenueAccount,
getOutputVatAccount,
InvoiceFxRateMissingError,
} from './invoice-entries'
} from './invoice-accounts'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
+1 -1
View File
@@ -9,7 +9,7 @@ import {
getRevenueAccount,
getOutputVatAccount,
InvoiceFxRateMissingError,
} from './invoice-entries'
} from './invoice-accounts'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
import { roundOre } from '@/lib/money'
+22
View File
@@ -0,0 +1,22 @@
'use client'
import { useEffect, useSyncExternalStore } from 'react'
import { ensureBasLoaded, isBasLoaded, subscribeBasLoaded } from './bas-lazy'
const serverSnapshot = () => false
/**
* Kick off the lazy BAS chart load on mount and re-render once it lands.
* Returns true when getBasLoaded() / getAccountDescription()'s BAS fallback
* can answer. False on the server and during hydration, so SSR and the
* first client render agree.
*/
export function useBasReference(): boolean {
const ready = useSyncExternalStore(subscribeBasLoaded, isBasLoaded, serverSnapshot)
useEffect(() => {
void ensureBasLoaded().catch(() => {
// Descriptions degrade to the hardcoded set; nothing to surface.
})
}, [])
return ready
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Bank file format registry: which parsers exist and how a file is matched
* to one. Split from parser.ts, which also hashes content with `crypto`,
* so client code can resolve a format id to its label without the browser
* crypto polyfill.
*/
import type { BankFileFormat, BankFileFormatId } from './types'
import { nordeaFormat } from './formats/nordea'
import { nordeaBusinessFormat } from './formats/nordea-business'
import { sebFormat } from './formats/seb'
import { swedbankFormat } from './formats/swedbank'
import { handelsbankenFormat } from './formats/handelsbanken'
import { lansforsakringarFormat } from './formats/lansforsakringar'
import { icaBankenFormat } from './formats/ica-banken'
import { skandiaFormat } from './formats/skandia'
import { lunarFormat } from './formats/lunar'
import { northmillFormat } from './formats/northmill'
import { wiseFormat } from './formats/wise'
import { wiseStatementFormat } from './formats/wise-statement'
import { camt053Format } from './formats/camt053'
import { genericCSVFormat } from './formats/generic-csv'
/**
* Ordered list of format detectors.
* camt.053 first (XML detection is unambiguous), then bank-specific CSV formats.
* New bank formats go after existing ones but before generic_csv.
* Generic CSV is last: it never auto-detects (manual fallback only).
*/
const FORMATS: BankFileFormat[] = [
camt053Format,
nordeaFormat,
nordeaBusinessFormat,
sebFormat,
swedbankFormat,
handelsbankenFormat,
lansforsakringarFormat,
icaBankenFormat,
skandiaFormat,
lunarFormat,
northmillFormat,
wiseFormat,
wiseStatementFormat,
genericCSVFormat,
]
/**
* Get a format by its ID
*/
export function getFormat(id: BankFileFormatId): BankFileFormat | undefined {
return FORMATS.find((f) => f.id === id)
}
/**
* Get all available formats
*/
export function getAllFormats(): BankFileFormat[] {
return FORMATS
}
/**
* Auto-detect the bank file format from content and filename
*
* Returns the first matching format, or null if no format matches.
* Uses filename extension as a hint (e.g. .xml for camt.053).
*/
export function detectFileFormat(content: string, filename: string): BankFileFormat | null {
for (const format of FORMATS) {
if (format.detect(content, filename)) {
return format
}
}
return null
}
+6 -66
View File
@@ -7,72 +7,12 @@
import * as crypto from 'crypto'
import type { BankFileFormat, BankFileFormatId, BankFileParseResult, ParsedBankTransaction } from './types'
import { nordeaFormat } from './formats/nordea'
import { nordeaBusinessFormat } from './formats/nordea-business'
import { sebFormat } from './formats/seb'
import { swedbankFormat } from './formats/swedbank'
import { handelsbankenFormat } from './formats/handelsbanken'
import { lansforsakringarFormat } from './formats/lansforsakringar'
import { icaBankenFormat } from './formats/ica-banken'
import { skandiaFormat } from './formats/skandia'
import { lunarFormat } from './formats/lunar'
import { northmillFormat } from './formats/northmill'
import { wiseFormat } from './formats/wise'
import { wiseStatementFormat } from './formats/wise-statement'
import { camt053Format } from './formats/camt053'
import { genericCSVFormat } from './formats/generic-csv'
/**
* Ordered list of format detectors.
* camt.053 first (XML detection is unambiguous), then bank-specific CSV formats.
* New bank formats go after existing ones but before generic_csv.
* Generic CSV is last: it never auto-detects (manual fallback only).
*/
const FORMATS: BankFileFormat[] = [
camt053Format,
nordeaFormat,
nordeaBusinessFormat,
sebFormat,
swedbankFormat,
handelsbankenFormat,
lansforsakringarFormat,
icaBankenFormat,
skandiaFormat,
lunarFormat,
northmillFormat,
wiseFormat,
wiseStatementFormat,
genericCSVFormat,
]
/**
* Get a format by its ID
*/
export function getFormat(id: BankFileFormatId): BankFileFormat | undefined {
return FORMATS.find((f) => f.id === id)
}
/**
* Get all available formats
*/
export function getAllFormats(): BankFileFormat[] {
return FORMATS
}
/**
* Auto-detect the bank file format from content and filename
*
* Returns the first matching format, or null if no format matches.
* Uses filename extension as a hint (e.g. .xml for camt.053).
*/
export function detectFileFormat(content: string, filename: string): BankFileFormat | null {
for (const format of FORMATS) {
if (format.detect(content, filename)) {
return format
}
}
return null
}
// The format registry and detection live in ./formats (no Node imports) so
// client components (BankFileImportHistory) can name a format without
// pulling this module's `crypto` import into the browser bundle.
import { detectFileFormat, getAllFormats, getFormat } from './formats'
export { detectFileFormat, getAllFormats, getFormat } from './formats'
/**
* Parse a bank file with auto-detection or explicit format
@@ -137,7 +77,7 @@ export function parseBankFile(
format = detectFileFormat(content, filename) || undefined
if (!format) {
// Build diagnostic message listing which formats were tried
const tried = FORMATS
const tried = getAllFormats()
.filter(f => f.id !== 'generic_csv')
.map(f => f.name)
const firstLine = content.split('\n')[0]?.substring(0, 80) || ''
+183
View File
@@ -0,0 +1,183 @@
/**
* Personnummer parsing, validation and formatting. Pure string/date logic,
* no Node imports: lib/salary/personnummer.ts (which also encrypts with
* `crypto`) re-exports everything here for its server callers, while
* client components reach these through lib/salary/tax-column.ts without
* the browser crypto polyfill.
*/
/**
* Extract the last 4 digits of a personnummer for display.
*/
export function extractLast4(personnummer: string): string {
const digits = personnummer.replace(/\D/g, '')
return digits.slice(-4)
}
/**
* Validate a Swedish personnummer or samordningsnummer (12-digit format:
* YYYYMMDDNNNN). Checks format + Luhn checksum on last 10 digits.
*
* A samordningsnummer is the identity number Skatteverket assigns to a person
* who has no personnummer. It has the same shape, except the day field carries
* an added 60, so the printed day is 61-91 instead of 1-31. Skatteverket files
* these under FK215 in the arbetsgivardeklaration exactly like a personnummer,
* and our own AGI generator accepts them (see IDENTITET_PATTERN in
* lib/salary/agi/xml-generator.ts, which spells out "samordningsnummer where
* day = actual_day + 60"). Rejecting them here meant the system could file an
* AGI for someone it refused to register as an employee.
*
* The Luhn check digit is computed over the printed digits, the +60 day
* included: a samordningsnummer has no underlying non-offset form to compute it
* from. So the checksum below is deliberately untouched by the offset.
*/
export function validatePersonnummer(personnummer: string): { valid: boolean; error?: string } {
const digits = personnummer.replace(/\D/g, '')
if (digits.length !== 12) {
return { valid: false, error: 'Personnummer måste vara 12 siffror (ÅÅÅÅMMDDNNNN)' }
}
const year = parseInt(digits.slice(0, 4))
const month = parseInt(digits.slice(4, 6))
const day = parseInt(digits.slice(6, 8))
if (year < 1900 || year > 2100) {
return { valid: false, error: 'Ogiltigt år' }
}
if (month < 1 || month > 12) {
return { valid: false, error: 'Ogiltig månad' }
}
// Strip the samordningsnummer offset before range-checking the day, so both
// forms collapse to a real 1-31 calendar day. This accepts 1-31 (personnummer)
// and 61-91 (samordningsnummer) while still rejecting 32-60 and 92-99, which
// are neither: 32-60 is an out-of-range day that has not been offset, and
// 92-99 offsets back to day 32-39.
const birthDay = day > 60 ? day - 60 : day
if (birthDay < 1 || birthDay > 31) {
return { valid: false, error: 'Ogiltig dag' }
}
// Luhn check on digits 3-12 (YYMMDDNNNN, 10 digits)
const luhnDigits = digits.slice(2)
if (!luhnCheck(luhnDigits)) {
return { valid: false, error: 'Ogiltigt kontrollnummer (Luhn)' }
}
return { valid: true }
}
/**
* Luhn checksum validation for 10-digit string.
*/
function luhnCheck(digits: string): boolean {
let sum = 0
for (let i = 0; i < digits.length; i++) {
let d = parseInt(digits[i])
// Multiply every other digit by 2, starting from the first
if (i % 2 === 0) {
d *= 2
if (d > 9) d -= 9
}
sum += d
}
return sum % 10 === 0
}
/**
* Extract birth date from a 12-digit personnummer or samordningsnummer.
*
* A samordningsnummer prints the day offset by 60 (61-91). The offset is a
* numbering convention, not a calendar fact, so the returned `day` is always
* the real 1-31 calendar day: consumers doing date math (calculateAge's
* birthday comparison, or anything constructing a Date) would otherwise be
* off by 60 days. The Luhn checksum is computed over the printed, offset
* digits and is untouched by this normalization (see validatePersonnummer).
*/
export function extractBirthDate(personnummer: string): { year: number; month: number; day: number } {
const digits = personnummer.replace(/\D/g, '')
const printedDay = parseInt(digits.slice(6, 8))
return {
year: parseInt(digits.slice(0, 4)),
month: parseInt(digits.slice(4, 6)),
day: printedDay > 60 ? printedDay - 60 : printedDay,
}
}
/**
* Calculate age at a given date from a personnummer.
*/
export function calculateAge(personnummer: string, atDate: string): number {
const birth = extractBirthDate(personnummer)
const [refYear, refMonth, refDay] = atDate.split('-').map(Number)
let age = refYear - birth.year
if (refMonth < birth.month || (refMonth === birth.month && refDay < birth.day)) {
age--
}
return age
}
/**
* Age tier for "vid årets ingång fyllt X" rules (avgifter age tiers).
*
* Skatteverket applies these rules as BIRTH-YEAR ranges (the 2026
* ungdomsrabatt covers born 2003-2007; the 66/67+ reduction for 2026 covers
* born 1958 or earlier), which equals the age attained by December 31 of
* the PRIOR year. Birthday-inclusive age at January 1 (calculateAge
* semantics) misclassifies employees born exactly on January 1 in both
* directions: born 2008-01-01 would get the 2026 youth rate (Skatteverket's
* AGI validation rejects it) and born 2003-01-01 would be denied it.
*/
export function calculateAgeAtYearStart(personnummer: string, year: number): number {
return year - 1 - extractBirthDate(personnummer).year
}
/**
* Mask personnummer for display: YYYYMMDD-XXXX (birthdate visible, suffix hidden).
*/
export function maskPersonnummer(personnummer: string): string {
const digits = personnummer.replace(/\D/g, '')
return `${digits.slice(0, 8)}-XXXX`
}
/**
* Format personnummer with dash: YYYYMMDD-NNNN
*/
export function formatPersonnummer(personnummer: string): string {
const digits = personnummer.replace(/\D/g, '')
return `${digits.slice(0, 8)}-${digits.slice(8)}`
}
/**
* Expand a personnummer to the 12-digit form (YYYYMMDDNNNN).
*
* Accepts the shapes the customer card stores (10 or 12 digits, optional -/+
* separator; see PERSONAL_NUMBER_INPUT_RE in lib/customers). A 10-digit value
* gets its century inferred the standard Skatteverket way: the most recent
* birth date not after `now`, minus a further hundred years when the
* separator is '+' (the over-100 marker). Samordningsnummer day offsets
* (+60) are stripped for the calendar comparison only; the returned digits
* keep the printed day. Returns digits only, or null when the input has
* neither shape. No checksum validation here: callers that need it run the
* result through validatePersonnummer.
*/
export function expandPersonnummerTo12(value: string, now: Date = new Date()): string | null {
const trimmed = value.trim()
const digits = trimmed.replace(/\D/g, '')
if (digits.length === 12) return digits
if (digits.length !== 10) return null
const yy = parseInt(digits.slice(0, 2), 10)
const month = parseInt(digits.slice(2, 4), 10)
const day = parseInt(digits.slice(4, 6), 10)
const birthDay = day > 60 ? day - 60 : day
// Compare dates as yyyymmdd integers: immune to Date rollover on the
// not-yet-validated month/day values.
const today = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate()
let year = Math.floor(now.getFullYear() / 100) * 100 + yy
if (year * 10000 + month * 100 + birthDay > today) year -= 100
if (trimmed.includes('+')) year -= 100
return `${year}${digits.slice(2)}`
}
+14 -175
View File
@@ -73,181 +73,20 @@ export function decryptPersonnummer(encrypted: string): string {
return decrypted
}
/**
* Extract the last 4 digits of a personnummer for display.
*/
export function extractLast4(personnummer: string): string {
const digits = personnummer.replace(/\D/g, '')
return digits.slice(-4)
}
/**
* Validate a Swedish personnummer or samordningsnummer (12-digit format:
* YYYYMMDDNNNN). Checks format + Luhn checksum on last 10 digits.
*
* A samordningsnummer is the identity number Skatteverket assigns to a person
* who has no personnummer. It has the same shape, except the day field carries
* an added 60, so the printed day is 61-91 instead of 1-31. Skatteverket files
* these under FK215 in the arbetsgivardeklaration exactly like a personnummer,
* and our own AGI generator accepts them (see IDENTITET_PATTERN in
* lib/salary/agi/xml-generator.ts, which spells out "samordningsnummer where
* day = actual_day + 60"). Rejecting them here meant the system could file an
* AGI for someone it refused to register as an employee.
*
* The Luhn check digit is computed over the printed digits, the +60 day
* included: a samordningsnummer has no underlying non-offset form to compute it
* from. So the checksum below is deliberately untouched by the offset.
*/
export function validatePersonnummer(personnummer: string): { valid: boolean; error?: string } {
const digits = personnummer.replace(/\D/g, '')
if (digits.length !== 12) {
return { valid: false, error: 'Personnummer måste vara 12 siffror (ÅÅÅÅMMDDNNNN)' }
}
const year = parseInt(digits.slice(0, 4))
const month = parseInt(digits.slice(4, 6))
const day = parseInt(digits.slice(6, 8))
if (year < 1900 || year > 2100) {
return { valid: false, error: 'Ogiltigt år' }
}
if (month < 1 || month > 12) {
return { valid: false, error: 'Ogiltig månad' }
}
// Strip the samordningsnummer offset before range-checking the day, so both
// forms collapse to a real 1-31 calendar day. This accepts 1-31 (personnummer)
// and 61-91 (samordningsnummer) while still rejecting 32-60 and 92-99, which
// are neither: 32-60 is an out-of-range day that has not been offset, and
// 92-99 offsets back to day 32-39.
const birthDay = day > 60 ? day - 60 : day
if (birthDay < 1 || birthDay > 31) {
return { valid: false, error: 'Ogiltig dag' }
}
// Luhn check on digits 3-12 (YYMMDDNNNN, 10 digits)
const luhnDigits = digits.slice(2)
if (!luhnCheck(luhnDigits)) {
return { valid: false, error: 'Ogiltigt kontrollnummer (Luhn)' }
}
return { valid: true }
}
/**
* Luhn checksum validation for 10-digit string.
*/
function luhnCheck(digits: string): boolean {
let sum = 0
for (let i = 0; i < digits.length; i++) {
let d = parseInt(digits[i])
// Multiply every other digit by 2, starting from the first
if (i % 2 === 0) {
d *= 2
if (d > 9) d -= 9
}
sum += d
}
return sum % 10 === 0
}
/**
* Extract birth date from a 12-digit personnummer or samordningsnummer.
*
* A samordningsnummer prints the day offset by 60 (61-91). The offset is a
* numbering convention, not a calendar fact, so the returned `day` is always
* the real 1-31 calendar day: consumers doing date math (calculateAge's
* birthday comparison, or anything constructing a Date) would otherwise be
* off by 60 days. The Luhn checksum is computed over the printed, offset
* digits and is untouched by this normalization (see validatePersonnummer).
*/
export function extractBirthDate(personnummer: string): { year: number; month: number; day: number } {
const digits = personnummer.replace(/\D/g, '')
const printedDay = parseInt(digits.slice(6, 8))
return {
year: parseInt(digits.slice(0, 4)),
month: parseInt(digits.slice(4, 6)),
day: printedDay > 60 ? printedDay - 60 : printedDay,
}
}
/**
* Calculate age at a given date from a personnummer.
*/
export function calculateAge(personnummer: string, atDate: string): number {
const birth = extractBirthDate(personnummer)
const [refYear, refMonth, refDay] = atDate.split('-').map(Number)
let age = refYear - birth.year
if (refMonth < birth.month || (refMonth === birth.month && refDay < birth.day)) {
age--
}
return age
}
/**
* Age tier for "vid årets ingång fyllt X" rules (avgifter age tiers).
*
* Skatteverket applies these rules as BIRTH-YEAR ranges (the 2026
* ungdomsrabatt covers born 2003-2007; the 66/67+ reduction for 2026 covers
* born 1958 or earlier), which equals the age attained by December 31 of
* the PRIOR year. Birthday-inclusive age at January 1 (calculateAge
* semantics) misclassifies employees born exactly on January 1 in both
* directions: born 2008-01-01 would get the 2026 youth rate (Skatteverket's
* AGI validation rejects it) and born 2003-01-01 would be denied it.
*/
export function calculateAgeAtYearStart(personnummer: string, year: number): number {
return year - 1 - extractBirthDate(personnummer).year
}
/**
* Mask personnummer for display: YYYYMMDD-XXXX (birthdate visible, suffix hidden).
*/
export function maskPersonnummer(personnummer: string): string {
const digits = personnummer.replace(/\D/g, '')
return `${digits.slice(0, 8)}-XXXX`
}
/**
* Format personnummer with dash: YYYYMMDD-NNNN
*/
export function formatPersonnummer(personnummer: string): string {
const digits = personnummer.replace(/\D/g, '')
return `${digits.slice(0, 8)}-${digits.slice(8)}`
}
/**
* Expand a personnummer to the 12-digit form (YYYYMMDDNNNN).
*
* Accepts the shapes the customer card stores (10 or 12 digits, optional -/+
* separator; see PERSONAL_NUMBER_INPUT_RE in lib/customers). A 10-digit value
* gets its century inferred the standard Skatteverket way: the most recent
* birth date not after `now`, minus a further hundred years when the
* separator is '+' (the over-100 marker). Samordningsnummer day offsets
* (+60) are stripped for the calendar comparison only; the returned digits
* keep the printed day. Returns digits only, or null when the input has
* neither shape. No checksum validation here: callers that need it run the
* result through validatePersonnummer.
*/
export function expandPersonnummerTo12(value: string, now: Date = new Date()): string | null {
const trimmed = value.trim()
const digits = trimmed.replace(/\D/g, '')
if (digits.length === 12) return digits
if (digits.length !== 10) return null
const yy = parseInt(digits.slice(0, 2), 10)
const month = parseInt(digits.slice(2, 4), 10)
const day = parseInt(digits.slice(4, 6), 10)
const birthDay = day > 60 ? day - 60 : day
// Compare dates as yyyymmdd integers: immune to Date rollover on the
// not-yet-validated month/day values.
const today = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate()
let year = Math.floor(now.getFullYear() / 100) * 100 + yy
if (year * 10000 + month * 100 + birthDay > today) year -= 100
if (trimmed.includes('+')) year -= 100
return `${year}${digits.slice(2)}`
}
// Pure parsing, validation and formatting helpers live in ./personnummer-format
// (no Node imports) so client components (via lib/salary/tax-column.ts) can
// use them without pulling this module's `crypto` import into the bundle.
export {
calculateAge,
calculateAgeAtYearStart,
expandPersonnummerTo12,
extractBirthDate,
extractLast4,
formatPersonnummer,
maskPersonnummer,
validatePersonnummer,
} from './personnummer-format'
import { maskPersonnummer } from './personnummer-format'
/**
* Shape a raw `employees` row (or an embedded employee object) for a JSON
+1 -1
View File
@@ -1,4 +1,4 @@
import { extractBirthDate } from './personnummer'
import { extractBirthDate } from './personnummer-format'
/**
* Skattetabell columns (1-6) per Skatteverket. The numbering matches the
@@ -0,0 +1,58 @@
/**
* Proof that the client-node-builtin guard follows static imports from a
* 'use client' module to a Node builtin, and only those. Fixtures live in an
* OS temp directory the test creates and deletes.
*/
import { describe, it, expect, afterAll } from 'vitest'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { findClientNodeBuiltins } from '../client-node-builtin.mjs'
const tempDirs: string[] = []
afterAll(() => {
for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true })
})
function fixture(files: Record<string, string>) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'client-builtin-'))
tempDirs.push(root)
for (const [rel, content] of Object.entries(files)) {
const full = path.join(root, rel)
fs.mkdirSync(path.dirname(full), { recursive: true })
fs.writeFileSync(full, content)
}
return root
}
describe('client-node-builtin guard', () => {
it('flags a client component whose lib import chain reaches crypto, with the chain', () => {
const root = fixture({
'lib/auth/hashing.ts': `import crypto from 'crypto'\nexport const hash = (s: string) => crypto.createHash('sha256').update(s).digest('hex')\nexport const isEnabled = () => true\n`,
'components/Login.tsx': `'use client'\nimport { isEnabled } from '@/lib/auth/hashing'\nexport default function Login() { return isEnabled() ? null : null }\n`,
})
const findings = findClientNodeBuiltins(root)
expect(findings).toHaveLength(1)
expect(findings[0]).toMatchObject({ file: 'components/Login.tsx', builtin: 'crypto' })
expect(findings[0].chain).toEqual(['components/Login.tsx', 'lib/auth/hashing.ts', 'bare:crypto'])
})
it('ignores server modules, type-only imports and dynamic imports', () => {
const root = fixture({
'lib/auth/hashing.ts': `import crypto from 'crypto'\nexport type Digest = string\nexport const hash = (s: string) => crypto.createHash('sha256').update(s).digest('hex')\n`,
'lib/server-only.ts': `import { hash } from './auth/hashing'\nexport const h = hash\n`,
'components/TypeOnly.tsx': `'use client'\nimport type { Digest } from '@/lib/auth/hashing'\nexport const d: Digest = ''\n`,
'components/Lazy.tsx': `'use client'\nexport async function load() { const m = await import('@/lib/auth/hashing'); return m.hash('x') }\n`,
})
expect(findClientNodeBuiltins(root)).toEqual([])
})
it('resolves the pure sibling pattern as clean', () => {
const root = fixture({
'lib/auth/flags.ts': `export const isEnabled = () => true\n`,
'lib/auth/hashing.ts': `import crypto from 'crypto'\nexport { isEnabled } from './flags'\nexport const hash = (s: string) => crypto.createHash('sha256').update(s).digest('hex')\n`,
'components/Login.tsx': `'use client'\nimport { isEnabled } from '@/lib/auth/flags'\nexport default function Login() { return isEnabled() ? null : null }\n`,
})
expect(findClientNodeBuiltins(root)).toEqual([])
})
})
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
/**
* Guard: a 'use client' module whose static import closure reaches a Node
* builtin (crypto, buffer, vm, stream, fs, ...).
*
* Turbopack polyfills those for the browser (crypto-browserify, vm-browserify,
* Buffer: ~327 KB uncompressed) the moment ANY client module can reach them,
* and the polyfill chunk then ships with every route that renders the
* component. Before the 2026-08-26 split, lib/auth/bankid.ts (login,
* register, security settings), lib/import/bank-file/parser.ts (bank import
* history), lib/salary/personnummer.ts (via tax-column, the employee forms)
* and lib/auth/api-keys.ts (the API key panel) each did this for a function
* that never touched crypto. The fix is always the same: move the pure part
* into a sibling module without the Node import and import that from the
* client (see bankid-flags.ts, bank-file/formats.ts, personnummer-format.ts,
* api-key-scopes.ts).
*
* No baseline: the count is 0, any new reacher is a hard failure. The walk
* is the same static closure scripts/perf/client-import-closure.mjs prints.
*/
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildGraph, clientReachers } from '../perf/client-import-closure.mjs'
export const NODE_BUILTINS = ['crypto', 'node:crypto', 'buffer', 'node:buffer', 'vm', 'node:vm', 'stream', 'node:stream', 'fs', 'node:fs', 'path', 'node:path', 'child_process', 'node:child_process']
/** [{ file, builtin, chain }] for every client file reaching a builtin. */
export function findClientNodeBuiltins(root) {
const graph = buildGraph(root)
const findings = []
for (const builtin of NODE_BUILTINS) {
for (const [file, chain] of clientReachers(graph, `bare:${builtin}`, root)) {
findings.push({ file, builtin, chain })
}
}
return findings.sort((a, b) => a.file.localeCompare(b.file) || a.builtin.localeCompare(b.builtin))
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (isMain) {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
const findings = findClientNodeBuiltins(root)
for (const f of findings) console.log(`${f.file} -> ${f.builtin}\n ${f.chain.join('\n > ')}`)
console.log(`${findings.length} client file(s) reach a Node builtin`)
process.exit(findings.length ? 1 : 0)
}
+23 -1
View File
@@ -122,6 +122,7 @@ import { fileURLToPath } from 'node:url'
import ts from 'typescript'
import { findSekLabelledFxAmounts } from './format-currency-sek-label.mjs'
import { findRawReferenceFetches } from './raw-reference-fetch.mjs'
import { findClientNodeBuiltins } from './client-node-builtin.mjs'
import {
findExtensionRouteFindings,
UNGATED_EXTENSION_ROUTES,
@@ -1018,6 +1019,7 @@ const current = {
dialogOverflowRisk: findDialogOverflowRisks(),
directAiClients: findDirectAiClients(),
rawReferenceFetch: findRawReferenceFetches(ROOT),
clientNodeBuiltins: findClientNodeBuiltins(ROOT),
}
const dialogOverflowFiles = [...new Set(current.dialogOverflowRisk.map((f) => f.file))].sort()
@@ -1089,6 +1091,26 @@ if (current.directJelInsert.length) {
)
}
// 1b3. client-node-builtin: a 'use client' module whose static import closure
// reaches a Node builtin ships the browser polyfill chunk (~327 KB) with every
// route that renders it. No baseline: 0 today, any reacher is a hard failure.
if (current.clientNodeBuiltins.length) {
failed = true
console.error(
`\n✗ client-node-builtin: ${current.clientNodeBuiltins.length} client module(s) reach a Node builtin ` +
`through their static imports (this ships crypto-browserify/Buffer/vm polyfills to the browser):`,
)
current.clientNodeBuiltins.forEach((f) =>
console.error(` ${f.file} -> ${f.builtin}\n ${f.chain.join('\n > ')}`),
)
console.error(
' → move the pure part the client needs into a sibling module without the Node import\n' +
' (see lib/auth/bankid-flags.ts, lib/import/bank-file/formats.ts, lib/salary/personnummer-format.ts,\n' +
' lib/auth/api-key-scopes.ts) and import that from the client. scripts/perf/client-import-closure.mjs\n' +
' prints the full chain for any module.',
)
}
// 1b2. leaky-supabase-client: server code must construct clients through
// createServiceRoleClient(). No baseline: the count is 0 today.
if (current.leakySupabaseClients.length) {
@@ -1381,5 +1403,5 @@ if (failed) {
process.exit(1)
}
console.log(
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s), raw-reference-fetch: ${current.rawReferenceFetch.length} file(s), direct-ai-client: ${current.directAiClients.length}/${DIRECT_AI_CLIENT_ALLOWED.size} allowlisted).`,
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s), raw-reference-fetch: ${current.rawReferenceFetch.length} file(s), client-node-builtin: ${current.clientNodeBuiltins.length}, direct-ai-client: ${current.directAiClients.length}/${DIRECT_AI_CLIENT_ALLOWED.size} allowlisted).`,
)
+56
View File
@@ -0,0 +1,56 @@
/**
* Regenerates lib/bookkeeping/bas-account-numbers.ts from the BAS chart.
*
* npx tsx scripts/generate-bas-account-numbers.ts # write
* npx tsx scripts/generate-bas-account-numbers.ts --check # exit 1 if stale
*
* The generated module is a sorted list of the ~1,276 standard account
* numbers (~9 KB) so client components can answer "is this a standard BAS
* account?" without the 315 KB data chunk. A unit test pins parity too.
*/
import fs from 'node:fs'
import path from 'node:path'
import { BAS_REFERENCE } from '../lib/bookkeeping/bas-data'
const OUT = path.resolve(__dirname, '..', 'lib', 'bookkeeping', 'bas-account-numbers.ts')
export function renderBasAccountNumbers(numbers: readonly string[]): string {
const sorted = [...new Set(numbers)].sort()
const rows: string[] = []
for (let i = 0; i < sorted.length; i += 12) {
rows.push(' ' + sorted.slice(i, i + 12).map((n) => `'${n}'`).join(', ') + ',')
}
return `// GENERATED by scripts/generate-bas-account-numbers.ts from lib/bookkeeping/bas-data.
// Do not edit by hand: run \`npx tsx scripts/generate-bas-account-numbers.ts\`.
//
// The sorted list of standard BAS account numbers (~9 KB) so client code can
// check membership without importing the full chart (315 KB uncompressed).
// lib/bookkeeping/__tests__/bas-account-numbers.test.ts pins parity.
export const BAS_ACCOUNT_NUMBERS: readonly string[] = [
${rows.join('\n')}
]
const BAS_ACCOUNT_NUMBER_SET: ReadonlySet<string> = new Set(BAS_ACCOUNT_NUMBERS)
/** Whether the number exists in the BAS chart (same answer as isStandardBASAccount). */
export function isStandardBASAccountNumber(accountNumber: string): boolean {
return BAS_ACCOUNT_NUMBER_SET.has(accountNumber)
}
`
}
if (require.main === module) {
const rendered = renderBasAccountNumbers(BAS_REFERENCE.map((a) => a.account_number))
if (process.argv.includes('--check')) {
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : ''
if (current !== rendered) {
console.error('lib/bookkeeping/bas-account-numbers.ts is stale: run npx tsx scripts/generate-bas-account-numbers.ts')
process.exit(1)
}
console.log('bas-account-numbers.ts is up to date')
} else {
fs.writeFileSync(OUT, rendered)
console.log(`wrote ${OUT}`)
}
}
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env node
/**
* Static import closure of every 'use client' module.
*
* Answers "which client components pull module X into the browser bundle,
* and through which path?" without running `next build`. Walks static
* `import ... from` / `export ... from` edges (NOT dynamic `import()`, which
* splits a chunk, and NOT `import type`, which is erased), resolving `@/`
* and relative specifiers to .ts/.tsx/.js/.mjs files or directory indexes.
* Bare specifiers (packages, Node builtins) are recorded as leaves.
*
* node scripts/perf/client-import-closure.mjs lib/bookkeeping/bas-data/index.ts
* node scripts/perf/client-import-closure.mjs crypto node:crypto buffer vm
*
* Prints, per target, the client files whose closure reaches it and the
* shortest import path for each. Used by the responsiveness plan (B7) and
* by the client-node-builtin guard in scripts/checks.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
const SCAN_DIRS = ['app', 'components', 'contexts', 'extensions', 'lib', 'i18n']
const IGNORE_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', 'coverage', '__tests__'])
const EXTS = ['.ts', '.tsx', '.js', '.mjs', '.jsx']
// Block comment bodies are `(?:[^*]|\*(?!\/))*` so an unclosed `/*` cannot be
// re-split at every later `/*` (CodeQL js/redos on the lazy form).
// Single-character whitespace alternative (not \s+): a `+` inside the outer
// `*` is a nested quantifier on the same character, which CodeQL js/redos
// flags as exponential on long runs of spaces.
const USE_CLIENT_RE = /^(?:\s|\/\/[^\n]*\n|\/\*(?:[^*]|\*(?!\/))*\*\/)*['"]use client['"]/
// Static edges only. `import type {...} from` and `export type {...} from`
// are skipped; `import x, { type Y } from` still counts (x is a value).
// One quantifier per span (a greedy `[^'"]*` up to the specifier's opening
// quote, which it cannot cross) so a run of whitespace has a single parse:
// the earlier `\s+ ... [^'"]*? ... \s` shape backtracked exponentially
// (CodeQL js/redos).
const EDGE_RE = /^[ \t]*(?:import|export) (?!type\b)[^'"]*from[ \t]*['"]([^'"]+)['"]/gm
const SIDE_EFFECT_IMPORT_RE = /^\s*import\s+['"]([^'"]+)['"]/gm
export function walkFiles(root = ROOT) {
const out = []
const visit = (dir) => {
if (!fs.existsSync(dir)) return
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (IGNORE_DIRS.has(entry.name)) continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) visit(full)
else if (/\.(?:ts|tsx|js|mjs|jsx)$/.test(entry.name) && !/\.(?:test|pg\.test)\.tsx?$/.test(entry.name)) out.push(full)
}
}
for (const d of SCAN_DIRS) visit(path.join(root, d))
return out
}
export function resolveSpecifier(spec, fromFile, root = ROOT) {
let base
if (spec.startsWith('@/')) base = path.join(root, spec.slice(2))
else if (spec.startsWith('.')) base = path.resolve(path.dirname(fromFile), spec)
else return { bare: spec }
const candidates = [base, ...EXTS.map((e) => base + e), ...EXTS.map((e) => path.join(base, 'index' + e))]
for (const c of candidates) {
if (fs.existsSync(c) && fs.statSync(c).isFile()) return { file: c }
}
return { missing: spec }
}
export function importsOf(source) {
const specs = new Set()
for (const m of source.matchAll(EDGE_RE)) specs.add(m[1])
for (const m of source.matchAll(SIDE_EFFECT_IMPORT_RE)) specs.add(m[1])
return [...specs]
}
/** Build the graph once: file -> { edges: [file|bare], client: boolean }. */
export function buildGraph(root = ROOT) {
const graph = new Map()
for (const file of walkFiles(root)) {
const source = fs.readFileSync(file, 'utf8')
const edges = []
for (const spec of importsOf(source)) {
const r = resolveSpecifier(spec, file, root)
if (r.file) edges.push(r.file)
else if (r.bare) edges.push(`bare:${r.bare}`)
}
graph.set(file, { edges, client: USE_CLIENT_RE.test(source) })
}
return graph
}
/**
* For each client file, BFS its closure; return { clientFile -> path[] } for
* closures that contain `target` (a repo-relative file path or `bare:<spec>`).
*/
export function clientReachers(graph, target, root = ROOT) {
const targetKey = target.startsWith('bare:') ? target : path.join(root, target)
const hits = new Map()
for (const [file, node] of graph) {
if (!node.client) continue
const prev = new Map([[file, null]])
const queue = [file]
let found = null
while (queue.length && !found) {
const cur = queue.shift()
const edges = graph.get(cur)?.edges ?? []
for (const next of edges) {
if (prev.has(next)) continue
prev.set(next, cur)
if (next === targetKey) { found = next; break }
if (!next.startsWith('bare:')) queue.push(next)
}
}
if (found) {
const chain = []
for (let n = found; n; n = prev.get(n)) chain.unshift(n)
hits.set(path.relative(root, file), chain.map((n) => (n.startsWith('bare:') ? n : path.relative(root, n))))
}
}
return hits
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (isMain) {
const targets = process.argv.slice(2)
if (!targets.length) {
console.error('usage: node scripts/perf/client-import-closure.mjs <repo-relative file | bare specifier> ...')
process.exit(1)
}
const graph = buildGraph()
for (const t of targets) {
const key = t.includes('/') || t.endsWith('.ts') || t.endsWith('.tsx') ? t : `bare:${t}`
const hits = clientReachers(graph, key)
console.log(`\n== ${t}: ${hits.size} client file(s) reach it`)
for (const [file, chain] of [...hits].sort()) console.log(` ${file}\n ${chain.join('\n > ')}`)
}
}