diff --git a/app/(auth)/login/login-client.tsx b/app/(auth)/login/login-client.tsx index 4fd7c858..38bb766f 100644 --- a/app/(auth)/login/login-client.tsx +++ b/app/(auth)/login/login-client.tsx @@ -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' diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 677b6722..596e767a 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -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' diff --git a/components/agent-knowledge/LedgerGraph.tsx b/components/agent-knowledge/LedgerGraph.tsx index 7bd8b34d..5600478a 100644 --- a/components/agent-knowledge/LedgerGraph.tsx +++ b/components/agent-knowledge/LedgerGraph.tsx @@ -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 = { 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]) diff --git a/components/bookkeeping/AddAccountDialog.tsx b/components/bookkeeping/AddAccountDialog.tsx index 3f48b4b0..92a9f722 100644 --- a/components/bookkeeping/AddAccountDialog.tsx +++ b/components/bookkeeping/AddAccountDialog.tsx @@ -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() { diff --git a/components/bookkeeping/ChartOfAccountsManager.tsx b/components/bookkeeping/ChartOfAccountsManager.tsx index c2ef7e61..6757b95a 100644 --- a/components/bookkeeping/ChartOfAccountsManager.tsx +++ b/components/bookkeeping/ChartOfAccountsManager.tsx @@ -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')} )} - {!isStandardBASAccount(account.account_number) && ( + {!isStandardBASAccountNumber(account.account_number) && ( {t('own_badge')} diff --git a/components/bookkeeping/CorrectOpeningBalanceDialog.tsx b/components/bookkeeping/CorrectOpeningBalanceDialog.tsx index 865d98d5..9fd9e899 100644 --- a/components/bookkeeping/CorrectOpeningBalanceDialog.tsx +++ b/components/bookkeeping/CorrectOpeningBalanceDialog.tsx @@ -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(null) const [isSubmitting, setIsSubmitting] = useState(false) diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 545d8348..5e54a550 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -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([]) const [committingId, setCommittingId] = useState(null) // Confirm-before-posting (convention 10): the draft the user is about to diff --git a/components/import/BankFileImportHistory.tsx b/components/import/BankFileImportHistory.tsx index 8fc14846..9d84e00e 100644 --- a/components/import/BankFileImportHistory.tsx +++ b/components/import/BankFileImportHistory.tsx @@ -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' diff --git a/components/import/OpeningBalanceRowEditor.tsx b/components/import/OpeningBalanceRowEditor.tsx index 828c96db..efbcce65 100644 --- a/components/import/OpeningBalanceRowEditor.tsx +++ b/components/import/OpeningBalanceRowEditor.tsx @@ -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 = { + 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 | null = null +function getFuse(): Fuse | 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 | null = null +function getBalanceFuse(): Fuse | 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) => { 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, diff --git a/components/settings/SecuritySettings.tsx b/components/settings/SecuritySettings.tsx index 99d2ce19..1e4d57d2 100644 --- a/components/settings/SecuritySettings.tsx +++ b/components/settings/SecuritySettings.tsx @@ -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' diff --git a/components/supplier-invoices/NewSupplierInvoiceForm.tsx b/components/supplier-invoices/NewSupplierInvoiceForm.tsx index c704abec..cd24416b 100644 --- a/components/supplier-invoices/NewSupplierInvoiceForm.tsx +++ b/components/supplier-invoices/NewSupplierInvoiceForm.tsx @@ -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 diff --git a/components/ui/account-number.tsx b/components/ui/account-number.tsx index 385254aa..627e638f 100644 --- a/components/ui/account-number.tsx +++ b/components/ui/account-number.tsx @@ -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 diff --git a/lib/auth/bankid-flags.ts b/lib/auth/bankid-flags.ts new file mode 100644 index 00000000..7af6f188 --- /dev/null +++ b/lib/auth/bankid-flags.ts @@ -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) +} diff --git a/lib/auth/bankid.ts b/lib/auth/bankid.ts index 5b9b471e..81f1d637 100644 --- a/lib/auth/bankid.ts +++ b/lib/auth/bankid.ts @@ -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) diff --git a/lib/bookkeeping/__tests__/bas-account-numbers.test.ts b/lib/bookkeeping/__tests__/bas-account-numbers.test.ts new file mode 100644 index 00000000..8b780161 --- /dev/null +++ b/lib/bookkeeping/__tests__/bas-account-numbers.test.ts @@ -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)) + } + }) +}) diff --git a/lib/bookkeeping/account-classifier-client.ts b/lib/bookkeeping/account-classifier-client.ts new file mode 100644 index 00000000..fdbd2621 --- /dev/null +++ b/lib/bookkeeping/account-classifier-client.ts @@ -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) +} diff --git a/lib/bookkeeping/account-classifier-heuristic.ts b/lib/bookkeeping/account-classifier-heuristic.ts new file mode 100644 index 00000000..8167e146 --- /dev/null +++ b/lib/bookkeeping/account-classifier-heuristic.ts @@ -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' } + } +} diff --git a/lib/bookkeeping/account-classifier.ts b/lib/bookkeeping/account-classifier.ts index 969badb0..dd558e97 100644 --- a/lib/bookkeeping/account-classifier.ts +++ b/lib/bookkeeping/account-classifier.ts @@ -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) } diff --git a/lib/bookkeeping/account-descriptions.ts b/lib/bookkeeping/account-descriptions.ts index 47945abc..f107b750 100644 --- a/lib/bookkeeping/account-descriptions.ts +++ b/lib/bookkeeping/account-descriptions.ts @@ -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 diff --git a/lib/bookkeeping/bas-account-numbers.ts b/lib/bookkeeping/bas-account-numbers.ts new file mode 100644 index 00000000..cf81b5e0 --- /dev/null +++ b/lib/bookkeeping/bas-account-numbers.ts @@ -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 = 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) +} diff --git a/lib/bookkeeping/bas-labels.ts b/lib/bookkeeping/bas-labels.ts new file mode 100644 index 00000000..753020e4 --- /dev/null +++ b/lib/bookkeeping/bas-labels.ts @@ -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 = { + 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 = { + // 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', +} diff --git a/lib/bookkeeping/bas-lazy.ts b/lib/bookkeeping/bas-lazy.ts new file mode 100644 index 00000000..8d0df8d5 --- /dev/null +++ b/lib/bookkeeping/bas-lazy.ts @@ -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 | null = null +let loading: Promise | null = null +const listeners = new Set<() => void>() + +/** Start (or join) the chunk load. Resolves with the full chart. */ +export function ensureBasLoaded(): Promise { + 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) + } +} diff --git a/lib/bookkeeping/bas-reference.ts b/lib/bookkeeping/bas-reference.ts index e3c00147..fc7953bf 100644 --- a/lib/bookkeeping/bas-reference.ts +++ b/lib/bookkeeping/bas-reference.ts @@ -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 = { - 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 = { - // 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) diff --git a/lib/bookkeeping/invoice-accounts.ts b/lib/bookkeeping/invoice-accounts.ts new file mode 100644 index 00000000..bd6bb345 --- /dev/null +++ b/lib/bookkeeping/invoice-accounts.ts @@ -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' + } +} diff --git a/lib/bookkeeping/invoice-entries.ts b/lib/bookkeeping/invoice-entries.ts index 2ffd3744..57a8441d 100644 --- a/lib/bookkeeping/invoice-entries.ts +++ b/lib/bookkeeping/invoice-entries.ts @@ -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' - } -} diff --git a/lib/bookkeeping/propose-payment-lines.ts b/lib/bookkeeping/propose-payment-lines.ts index 103c08a7..40875935 100644 --- a/lib/bookkeeping/propose-payment-lines.ts +++ b/lib/bookkeeping/propose-payment-lines.ts @@ -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' diff --git a/lib/bookkeeping/propose-send-lines.ts b/lib/bookkeeping/propose-send-lines.ts index f76411a3..10c9ce58 100644 --- a/lib/bookkeeping/propose-send-lines.ts +++ b/lib/bookkeeping/propose-send-lines.ts @@ -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' diff --git a/lib/bookkeeping/use-bas-reference.ts b/lib/bookkeeping/use-bas-reference.ts new file mode 100644 index 00000000..279c9b5b --- /dev/null +++ b/lib/bookkeeping/use-bas-reference.ts @@ -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 +} diff --git a/lib/import/bank-file/formats.ts b/lib/import/bank-file/formats.ts new file mode 100644 index 00000000..74480e85 --- /dev/null +++ b/lib/import/bank-file/formats.ts @@ -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 +} diff --git a/lib/import/bank-file/parser.ts b/lib/import/bank-file/parser.ts index 98ac8ef0..f1654d98 100644 --- a/lib/import/bank-file/parser.ts +++ b/lib/import/bank-file/parser.ts @@ -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) || '' diff --git a/lib/salary/personnummer-format.ts b/lib/salary/personnummer-format.ts new file mode 100644 index 00000000..ace73948 --- /dev/null +++ b/lib/salary/personnummer-format.ts @@ -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)}` +} diff --git a/lib/salary/personnummer.ts b/lib/salary/personnummer.ts index 5ec0bca4..8def9035 100644 --- a/lib/salary/personnummer.ts +++ b/lib/salary/personnummer.ts @@ -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 diff --git a/lib/salary/tax-column.ts b/lib/salary/tax-column.ts index e9ad828e..508fbbff 100644 --- a/lib/salary/tax-column.ts +++ b/lib/salary/tax-column.ts @@ -1,4 +1,4 @@ -import { extractBirthDate } from './personnummer' +import { extractBirthDate } from './personnummer-format' /** * Skattetabell columns (1-6) per Skatteverket. The numbering matches the diff --git a/scripts/checks/__tests__/client-node-builtin.test.ts b/scripts/checks/__tests__/client-node-builtin.test.ts new file mode 100644 index 00000000..0052bec7 --- /dev/null +++ b/scripts/checks/__tests__/client-node-builtin.test.ts @@ -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) { + 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([]) + }) +}) diff --git a/scripts/checks/client-node-builtin.mjs b/scripts/checks/client-node-builtin.mjs new file mode 100644 index 00000000..c5fe0c48 --- /dev/null +++ b/scripts/checks/client-node-builtin.mjs @@ -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) +} diff --git a/scripts/checks/no-new-antipatterns.mjs b/scripts/checks/no-new-antipatterns.mjs index addecfea..17fe1d76 100644 --- a/scripts/checks/no-new-antipatterns.mjs +++ b/scripts/checks/no-new-antipatterns.mjs @@ -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).`, ) diff --git a/scripts/generate-bas-account-numbers.ts b/scripts/generate-bas-account-numbers.ts new file mode 100644 index 00000000..4a3dd060 --- /dev/null +++ b/scripts/generate-bas-account-numbers.ts @@ -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 = 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}`) + } +} diff --git a/scripts/perf/client-import-closure.mjs b/scripts/perf/client-import-closure.mjs new file mode 100644 index 00000000..004f97f2 --- /dev/null +++ b/scripts/perf/client-import-closure.mjs @@ -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:`). + */ +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 ...') + 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 > ')}`) + } +}