diff --git a/DECISIONS.md b/DECISIONS.md index a44694c6..cbb7e9f0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1023,3 +1023,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-16] Row exit animation for dry-table 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. diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index 0dec30da..eff3dce3 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -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

{/* 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')}

{hasAnyRotLine && ( diff --git a/lib/invoices/__tests__/build-invoice-write.test.ts b/lib/invoices/__tests__/build-invoice-write.test.ts index 21582d1b..39057278 100644 --- a/lib/invoices/__tests__/build-invoice-write.test.ts +++ b/lib/invoices/__tests__/build-invoice-write.test.ts @@ -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') + }) +}) diff --git a/lib/invoices/__tests__/rot-rut-rules.test.ts b/lib/invoices/__tests__/rot-rut-rules.test.ts index 43838099..cbf30e8c 100644 --- a/lib/invoices/__tests__/rot-rut-rules.test.ts +++ b/lib/invoices/__tests__/rot-rut-rules.test.ts @@ -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() + }) +}) diff --git a/lib/invoices/build-invoice-write.ts b/lib/invoices/build-invoice-write.ts index 6937a31d..e60169b1 100644 --- a/lib/invoices/build-invoice-write.ts +++ b/lib/invoices/build-invoice-write.ts @@ -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) } } diff --git a/lib/invoices/rot-rut-rules.ts b/lib/invoices/rot-rut-rules.ts index 6e7d8a71..02c657bb 100644 --- a/lib/invoices/rot-rut-rules.ts +++ b/lib/invoices/rot-rut-rules.ts @@ -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 diff --git a/lib/salary/__tests__/personnummer.test.ts b/lib/salary/__tests__/personnummer.test.ts index d86ca61e..4e094a66 100644 --- a/lib/salary/__tests__/personnummer.test.ts +++ b/lib/salary/__tests__/personnummer.test.ts @@ -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() + }) +}) diff --git a/lib/salary/personnummer.ts b/lib/salary/personnummer.ts index 44961575..5ec0bca4 100644 --- a/lib/salary/personnummer.ts +++ b/lib/salary/personnummer.ts @@ -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 diff --git a/messages/en.json b/messages/en.json index 479186ce..447f84d8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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).", diff --git a/messages/sv.json b/messages/sv.json index b7308c83..28f1e6e2 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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).",