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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
44c3116357
commit
62c6fc44fe
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
Reference in New Issue
Block a user