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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user