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>
This commit is contained in:
Jakob Wennberg
2026-08-17 10:06:05 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 44c3116357
commit 62c6fc44fe
10 changed files with 366 additions and 7 deletions
+1
View File
@@ -1023,3 +1023,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-16] Row exit animation for dry-table <tr> rows collapses via td padding/line-height/font-size transitions plus a numeric max-height (.row-collapsible) on the fixed-height cell spans, not grid-template-rows 0fr (the AttGoraSection pattern): table cells cannot host the grid wrapper without restructuring every td, and max-height needs a numeric rest value because auto/none does not interpolate. prefers-reduced-motion hides the exiting row instantly (display: none) while the 350ms timer does the state cleanup.
[2026-08-16] Restyled QuickReviewDialog's inbox-picker trigger to the same full-width dropzone-footer row as TransactionBookingDialog even though it did not share the orphan-button layout: both surfaces come from #1620 and should present the same underlag affordance; the alternative (leaving a small outline button in one dialog and a footer row in the other) would split the visual language of one control. Presentation only, disabled-while-booking kept (PR #1628).
[2026-08-17] Full-archive direct download resurfaced as an ImportRow + small centered dialog on /import's Exportera tab (row "Komplett arkiv", hash #full-archive), not by re-mounting the orphaned components/settings/BackupDownloadForm.tsx: the form was pre-frame card styling with a duplicate cloud-backup section, while the export tab's existing SIE dialog sets the house pattern (ImportRow -> sm:max-w-md dialog). Its logic (estimate, 413 handling, last-download stamp) ported into components/import/FullArchiveDialog.tsx; the orphan and its dead settings_backup_download i18n namespace deleted. The dialog reuses FiscalYearSelector despite design.md's "legacy, no new uses" line: FyPicker is a toolbar context chip, and the SIE dialog in the same file already uses FiscalYearSelector for the identical dialog-form slot, so matching it beats introducing a third pattern. Row gated to owner/admin because GET /api/reports/full-archive enforces that role server-side; showing members a download that can only 403 helps nobody.
[2026-08-17] Article picker now overwrites the line's ROT/RUT (deduction_type + work_type) from the article's housework_type, INCLUDING clearing it when the article has none: article-defines-the-row is the established applyArticle semantic (description/price/unit already overwrite), and keeping a RUT flag when switching a row to a material article would silently claim a deduction on material (HUSFL labor-only rule). Kundkort personnummer prefill is a server-side fallback in buildInvoiceWriteData (typed > stored draft > kundkort), never a client prefill: customers.personal_number reaches the browser only as ciphertext/mask by design, so the editor just relaxes the required-mark and says where the number will come from.
+50 -5
View File
@@ -62,7 +62,9 @@ import {
ROT_MAX,
RUT_MAX,
computeDeduction,
deductionTypeForWorkType,
} from '@/lib/invoices/rot-rut-rules'
import { UNDECRYPTABLE_PERSONAL_NUMBER_MASK } from '@/lib/customers/mask-personal-number'
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
@@ -98,7 +100,7 @@ export type InvoiceEditorProps = (
// Subset of Article fields the line picker needs to pre-fill a row.
type ArticleOption = Pick<
Article,
'id' | 'article_number' | 'name' | 'unit' | 'price_excl_vat' | 'vat_rate' | 'revenue_account' | 'currency'
'id' | 'article_number' | 'name' | 'unit' | 'price_excl_vat' | 'vat_rate' | 'revenue_account' | 'currency' | 'housework_type'
>
function RequiredMark() {
@@ -516,7 +518,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
if (!company?.id) return
const { data } = await supabase
.from('articles')
.select('id, article_number, name, unit, price_excl_vat, vat_rate, revenue_account, currency')
.select('id, article_number, name, unit, price_excl_vat, vat_rate, revenue_account, currency, housework_type')
.eq('company_id', company.id)
.eq('active', true)
// Numeric-aware order by article number ('2' before '10', unnumbered last):
@@ -565,6 +567,31 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// The account override rides along regardless of rate; the engine ignores it
// for reverse-charge/export and validates it against the chart of accounts.
setValue(`items.${index}.revenue_account`, a.revenue_account ?? null, { shouldDirty: true })
// ROT/RUT: the article's housework_type (Skatteverket arbetstypskod)
// decides both the line's deduction kind and its work type. An article
// WITHOUT one re-defaults the row to no deduction, the same overwrite
// semantics as description/price above: a material article picked onto a
// previously RUT-flagged row must not keep claiming a deduction on
// material. Proformas/delivery notes/self-billing have no deduction model
// (their rows keep no ⋮ menu either), so they are left untouched.
if (isInvoiceDoc) {
const kind = deductionTypeForWorkType(a.housework_type)
setValue(`items.${index}.deduction_type`, kind, { shouldDirty: true })
setValue(`items.${index}.work_type`, kind ? a.housework_type : null, { shouldDirty: true })
if (kind) {
// Same rule as the manual ⋮ menu: ROT/RUT och periodisering
// kombineras aldrig på samma rad; avdraget vinner.
if (getValues(`items.${index}.accrual_balance_account`) != null) {
setValue(`items.${index}.accrual_period_start`, null)
setValue(`items.${index}.accrual_period_end`, null)
setValue(`items.${index}.accrual_balance_account`, null)
}
} else {
setValue(`items.${index}.labor_hours`, null)
setValue(`items.${index}.housing_designation`, null)
setValue(`items.${index}.apartment_number`, null)
}
}
// Pre-fill the invoice's (single) currency from the article ONLY on the
// first priced line, and only while the user hasn't chosen a currency
// themselves. Never flip an in-progress invoice's currency on a later pick:
@@ -609,6 +636,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// The typed unit price is in the invoice's currency: without this an
// EUR invoice line becomes an SEK article with the EUR number.
currency: getValues('currency'),
// Round-trip the ROT/RUT arbetstypskod so the saved article
// pre-fills the deduction the next time it is picked.
housework_type: item.deduction_type ? item.work_type ?? null : null,
}),
})
const result = await response.json()
@@ -875,6 +905,16 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const deductionTotal = Math.round((deductionByKind.rot + deductionByKind.rut) * 100) / 100
const hasAnyDeduction = deductionTotal > 0
const hasAnyRotLine = isInvoiceDoc && watchItems.some((i) => i.deduction_type === 'rot')
// The kundkort's personnummer reaches this component as ciphertext (direct
// table read) or as the masked display form (rows from the API), so the
// editor can only know THAT the customer has one, never render it. Presence
// is enough: the server falls back to it when the field is left empty, so
// the field stops being required and the hint says where the number will
// come from. The undecryptable placeholder is not presence.
const customerHasPersonalNumber = Boolean(
selectedCustomer?.personal_number &&
selectedCustomer.personal_number !== UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
)
// Öresavrundning live preview: same helper as the PDF/email, so the summary
// shows exactly what the customer will see. Display-only; the saved invoice
@@ -2159,7 +2199,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="deduction_personnummer">
{t('deduction_personnummer_label')}<RequiredMark />
{t('deduction_personnummer_label')}
{!(initial?.deduction_personnummer_last4 || customerHasPersonalNumber) && <RequiredMark />}
</Label>
<Input
id="deduction_personnummer"
@@ -2169,10 +2210,14 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
/>
<p className="text-xs text-muted-foreground">
{/* Stored pn exists only as ciphertext: an empty field on
edit keeps it server-side instead of failing validation. */}
edit keeps it server-side instead of failing validation.
Otherwise, a kundkort with a personnummer covers an
empty field via the server-side fallback. */}
{initial?.deduction_personnummer_last4
? t('deduction_personnummer_kept_hint', { last4: initial.deduction_personnummer_last4 })
: t('deduction_personnummer_hint')}
: customerHasPersonalNumber
? t('deduction_personnummer_customer_hint')
: t('deduction_personnummer_hint')}
</p>
</div>
{hasAnyRotLine && (
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase, makeCustomer } from '@/tests/helpers'
import { buildInvoiceWriteData, type InvoiceWriteInput } from '@/lib/invoices/build-invoice-write'
import { encryptPersonnummer, decryptPersonnummer } from '@/lib/salary/personnummer'
import type { Customer, InvoiceDocumentType } from '@/types'
// Uses the REAL getVatRules / rot-rut-rules / personnummer helpers (only the
@@ -361,3 +362,165 @@ describe('buildInvoiceWriteData stored ROT/RUT personnummer (edit path)', () =>
expect(result.invoiceFields.deduction_personnummer_last4).toBeNull()
})
})
describe('buildInvoiceWriteData kundkort personnummer fallback', () => {
const rutItem = {
description: 'Städning',
quantity: 10,
unit: 'tim',
unit_price: 500,
vat_rate: 25,
deduction_type: 'rut' as const,
labor_hours: 10,
}
it('falls back to the customer card personal_number when the field is empty', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
const customer = makeCustomer({
customer_type: 'individual',
personal_number: encryptPersonnummer('199001019802'),
})
const result = await buildInvoiceWriteData({
supabase: supabase as unknown as SupabaseClient,
companyId: 'company-1',
customer,
documentType: 'invoice',
input: { ...baseHeader, items: [rutItem] },
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.invoiceFields.deduction_personnummer_last4).toBe('9802')
expect(decryptPersonnummer(result.invoiceFields.deduction_personnummer_encrypted as string)).toBe('199001019802')
})
it('expands a 10-digit legacy plaintext kundkort value to 12 digits', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
const customer = makeCustomer({
customer_type: 'individual',
personal_number: '900101-9802',
})
const result = await buildInvoiceWriteData({
supabase: supabase as unknown as SupabaseClient,
companyId: 'company-1',
customer,
documentType: 'invoice',
input: { ...baseHeader, items: [rutItem] },
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.invoiceFields.deduction_personnummer_last4).toBe('9802')
expect(decryptPersonnummer(result.invoiceFields.deduction_personnummer_encrypted as string)).toBe('199001019802')
})
it('lets a typed personnummer win over the customer card', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
const customer = makeCustomer({
customer_type: 'individual',
personal_number: '250101-0025',
})
const result = await buildInvoiceWriteData({
supabase: supabase as unknown as SupabaseClient,
companyId: 'company-1',
customer,
documentType: 'invoice',
input: { ...baseHeader, deduction_personnummer: '199001019802', items: [rutItem] },
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.invoiceFields.deduction_personnummer_last4).toBe('9802')
expect(decryptPersonnummer(result.invoiceFields.deduction_personnummer_encrypted as string)).toBe('199001019802')
})
it('lets the stored draft personnummer outrank the customer card (edit path)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
const customer = makeCustomer({
customer_type: 'individual',
personal_number: '900101-9802',
})
const result = await buildInvoiceWriteData({
supabase: supabase as unknown as SupabaseClient,
companyId: 'company-1',
customer,
documentType: 'invoice',
input: { ...baseHeader, items: [rutItem] },
existingPersonnummer: { encrypted: 'stored-ciphertext', last4: '1234' },
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.invoiceFields.deduction_personnummer_encrypted).toBe('stored-ciphertext')
expect(result.invoiceFields.deduction_personnummer_last4).toBe('1234')
})
it('treats an invalid kundkort value as absent and still requires a typed one', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
const customer = makeCustomer({
customer_type: 'individual',
// Bad Luhn: must fall through to the "Personnummer krävs" error, never
// to a confusing "invalid personnummer" for a value the user never typed.
personal_number: '900101-9803',
})
const result = await buildInvoiceWriteData({
supabase: supabase as unknown as SupabaseClient,
companyId: 'company-1',
customer,
documentType: 'invoice',
input: { ...baseHeader, items: [rutItem] },
})
expect(result.ok).toBe(false)
if (result.ok) return
expect('code' in result && result.code).toBe('INVOICE_CREATE_ROT_RUT_VALIDATION')
})
})
describe('buildInvoiceWriteData kundkort fallback customer-type gate', () => {
it('never claims on a stray personal_number of a non-individual customer', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
// personal_number is individual-only in the Zod schemas but not in the
// DB: a legacy/business row carrying one must not be claimed on
// implicitly, so the fallback stays off and validation asks for a typed
// personnummer.
const customer = makeCustomer({
customer_type: 'swedish_business',
personal_number: '900101-9802',
})
const result = await buildInvoiceWriteData({
supabase: supabase as unknown as SupabaseClient,
companyId: 'company-1',
customer,
documentType: 'invoice',
input: {
...baseHeader,
items: [{
description: 'Städning',
quantity: 10,
unit: 'tim',
unit_price: 500,
vat_rate: 25,
deduction_type: 'rut' as const,
labor_hours: 10,
}],
},
})
expect(result.ok).toBe(false)
if (result.ok) return
expect('code' in result && result.code).toBe('INVOICE_CREATE_ROT_RUT_VALIDATION')
})
})
@@ -10,6 +10,7 @@ import {
validateInvoice,
deductionSekConverter,
deductionToSek,
deductionTypeForWorkType,
type ItemForDeduction,
type ValidateInvoiceItem,
} from '../rot-rut-rules'
@@ -360,3 +361,22 @@ describe('validateInvoice: foreign currency vs the kronor ceilings', () => {
expect(result.warnings[1]).toContain('RUT-avdraget')
})
})
describe('deductionTypeForWorkType', () => {
it('maps ROT codes to rot and RUT codes to rut', () => {
expect(deductionTypeForWorkType('BYGG')).toBe('rot')
expect(deductionTypeForWorkType('VVS')).toBe('rot')
expect(deductionTypeForWorkType('STAD')).toBe('rut')
// IT-tjänster moved from the rot list to rut 2026-07: the mapping must
// follow the lists, never a hardcoded copy.
expect(deductionTypeForWorkType('IT')).toBe('rut')
expect(deductionTypeForWorkType('TVATT')).toBe('rut')
})
it('maps unknown or absent codes to null', () => {
expect(deductionTypeForWorkType(null)).toBeNull()
expect(deductionTypeForWorkType(undefined)).toBeNull()
expect(deductionTypeForWorkType('')).toBeNull()
expect(deductionTypeForWorkType('SNICKERI')).toBeNull()
})
})
+39 -2
View File
@@ -11,9 +11,11 @@ import {
} from '@/lib/invoices/rot-rut-rules'
import {
encryptPersonnummer,
expandPersonnummerTo12,
extractLast4,
validatePersonnummer,
} from '@/lib/salary/personnummer'
import { revealStoredCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
/**
* Shared invoice write-builder.
@@ -343,7 +345,38 @@ export async function buildInvoiceWriteData(params: {
const hasDeductionItems = validateInput.some((item) => item.deduction_type != null)
const keepStoredPersonnummer =
personnummerRaw.length === 0 && hasDeductionItems && !!existingPersonnummer
const personnummerProvided = personnummerRaw.length > 0 || keepStoredPersonnummer
// Neither typed nor stored on the draft: fall back to the personnummer on
// the customer card (kundkortet). It lives on customers.personal_number as
// ciphertext (or a legacy plaintext row) in 10- or 12-digit form; the
// Skatteverket claim needs 12 digits, so expand and Luhn-validate before
// counting it as provided. Anything unreadable, inexpandable or invalid is
// treated as absent: the validator below then asks the user to type one,
// which beats surfacing an "invalid personnummer" error for a value they
// never entered.
// Individual-only: ROT/RUT is a privatperson deduction (HUSFL), and
// customers.personal_number is individual-only in the Zod schemas but not
// in the DB, so a stray value on a business row (legacy import, direct
// write) must never be claimed on implicitly. A typed personnummer is
// unaffected: the user is stating it explicitly.
let customerCardPersonnummer: string | null = null
if (
personnummerRaw.length === 0 &&
hasDeductionItems &&
!keepStoredPersonnummer &&
customer.customer_type === 'individual'
) {
try {
const revealed = revealStoredCustomerPersonalNumber(customer.personal_number)
const expanded = revealed ? expandPersonnummerTo12(revealed) : null
if (expanded && validatePersonnummer(expanded).valid) {
customerCardPersonnummer = expanded
}
} catch {
// Undecryptable customer value: same as absent.
}
}
const personnummerProvided =
personnummerRaw.length > 0 || keepStoredPersonnummer || customerCardPersonnummer !== null
// The invoice currency decides whether the item amounts can be compared
// against the kronor ceilings at all. The booking rate is fetched further
// down (the write needs the invoice totals first), so a foreign-currency
@@ -367,13 +400,17 @@ export async function buildInvoiceWriteData(params: {
if (keepStoredPersonnummer && existingPersonnummer) {
deductionPersonnummerEncrypted = existingPersonnummer.encrypted
deductionPersonnummerLast4 = existingPersonnummer.last4
} else if (personnummerProvided) {
} else if (personnummerRaw.length > 0) {
const pnValid = validatePersonnummer(personnummerRaw)
if (!pnValid.valid) {
return { ok: false, code: 'INVOICE_CREATE_ROT_RUT_PERSONNUMMER_INVALID', details: { error: pnValid.error } }
}
deductionPersonnummerEncrypted = encryptPersonnummer(personnummerRaw)
deductionPersonnummerLast4 = extractLast4(personnummerRaw)
} else if (customerCardPersonnummer) {
// Already expanded to 12 digits and Luhn-validated above.
deductionPersonnummerEncrypted = encryptPersonnummer(customerCardPersonnummer)
deductionPersonnummerLast4 = extractLast4(customerCardPersonnummer)
}
}
+13
View File
@@ -133,6 +133,19 @@ export const RUT_WORK_TYPES = [
{ code: 'TVATT', label: 'Tvätt vid tvättinrättning (schablon)' },
] as const
/**
* Which deduction kind a Skatteverket work-type code belongs to. The two code
* lists are disjoint, so the code alone decides ROT vs RUT: this is what lets
* an article's housework_type pre-fill both the invoice line's work_type and
* its deduction_type. Unknown or absent codes map to null (no deduction).
*/
export function deductionTypeForWorkType(code: string | null | undefined): DeductionType | null {
if (!code) return null
if (ROT_WORK_TYPES.some((w) => w.code === code)) return 'rot'
if (RUT_WORK_TYPES.some((w) => w.code === code)) return 'rut'
return null
}
export interface ItemForDeduction {
/** Unit price (per `quantity`). Same field as invoice_items.unit_price. */
unit_price: number
+45
View File
@@ -9,6 +9,7 @@ import {
calculateAgeAtYearStart,
maskPersonnummer,
formatPersonnummer,
expandPersonnummerTo12,
encryptPersonnummer,
decryptPersonnummer,
} from '../personnummer'
@@ -319,3 +320,47 @@ describe('decryptPersonnummer tolerance for unencrypted rows', () => {
expect(decryptPersonnummer(enc)).toBe('199001019802')
})
})
describe('expandPersonnummerTo12', () => {
// Fixed clock (2026-08-17) so century inference is deterministic.
const now = new Date(2026, 7, 17)
it('passes a 12-digit value through with the separator stripped', () => {
expect(expandPersonnummerTo12('19900101-9802', now)).toBe('199001019802')
expect(expandPersonnummerTo12('199001019802', now)).toBe('199001019802')
})
it('expands a 10-digit value to the most recent past century', () => {
expect(expandPersonnummerTo12('900101-9802', now)).toBe('199001019802')
expect(expandPersonnummerTo12('9001019802', now)).toBe('199001019802')
})
it('keeps a birth date earlier this century in the 2000s', () => {
expect(expandPersonnummerTo12('250101-0025', now)).toBe('202501010025')
})
it('treats a birth date equal to today as this century', () => {
expect(expandPersonnummerTo12('260817-0000', now)).toBe('202608170000')
})
it('rolls a not-yet-reached date this century back to the 1900s', () => {
expect(expandPersonnummerTo12('261231-0000', now)).toBe('192612310000')
})
it('subtracts a further century for the over-100 plus separator', () => {
expect(expandPersonnummerTo12('900101+9802', now)).toBe('189001019802')
})
it('strips the samordningsnummer day offset for the comparison only', () => {
// Day 77 = real day 17 (today): this century. Day 78 = real day 18
// (tomorrow): last century. The returned digits keep the printed day.
expect(expandPersonnummerTo12('260877-0000', now)).toBe('202608770000')
expect(expandPersonnummerTo12('260878-0000', now)).toBe('192608780000')
})
it('returns null for shapes that are neither 10 nor 12 digits', () => {
expect(expandPersonnummerTo12('', now)).toBeNull()
expect(expandPersonnummerTo12('123', now)).toBeNull()
expect(expandPersonnummerTo12('********-9802', now)).toBeNull()
})
})
+33
View File
@@ -216,6 +216,39 @@ export function formatPersonnummer(personnummer: string): string {
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
+1
View File
@@ -3556,6 +3556,7 @@
"deduction_personnummer_placeholder": "YYYYMMDD-NNNN",
"deduction_personnummer_hint": "Encrypted before storage. Only the last four digits are shown on the invoice.",
"deduction_personnummer_kept_hint": "The saved personal number (****{last4}) is kept if the field is left empty. Enter one only to replace it.",
"deduction_personnummer_customer_hint": "Taken from the customer card if left empty. Encrypted before storage; only the last four digits are shown on the invoice.",
"deduction_housing_label": "Property designation (fastighetsbeteckning)",
"deduction_housing_placeholder": "e.g. Stockholm Vasastan 1:23",
"deduction_housing_hint": "Required for ROT deductions (not needed for RUT).",
+1
View File
@@ -3556,6 +3556,7 @@
"deduction_personnummer_placeholder": "ÅÅÅÅMMDD-NNNN",
"deduction_personnummer_hint": "Krypteras innan lagring. Endast de fyra sista siffrorna visas på fakturan.",
"deduction_personnummer_kept_hint": "Sparat personnummer (****{last4}) behålls om fältet lämnas tomt. Fyll i endast för att byta.",
"deduction_personnummer_customer_hint": "Hämtas från kundkortet om fältet lämnas tomt. Krypteras innan lagring; endast de fyra sista siffrorna visas på fakturan.",
"deduction_housing_label": "Fastighetsbeteckning",
"deduction_housing_placeholder": "t.ex. Stockholm Vasastan 1:23",
"deduction_housing_hint": "Krävs för ROT-avdrag (RUT behöver inte detta fält).",