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