* fix(salary): show birthdate in masked personnummer, hide the 4-digit suffix Flip the personnummer display format from XXXXXXXX-NNNN to YYYYMMDD-XXXX so the sensitive 4-digit suffix is hidden while the (public) birthdate stays visible. Affects the employees list/detail, salary run, payslip PDF, payslip email, and the MCP server tools (list_employees, get_salary_run). Each call site now decrypts the stored personnummer before masking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(transactions): allow deleting unbooked transactions from "Alla transaktioner" The history list only let users delete via the inbox card; once a category or mall was picked but the verifikation hadn't been created, the row showed "Ej bokförd" with no way to remove it. The API already permits delete while journal_entry_id is null, so the gap was purely a missing UI affordance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(vat): populate ruta 20-24 for reverse charge + dishonest "Validera OK" Three connected issues caused Skatteverket to reject momsdeklarationer with FK004 even after our local "Validera"-knapp returned OK. 1. supplier-invoice-entries booked fiktiv moms (2614/2624/2634 + 2645/2647) on reverse-charge invoices but never the underlying basbelopp on 44xx/45xx. Ruta 30-32 filled up at SKV while ruta 20-24 stayed at 0 — SKV's FK004 ("silent netting prohibited", ML 13 kap kräver båda sidor). Fix: generateReverseChargeBasisLines in vat-entries.ts emits parallel 45xx/44xx debit + 4598 motkonto credit per rate group. Engine calls it from registration, cash, and credit-note paths. Skipped when the user booked the expense directly on a basis account to avoid double-counting. 4598 added to BAS reference (no migration needed; account_number is plain text on journal_entry_lines). 2. rutorToMomsuppgift rounded each ruta independently but computed summaMoms from the unrounded ruta49. SKV recomputes the sum from integer rutor on their side, so fractional öres caused ±1 SEK drift and SKV rejected with FK009. Fix: derive summaMoms from the already-rounded VAT-amount rutor. 3. "Validera"-knappen only confirmed SKV's internal arithmetic — a declaration with ruta 30-32 populated and ruta 20-24 empty validated fine until /utkast hit FK004. Users got a false green light. Fix: vat-declaration-checks.ts runs locally before the SKV call, blocks Validera/Spara when ERROR-level findings exist, and surfaces them in a separate "Lokala kontroller"-section. Success message reworded so SKV's OK is no longer presented as filing-ready. Tests: 4535/4536/4531/4425 lines + 4598 motkonto on EU/non-EU/byggtjänster RC, credit-note reversal, fractional-öres summaMoms, all four pre-flight codes (RC_BASIS_MISSING, RC_OUTPUT_MISSING, RC_INPUT_VAT_MISMATCH, SUMMA_MOMS_DRIFT). Backfill for already-posted entries follows in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add skattekonto matching functionality - Enhance TransactionInboxCard to display a warning for potential 1930↔1630 transfers. - Implement match suggestions for skattekonto transactions in the backend. - Create SkattekontoMatchDialog component for linking skattekonto rows to existing journal entries. - Develop SkattekontoInboxCard component to handle skattekonto transactions in the inbox. - Introduce skattekonto-match utility functions for candidate matching and linking. - Update types to include match suggestions and enriched transaction responses. * refactor: reorganize skattekonto types and implement bank counterpart matching logic * docs: update CLAUDE.md to streamline integrations and clarify architecture details * refactor: enhance reverse charge logic to handle non-basis accounts and prevent double-counting --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
4.8 KiB
TypeScript
168 lines
4.8 KiB
TypeScript
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'
|
|
|
|
const ALGORITHM = 'aes-256-gcm'
|
|
const IV_LENGTH = 12
|
|
const TAG_LENGTH = 16
|
|
|
|
/**
|
|
* 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 {
|
|
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 (12-digit format: YYYYMMDDNNNN).
|
|
* Checks format + Luhn checksum on last 10 digits.
|
|
*/
|
|
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' }
|
|
}
|
|
if (day < 1 || day > 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.
|
|
*/
|
|
export function extractBirthDate(personnummer: string): { year: number; month: number; day: number } {
|
|
const digits = personnummer.replace(/\D/g, '')
|
|
return {
|
|
year: parseInt(digits.slice(0, 4)),
|
|
month: parseInt(digits.slice(4, 6)),
|
|
day: parseInt(digits.slice(6, 8)),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
/**
|
|
* Calculate age at the start of a given year.
|
|
* Used for avgifter age tier determination.
|
|
*/
|
|
export function calculateAgeAtYearStart(personnummer: string, year: number): number {
|
|
return calculateAge(personnummer, `${year}-01-01`)
|
|
}
|
|
|
|
/**
|
|
* 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)}`
|
|
}
|