Files
accounted/lib/salary/personnummer.ts
T
62c6fc44fe fix(invoices): article pre-fills ROT/RUT and kundkort personnummer covers the claim (#1634)
* fix(invoices): article pre-fills ROT/RUT and kundkort personnummer covers the claim

Two gaps reported by a user invoicing RUT work:

- Picking an article with a housework_type (arbetstypskod) left the line's
  skattereduktion on 'Ingen': the editor never fetched the field. applyArticle
  now derives deduction_type from the code's Skatteverket list (disjoint ROT/
  RUT lists, new deductionTypeForWorkType helper) and sets work_type, with the
  same overwrite semantics as description/price: an article without a code
  clears the deduction so a material article never keeps claiming one.
  'Spara som artikel' round-trips the code back onto the created article.

- The customer card's personnummer was never used for the ROT/RUT claim; the
  user had to retype it per invoice. The browser only ever sees ciphertext or
  a mask, so the fix is a server-side fallback in buildInvoiceWriteData:
  typed > stored draft > kundkort. The kundkort value is decrypted, expanded
  to 12 digits (new expandPersonnummerTo12, century inference incl. '+' and
  samordningsnummer), Luhn-validated, and encrypted into the invoice; invalid
  or unreadable values fall through to the existing 'Personnummer krävs'
  error. The editor drops the required-mark and hints that the number comes
  from the kundkort when one exists.

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

* fix(invoices): gate the kundkort personnummer fallback on individual customers

ROT/RUT is a privatperson deduction; customers.personal_number is
individual-only in the Zod schemas but not in the DB, so a stray value on a
business row must never be claimed on implicitly. Typed values unaffected.
Raised by the compliance review bot on #1634.

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>
2026-08-17 10:06:05 +02:00

281 lines
11 KiB
TypeScript

import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'
import { createLogger } from '@/lib/logger'
const ALGORITHM = 'aes-256-gcm'
const IV_LENGTH = 12
const TAG_LENGTH = 16
const logger = createLogger('salary/personnummer')
/**
* Get the encryption key from environment.
* Falls back to a dev-only key for local development.
*/
function getEncryptionKey(): Buffer {
const envKey = process.env.PERSONNUMMER_ENCRYPTION_KEY
if (!envKey) {
if (process.env.NODE_ENV === 'production') {
throw new Error('PERSONNUMMER_ENCRYPTION_KEY is required in production')
}
// Dev-only deterministic key (NOT safe for production)
return scryptSync('dev-only-key', 'gnubok-dev-salt', 32)
}
// Use scrypt to derive a 32-byte key from the env var
return scryptSync(envKey, 'gnubok-pnr-salt', 32)
}
/**
* Encrypt a personnummer for storage.
* Returns a hex string: iv + ciphertext + authTag
*/
export function encryptPersonnummer(personnummer: string): string {
const key = getEncryptionKey()
const iv = randomBytes(IV_LENGTH)
const cipher = createCipheriv(ALGORITHM, key, iv)
let encrypted = cipher.update(personnummer, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag()
return iv.toString('hex') + encrypted + authTag.toString('hex')
}
/**
* Decrypt a personnummer from storage.
*/
export function decryptPersonnummer(encrypted: string): string {
// Tolerate legacy/unencrypted rows. A raw 12-digit personnummer (written by
// a path that skipped encryptPersonnummer, e.g. the v1 REST create route
// before this fix, or a seed) would otherwise be sliced as iv/ciphertext/tag
// and throw ERR_CRYPTO_INVALID_AUTH_TAG ("Invalid authentication tag length:
// 6"), 500-ing every decrypt-on-read path (roster, salary runs, payslips,
// KU, AGI, MCP). Real ciphertext is 80 hex chars, so a 12-digit match is
// unambiguously plaintext. Return it as-is and warn so the backfill can find
// and re-encrypt it. Value is never logged. See DECISIONS.md.
if (/^\d{12}$/.test(encrypted)) {
logger.warn('decryptPersonnummer received an unencrypted personnummer; returning as-is (row needs backfill)')
return encrypted
}
const key = getEncryptionKey()
const ivHex = encrypted.slice(0, IV_LENGTH * 2)
const authTagHex = encrypted.slice(-TAG_LENGTH * 2)
const ciphertext = encrypted.slice(IV_LENGTH * 2, -TAG_LENGTH * 2)
const iv = Buffer.from(ivHex, 'hex')
const authTag = Buffer.from(authTagHex, 'hex')
const decipher = createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(authTag)
let decrypted = decipher.update(ciphertext, 'hex', 'utf8')
decrypted += decipher.final('utf8')
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)}`
}
/**
* Shape a raw `employees` row (or an embedded employee object) for a JSON
* response: drop every personnummer-derived column and expose the display
* form under `personnummer_masked`.
*
* Two columns must go, not one:
* - `personnummer` (the AES-256-GCM ciphertext), and
* - `personnummer_last4`: the mask is 'YYYYMMDD-XXXX', so a response that
* carries the mask AND the last four digits hands the client the full
* personnummer by simple concatenation, defeating the mask entirely.
* No UI reads employees.personnummer_last4; it exists for the DB-side
* uniqueness constraint and Skatteverket-bound documents (payslips, AGI,
* KU), which render server-side.
*
* The mask goes out under `personnummer_masked`, never under the writable
* `personnummer` key: these payloads feed edit forms, and a mask returned
* under the write key could be posted straight back into the encrypt path.
* v1, the MCP tools and lib/salary/employee-commands.ts use the `_masked`
* suffix for the same reason.
*/
export function maskEmployeeForResponse(
employee: Record<string, unknown>
): Record<string, unknown> {
const { personnummer, personnummer_last4: _last4, ...rest } = employee
return {
...rest,
personnummer_masked: maskPersonnummer(decryptPersonnummer(personnummer as string)),
}
}