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
+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 && (