fix(articles): article ROT/RUT prefill was dead for every dashboard-created article (#1651)

* fix(articles): article ROT/RUT prefill was dead for every dashboard-created article

Follow-up to #1634. The user re-tested and picking a RUT article still left
the line on "Ingen": the article form has always stored the bare kind
('ROT'/'RUT'), while the prefill only recognised Skatteverket work-type codes
(BYGG, STAD, ...). On prod every dashboard-created ROT/RUT article holds the
bare kind, so the fix in #1634 never fired for a real user, and worse, since
the helper returned null for those values, picking such an article CLEARED a
deduction the user had set manually on the row.

- rot-rut-rules: parseArticleHouseworkType() understands both vocabularies
  (code -> kind + arbetstyp; bare ROT/RUT -> kind only), plus
  normalizeHouseworkType()/HOUSEWORK_TYPE_VALUES/workTypeLabel().
- InvoiceEditor.applyArticle: kind-only articles pre-fill the deduction and
  keep a same-kind arbetstyp already chosen on the row; "Spara som artikel"
  round-trips the code or, lacking one, the kind.
- ArticleForm: the ROT/RUT select now offers the real Skatteverket arbetstyper
  in ROT/RUT groups (its own hint always promised "förifyller arbetstyp");
  legacy kind-only values stay selectable as "RUT (arbetstyp ej vald)" so an
  edit never silently drops the flag. Article detail renders "RUT · Städning"
  instead of the raw code.
- API + MCP commit schemas normalize housework_type (case-insensitive code or
  ROT/RUT, '' clears) and reject anything else; the CSV article import
  normalizes the column the same way. Prod holds 178 articles with '0'/'1'
  from a boolean "Rot" column that the keyword detector mapped straight
  through; those now read as no flag everywhere and can no longer be created.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(articles): review follow-ups on #1651

- InvoiceEditor: switching a row's skattereduktion ROT<->RUT clears an
  arbetstyp from the other list, and Spara som artikel only round-trips a
  work type that belongs to the row's kind (CodeRabbit).
- MCP update_article: null / '' / whitespace now clear housework_type
  (commit drops only undefined keys, so the old undefined mapping made the
  flag un-clearable); create keeps treating them as unset. Tests.
- Article CSV import warns when a non-empty ROT/RUT value is dropped as
  not-an-arbetstyp instead of dropping it silently. Test.
- Hint wording: arbetstyp is pre-filled only when the article carries one.

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 19:47:29 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 1cd33d9d97
commit 79240cb2ed
15 changed files with 359 additions and 25 deletions
+1
View File
@@ -1040,3 +1040,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-17] Supplier standardkonto empty-string fix lives in the API schemas, split by verb: '' normalizes to undefined on create (key dropped, column NULL) but to null on update, because update routes pass validated fields straight into .update() where undefined means "leave unchanged"; without the null mapping a cleared standardkonto/e-post would silently never clear. Client keeps sending '' as-is (the old email-strip hack removed), since stripping client-side would break exactly that clear path. The field itself became an AccountCombobox filtered to cost classes 4-7 (matches the agent-path expenseAccountField rule); other 4-digit numbers stay typeable, and the API still enforces format only. Standardkonto stays optional: it only prefills supplier-invoice lines, and the ledger-context suggestion covers the empty case, so requiring it (what the bug accidentally did) is wrong for the target user.
[2026-08-17] Replay-masking skeptic round (PR #1639): explicit data-ph tags now resolve BEFORE the th chrome fallback in replayMaskText (a single closest over tags-plus-th let a th nested in a masked container win on DOM proximity, CodeRabbit); seven missed text-leak sites got call-site masks (delete-invoice number, credit-page number, IB voucher ref, TIC orgnr since TIC serves it unnormalized so the separator scrub cannot be relied on, articles search term, dimension segment labels, activate-account buttons); the attribute channel (placeholders prefilled with effective values, title tooltips) is handled with rrweb's blockClass: user-data placeholders carry ph-no-capture, which removes the element from the recording while app UX keeps the founder-approved prefill-override pattern intact. Chose ph-no-capture over stripping the placeholders because the prefilled effective value IS the UX.
[2026-08-17] Skattekontoutdrag file import (Sebastian's request) writes into skattekonto_transactions, not into transactions as a pseudo-bank with 1630 unlocked in BankFileConfirmStep: rows inherit the skattekonto_rules 1630 booking engine, matching, drift and both UIs for free, while the literal ask would bypass the rules and double against the SKV inbox for connected companies. Dedup pairs file hash-keys with API id-keys by CONTENT in both directions (import-time skip/promote against existing rows, sync-time takeover that rewrites an imported row's key in place so journal links survive connecting the API later). Import is free for everyone per the requireSkvCapability doctrine (manual paths never blocked); only sync/saldo stay capability-gated. The parse route hard-rejects files that fail detectSkattekontoFile and statements whose opening+sum!=closing, and warns on orgnr mismatch against company_settings: wrong-company imports are a known support-incident class.
[2026-08-17] articles.housework_type keeps two vocabularies (Skatteverket arbetstypskod, or bare ROT/RUT) instead of migrating legacy ROT/RUT rows: a kind-only row cannot be upgraded to a code without knowing the work, so the article form preserves the legacy choice as an explicit option and the invoice prefill treats it as kind-only; everything else normalizes to null and is rejected at the API.
+12 -2
View File
@@ -20,6 +20,7 @@ import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui
import { ArrowLeft, Loader2, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency } from '@/lib/utils'
import { parseArticleHouseworkType, workTypeLabel } from '@/lib/invoices/rot-rut-rules'
import type { Article, ArticleType, CreateArticleInput } from '@/types'
const ARTICLE_TYPE_KEY: Record<ArticleType, string> = {
@@ -206,6 +207,15 @@ export default function ArticleDetailPage({
if (!article) return null
// "RUT · Städning" for a work-type code, "RUT" for a legacy kind-only row,
// nothing for values that are not a housework flag (mis-mapped imports).
const houseworkDisplay = (() => {
const { deductionType, workType } = parseArticleHouseworkType(article.housework_type)
if (!deductionType) return null
const label = workTypeLabel(workType)
return label ? `${deductionType.toUpperCase()} · ${label}` : deductionType.toUpperCase()
})()
return (
<div className="max-w-2xl space-y-8 stagger-enter">
{/* Header: serif name over a quiet type/status kicker, quiet actions right */}
@@ -307,8 +317,8 @@ export default function ArticleDetailPage({
<span className="text-muted-foreground">{t('revenue_account_auto')}</span>
)}
</DefRow>
{article.type === 'tjanst' && article.housework_type && (
<DefRow label={t('label_housework')}>{article.housework_type}</DefRow>
{article.type === 'tjanst' && houseworkDisplay && (
<DefRow label={t('label_housework')}>{houseworkDisplay}</DefRow>
)}
</DetailSection>
+38 -3
View File
@@ -23,6 +23,11 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
import type { BASAccount, CreateArticleInput } from '@/types'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import {
ROT_WORK_TYPES,
RUT_WORK_TYPES,
normalizeHouseworkType,
} from '@/lib/invoices/rot-rut-rules'
// A row from the currencies reference table (lib migration
// 20260630110000_currencies_reference_table.sql).
@@ -202,11 +207,22 @@ export default function ArticleForm({
revenue_account: initialData?.revenue_account || '',
cost_price: initialData?.cost_price ?? undefined,
ean: initialData?.ean || '',
housework_type: initialData?.housework_type || '',
// Canonical form (work-type code, bare ROT/RUT, or ''): a stray value
// from an import must not sit invisibly in the select as "Ingen".
housework_type: normalizeHouseworkType(initialData?.housework_type) ?? '',
notes: initialData?.notes || '',
},
})
// Legacy articles carry only the kind ('ROT'/'RUT'), stored before this
// form offered Skatteverket work types. Keep that choice selectable so an
// edit never silently drops the flag; the user upgrades it to a real
// arbetstyp when they want the invoice row's work type pre-filled too.
const legacyHouseworkKind = (() => {
const v = normalizeHouseworkType(initialData?.housework_type)
return v === 'ROT' || v === 'RUT' ? v : null
})()
const type = watch('type')
const watchedName = watch('name')
const watchedUnit = watch('unit')
@@ -454,8 +470,27 @@ export default function ArticleForm({
onChange={(e) => field.onChange(e.target.value)}
>
<option value="">{t('housework_none')}</option>
<option value="ROT">{t('housework_rot')}</option>
<option value="RUT">{t('housework_rut')}</option>
{legacyHouseworkKind && (
<option value={legacyHouseworkKind}>
{legacyHouseworkKind === 'ROT'
? t('housework_legacy_rot')
: t('housework_legacy_rut')}
</option>
)}
<optgroup label={t('housework_rot')}>
{ROT_WORK_TYPES.map((w) => (
<option key={w.code} value={w.code}>
{w.label}
</option>
))}
</optgroup>
<optgroup label={t('housework_rut')}>
{RUT_WORK_TYPES.map((w) => (
<option key={w.code} value={w.code}>
{w.label}
</option>
))}
</optgroup>
</SettingsSelect>
)}
/>
+38 -12
View File
@@ -64,6 +64,7 @@ import {
RUT_MAX,
computeDeduction,
deductionTypeForWorkType,
parseArticleHouseworkType,
} from '@/lib/invoices/rot-rut-rules'
import { UNDECRYPTABLE_PERSONAL_NUMBER_MASK } from '@/lib/customers/mask-personal-number'
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
@@ -568,17 +569,27 @@ 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.
// ROT/RUT: the article's housework_type decides the line's deduction kind
// and, when it is a Skatteverket arbetstypskod, its work type too. Legacy
// articles carry only the kind (`ROT`/`RUT`): those pre-fill the deduction
// and keep a same-kind arbetstyp already chosen on the row. An article
// WITHOUT any housework flag 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)
const { deductionType: kind, workType } = parseArticleHouseworkType(a.housework_type)
const currentWorkType = getValues(`items.${index}.work_type`) ?? null
const keepCurrentWorkType =
kind != null && !workType && deductionTypeForWorkType(currentWorkType) === kind
setValue(`items.${index}.deduction_type`, kind, { shouldDirty: true })
setValue(`items.${index}.work_type`, kind ? a.housework_type : null, { shouldDirty: true })
setValue(
`items.${index}.work_type`,
workType ?? (keepCurrentWorkType ? currentWorkType : null),
{ shouldDirty: true },
)
if (kind) {
// Same rule as the manual ⋮ menu: ROT/RUT och periodisering
// kombineras aldrig på samma rad; avdraget vinner.
@@ -637,9 +648,14 @@ 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,
// Round-trip the ROT/RUT flag so the saved article pre-fills the
// deduction the next time it is picked: the arbetstypskod when the
// row has one, otherwise the bare kind.
housework_type: item.deduction_type
? deductionTypeForWorkType(item.work_type) === item.deduction_type
? item.work_type
: item.deduction_type.toUpperCase()
: null,
}),
})
const result = await response.json()
@@ -1700,6 +1716,16 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
onValueChange={(v) => {
const next = v === 'none' ? null : (v as 'rot' | 'rut')
setValue(`items.${index}.deduction_type`, next, { shouldDirty: true })
// The arbetstyp lists are per kind: a ROT code
// must not survive a switch to RUT (the select
// would show it as empty while the payload kept
// the wrong code).
if (
next !== null &&
deductionTypeForWorkType(watchItems[index]?.work_type) !== next
) {
setValue(`items.${index}.work_type`, null)
}
if (next === null) {
setValue(`items.${index}.work_type`, null)
setValue(`items.${index}.labor_hours`, null)
+2 -2
View File
@@ -4812,7 +4812,7 @@ export const tools: McpTool[] = [
revenue_account: { type: 'string', description: 'Optional BAS class-3 revenue account (e.g. 3041). Omit to derive from VAT.' },
cost_price: { type: 'number', description: 'Optional cost price (margin only; never booked).' },
ean: { type: 'string', description: 'Barcode / EAN.' },
housework_type: { type: 'string', description: 'ROT/RUT arbetstyp (services only).' },
housework_type: { type: 'string', description: 'ROT/RUT flag for service articles: a Skatteverket arbetstypskod (ROT: BYGG, EL, GLAS_PLAT, MARK_DRAN, MURNING, MALNING, VVS; RUT: STAD, KLAD, SNOSKOTTNING, TRADGARD, BARNPASS, PERSONLIG_OMS, FLYTT, IT, REPARATION, MOBLERING, TILLSYN, TRANSPORT, TVATT) or the bare kind ROT / RUT. Picking the article on an invoice line pre-fills the skattereduktion (and the arbetstyp when a code is given). Any other value is rejected.' },
name_en: { type: 'string', description: 'English name for English-language invoices.' },
notes: { type: 'string' },
article_number: { type: 'string', description: 'Optional manual number; omit to auto-generate.' },
@@ -4886,7 +4886,7 @@ export const tools: McpTool[] = [
revenue_account: { type: 'string', description: 'BAS class-3 revenue account, or omit to leave unchanged.' },
cost_price: { type: 'number' },
ean: { type: 'string' },
housework_type: { type: 'string' },
housework_type: { type: 'string', description: 'Skatteverket arbetstypskod (e.g. BYGG, STAD) or bare ROT / RUT; empty string clears. See gnubok_create_article.' },
name_en: { type: 'string' },
notes: { type: 'string' },
active: { type: 'boolean', description: 'Set false to deactivate (hide from pickers, keep history).' },
+26
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest'
import { HouseworkTypeSchema } from '../schemas'
import {
// Enums
EntityTypeSchema,
@@ -2871,3 +2872,28 @@ describe('CreateSalaryLineItemSchema: derived-only item types', () => {
expect(UpdateSalaryLineItemSchema.safeParse({ item_type: 'bonus' }).success).toBe(true)
})
})
describe('HouseworkTypeSchema (articles.housework_type)', () => {
it('stores a Skatteverket work-type code upper-cased', () => {
expect(HouseworkTypeSchema.parse('stad')).toBe('STAD')
expect(HouseworkTypeSchema.parse('BYGG')).toBe('BYGG')
})
it('accepts the bare kind ROT / RUT (deduction only, no arbetstyp pre-fill)', () => {
expect(HouseworkTypeSchema.parse('rut')).toBe('RUT')
expect(HouseworkTypeSchema.parse('ROT')).toBe('ROT')
})
it('treats empty string as clear and passes null/undefined through', () => {
expect(HouseworkTypeSchema.parse('')).toBeNull()
expect(HouseworkTypeSchema.parse(' ')).toBeNull()
expect(HouseworkTypeSchema.parse(null)).toBeNull()
expect(HouseworkTypeSchema.parse(undefined)).toBeUndefined()
})
it('rejects values the invoice editor could never interpret', () => {
for (const bad of ['1', '0', 'Ja', 'SNICKERI', 'RUT-städning']) {
expect(HouseworkTypeSchema.safeParse(bad).success).toBe(false)
}
})
})
+28 -1
View File
@@ -13,6 +13,7 @@ import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
import { MAX_INVOICE_EMAIL_COPY_RECIPIENTS } from '@/lib/invoices/email-recipients'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import { HOUSEWORK_TYPE_VALUES, normalizeHouseworkType } from '@/lib/invoices/rot-rut-rules'
import { PERSONAL_NUMBER_INPUT_RE } from '@/lib/customers/mask-personal-number'
import type { AuditAction } from '@/types'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
@@ -637,6 +638,32 @@ export const RotRutBeslutFileSchema = z.object({
export const ArticleTypeSchema = z.enum(['vara', 'tjanst'])
/**
* articles.housework_type: a Skatteverket arbetstypskod (BYGG, EL, ..., STAD,
* TRADGARD, ...) or the bare kind ROT / RUT (deduction only, no arbetstyp
* pre-fill). Case-insensitive, stored upper-case; '' clears to null. The
* invoice editor derives a line's skattereduktion from this value, so any
* other string is a silently dead flag and is rejected here.
*/
export const HouseworkTypeSchema = z
.string()
.max(64)
.nullable()
.optional()
.transform((v, ctx) => {
if (v == null) return v
if (v.trim() === '') return null
const normalized = normalizeHouseworkType(v)
if (!normalized) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Ogiltig ROT/RUT-arbetstyp. Tillåtna värden: ${HOUSEWORK_TYPE_VALUES.join(', ')}`,
})
return z.NEVER
}
return normalized
})
export const CreateArticleSchema = z.object({
name: z.string().min(1, 'Article name is required').max(200),
type: ArticleTypeSchema.optional(),
@@ -655,7 +682,7 @@ export const CreateArticleSchema = z.object({
cost_price: nonNegativeAmount.nullable().optional(),
ean: z.string().max(32).nullable().optional(),
// ROT/RUT arbetstyp; only meaningful for type === 'tjanst'.
housework_type: z.string().max(64).nullable().optional(),
housework_type: HouseworkTypeSchema,
name_en: z.string().max(200).nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
// Manual article number; omit to auto-generate via generate_article_number.
@@ -239,3 +239,28 @@ describe('parseArticlesFile currency (Valuta) column', () => {
expect(result.rows[0].vat_rate).toBe(25)
})
})
describe('parseArticlesFile ROT/RUT (housework) column', () => {
it('normalizes work-type codes and bare kinds, and drops boolean-style values', () => {
const buffer = buildXlsx([
['Benämning', 'Pris', 'Husarbete'],
['Städning', '450', 'stad'],
['Fönsterputs', '500', 'RUT'],
// A boolean "Rot"/"Husarbete" column exported by other systems: '1'/'0'
// are not values the invoice editor can interpret, so they must not be
// stored (this is what produced 178 junk rows on prod).
['Bygg', '600', '1'],
['Skruv', '2', '0'],
])
const result = parseArticlesFile(buffer, 'husarbete.xlsx')
expect(result.detected_columns.housework_type_col).toBe(2)
expect(result.rows[0].housework_type).toBe('STAD')
expect(result.rows[1].housework_type).toBe('RUT')
expect(result.rows[2].housework_type).toBeNull()
expect(result.rows[3].housework_type).toBeNull()
// Dropping a non-empty value is surfaced, never silent.
expect(result.warnings.some((w) => w.includes('2 rader hade ett ROT/RUT-värde'))).toBe(true)
})
})
+11 -1
View File
@@ -4,6 +4,7 @@ import { cellOrNull } from '../shared/column-utils'
import { parseAmount } from '../opening-balance/parser'
import { readBestSheet } from '../shared/workbook-reader'
import type { DetectedArticleColumns, ParsedArticleRow } from './types'
import { normalizeHouseworkType } from '@/lib/invoices/rot-rut-rules'
const VALID_VAT_RATES = [0, 6, 12, 25] as const
@@ -141,6 +142,7 @@ export function parseArticlesFile(
let vatNoteCount = 0
let droppedAccountCount = 0
let droppedCurrencyCount = 0
let droppedHouseworkCount = 0
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i]
@@ -183,7 +185,12 @@ export function parseArticlesFile(
const costPrice = costRaw !== null ? parseAmount(costRaw) : null
const ean = cell(row, columns.ean_col)
const houseworkType = cell(row, columns.housework_type_col)
// Canonical work-type code / bare ROT-RUT, or null: a boolean "Rot" column
// ('0'/'1'/'Ja') mapped by the keyword detector must not land as a value
// the invoice editor can never interpret.
const houseworkRaw = cell(row, columns.housework_type_col)
const houseworkType = normalizeHouseworkType(houseworkRaw)
if (houseworkRaw !== null && houseworkType === null) droppedHouseworkCount++
const notes = cell(row, columns.notes_col)
const validationErrors: string[] = []
@@ -218,6 +225,9 @@ export function parseArticlesFile(
if (droppedAccountCount > 0) {
warnings.push(`${droppedAccountCount} rad${droppedAccountCount === 1 ? '' : 'er'} hade ett ogiltigt bokföringskonto (måste vara klass 1-3) som ignorerades.`)
}
if (droppedHouseworkCount > 0) {
warnings.push(`${droppedHouseworkCount} rad${droppedHouseworkCount === 1 ? '' : 'er'} hade ett ROT/RUT-värde som inte är en arbetstyp (t.ex. 0/1/Ja) och som ignorerades: sätt arbetstyp på artikeln efteråt.`)
}
if (droppedCurrencyCount > 0) {
warnings.push(`${droppedCurrencyCount} rad${droppedCurrencyCount === 1 ? '' : 'er'} hade en ogiltig valutakod (måste vara tre bokstäver, t.ex. EUR) som ignorerades: priset importeras som SEK.`)
}
@@ -11,6 +11,12 @@ import {
deductionSekConverter,
deductionToSek,
deductionTypeForWorkType,
parseArticleHouseworkType,
normalizeHouseworkType,
workTypeLabel,
HOUSEWORK_TYPE_VALUES,
ROT_WORK_TYPES,
RUT_WORK_TYPES,
type ItemForDeduction,
type ValidateInvoiceItem,
} from '../rot-rut-rules'
@@ -380,3 +386,50 @@ describe('deductionTypeForWorkType', () => {
expect(deductionTypeForWorkType('SNICKERI')).toBeNull()
})
})
describe('parseArticleHouseworkType (articles.housework_type vocabularies)', () => {
it('a Skatteverket code decides both kind and work type', () => {
expect(parseArticleHouseworkType('STAD')).toEqual({ deductionType: 'rut', workType: 'STAD' })
expect(parseArticleHouseworkType('BYGG')).toEqual({ deductionType: 'rot', workType: 'BYGG' })
// Case-insensitive: the API stores upper-case, but older writers did not.
expect(parseArticleHouseworkType(' malning ')).toEqual({ deductionType: 'rot', workType: 'MALNING' })
})
it('the bare kind (what the article form stored before it offered work types) decides the kind only', () => {
// This is the exact prod value behind the 2026-08-17 report: picking a
// "RUT" article pre-filled nothing because only codes were recognised.
expect(parseArticleHouseworkType('RUT')).toEqual({ deductionType: 'rut', workType: null })
expect(parseArticleHouseworkType('rot')).toEqual({ deductionType: 'rot', workType: null })
})
it('anything else is not a housework flag', () => {
// '0'/'1' are what a boolean "Rot" CSV column produced on prod.
for (const v of ['0', '1', 'Ja', 'SNICKERI', '', null, undefined]) {
expect(parseArticleHouseworkType(v)).toEqual({ deductionType: null, workType: null })
}
})
})
describe('normalizeHouseworkType / HOUSEWORK_TYPE_VALUES', () => {
it('canonicalises to the code, the bare kind, or null', () => {
expect(normalizeHouseworkType('stad')).toBe('STAD')
expect(normalizeHouseworkType('Rut')).toBe('RUT')
expect(normalizeHouseworkType('1')).toBeNull()
expect(normalizeHouseworkType('')).toBeNull()
expect(normalizeHouseworkType(null)).toBeNull()
})
it('accepts exactly the two kinds plus every code in both lists', () => {
expect(HOUSEWORK_TYPE_VALUES).toHaveLength(2 + ROT_WORK_TYPES.length + RUT_WORK_TYPES.length)
for (const v of HOUSEWORK_TYPE_VALUES) expect(normalizeHouseworkType(v)).toBe(v)
})
})
describe('workTypeLabel', () => {
it('returns the Skatteverket label for known codes and null otherwise', () => {
expect(workTypeLabel('STAD')).toBe('Städning')
expect(workTypeLabel('VVS')).toBe('VVS-arbete')
expect(workTypeLabel('RUT')).toBeNull()
expect(workTypeLabel(null)).toBeNull()
})
})
+51
View File
@@ -146,6 +146,57 @@ export function deductionTypeForWorkType(code: string | null | undefined): Deduc
return null
}
/** Human label for a Skatteverket work-type code, or null for unknown codes. */
export function workTypeLabel(code: string | null | undefined): string | null {
if (!code) return null
const hit = [...ROT_WORK_TYPES, ...RUT_WORK_TYPES].find((w) => w.code === code)
return hit ? hit.label : null
}
/**
* The two vocabularies `articles.housework_type` has been written in:
* - a Skatteverket work-type code (`BYGG`, `STAD`, ...): the intended value,
* decides both the deduction kind and the line's arbetstyp;
* - the bare kind `ROT` / `RUT`: what the article form stored before it
* offered real work types (legacy rows), decides the kind only.
* Anything else (free text, `0`/`1` from a mis-mapped CSV column) is not a
* housework flag at all and normalizes to null.
*/
export interface ArticleHousework {
deductionType: DeductionType | null
/** Skatteverket work-type code, or null when only the kind is known. */
workType: string | null
}
export function parseArticleHouseworkType(value: string | null | undefined): ArticleHousework {
const raw = value?.trim().toUpperCase() ?? ''
if (!raw) return { deductionType: null, workType: null }
const kindFromCode = deductionTypeForWorkType(raw)
if (kindFromCode) return { deductionType: kindFromCode, workType: raw }
if (raw === 'ROT' || raw === 'RUT') return { deductionType: raw.toLowerCase() as DeductionType, workType: null }
return { deductionType: null, workType: null }
}
/**
* Canonical stored form of a housework_type input: the work-type code, the
* bare kind (`ROT`/`RUT`), or null. Case-insensitive; unknown values are
* null so the column never accumulates a third vocabulary again.
*/
export function normalizeHouseworkType(value: string | null | undefined): string | null {
const parsed = parseArticleHouseworkType(value)
if (parsed.workType) return parsed.workType
if (parsed.deductionType) return parsed.deductionType.toUpperCase()
return null
}
/** Accepted housework_type values: every work-type code plus the bare kinds. */
export const HOUSEWORK_TYPE_VALUES: readonly string[] = [
'ROT',
'RUT',
...ROT_WORK_TYPES.map((w) => w.code),
...RUT_WORK_TYPES.map((w) => w.code),
]
export interface ItemForDeduction {
/** Unit price (per `quantity`). Same field as invoice_items.unit_price. */
unit_price: number
@@ -34,3 +34,43 @@ describe('UpdateArticleParamsSchema currency', () => {
expect(parsed.currency).toBeUndefined()
})
})
describe('CreateArticleParamsSchema housework_type', () => {
it('normalizes a work-type code or bare kind to upper case', () => {
expect(CreateArticleParamsSchema.parse({ ...base, housework_type: 'stad' }).housework_type).toBe('STAD')
expect(CreateArticleParamsSchema.parse({ ...base, housework_type: 'rut' }).housework_type).toBe('RUT')
})
it('treats empty, whitespace and null as unset', () => {
expect(CreateArticleParamsSchema.parse({ ...base, housework_type: '' }).housework_type).toBeUndefined()
expect(CreateArticleParamsSchema.parse({ ...base, housework_type: ' ' }).housework_type).toBeUndefined()
expect(CreateArticleParamsSchema.parse({ ...base, housework_type: null }).housework_type).toBeUndefined()
})
it('rejects free text: the flag would otherwise be silently dead on invoice lines', () => {
expect(() => CreateArticleParamsSchema.parse({ ...base, housework_type: 'Snickeri' })).toThrow()
expect(() => CreateArticleParamsSchema.parse({ ...base, housework_type: '1' })).toThrow()
})
})
describe('UpdateArticleParamsSchema housework_type', () => {
const id = { article_id: '3a9ac4d2-163a-4d43-8fa3-1b32827505fa' }
it('accepts a housework_type-only update and normalizes it', () => {
expect(UpdateArticleParamsSchema.parse({ ...id, housework_type: 'malning' }).housework_type).toBe('MALNING')
})
it('leaves housework_type undefined when omitted (sparse update)', () => {
expect(UpdateArticleParamsSchema.parse({ ...id, name: 'Nytt namn' }).housework_type).toBeUndefined()
})
it('null, empty and whitespace-only clear the flag (commit drops only undefined keys)', () => {
expect(UpdateArticleParamsSchema.parse({ ...id, housework_type: null }).housework_type).toBeNull()
expect(UpdateArticleParamsSchema.parse({ ...id, housework_type: '' }).housework_type).toBeNull()
expect(UpdateArticleParamsSchema.parse({ ...id, housework_type: ' ' }).housework_type).toBeNull()
})
it('rejects free text on update too', () => {
expect(() => UpdateArticleParamsSchema.parse({ ...id, housework_type: 'Snickeri' })).toThrow()
})
})
+28 -2
View File
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import { HOUSEWORK_TYPE_VALUES, normalizeHouseworkType } from '@/lib/invoices/rot-rut-rules'
// Commit-boundary re-validation for staged article operations. A staged
// pending_operations row is re-parsed here before it touches the articles table
@@ -16,6 +17,31 @@ const vatRatePercent = z.union([z.literal(0), z.literal(6), z.literal(12), z.lit
const optString = (max: number) =>
z.preprocess((v) => (v == null || v === '' ? undefined : v), z.string().max(max).optional())
// Same vocabulary as HouseworkTypeSchema in lib/api/schemas.ts: work-type code
// or bare ROT/RUT, upper-cased; empty → undefined; anything else rejected.
const houseworkCode = z.string().max(64).transform((v, ctx) => {
const normalized = normalizeHouseworkType(v)
if (!normalized) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid housework_type. Allowed: ${HOUSEWORK_TYPE_VALUES.join(', ')}`,
})
return z.NEVER
}
return normalized
})
// Create: empty / null / omitted all mean "no flag" (column stays NULL).
const houseworkTypeCreate = z.preprocess(
(v) => (v == null || (typeof v === 'string' && v.trim() === '') ? undefined : v),
houseworkCode.optional(),
)
// Update: commitUpdateArticle drops undefined keys, so a clear must arrive as
// null. Omitted stays undefined (untouched); null / '' / whitespace clear.
const houseworkTypeUpdate = z.preprocess(
(v) => (v == null || (typeof v === 'string' && v.trim() === '') ? (v === undefined ? undefined : null) : v),
houseworkCode.nullable().optional(),
)
const trimmedName = z.preprocess(
(v) => (typeof v === 'string' ? v.trim() : v),
z.string().min(1, 'Article name is required').max(200),
@@ -39,7 +65,7 @@ export const CreateArticleParamsSchema = z.object({
revenue_account: invoicePostingAccount.nullable().optional(),
cost_price: z.number().nonnegative().nullable().optional(),
ean: optString(32),
housework_type: optString(64),
housework_type: houseworkTypeCreate,
name_en: optString(200),
notes: optString(2000),
article_number: optString(64),
@@ -56,7 +82,7 @@ export const UpdateArticleParamsSchema = z.object({
revenue_account: invoicePostingAccount.nullable().optional(),
cost_price: z.number().nonnegative().nullable().optional(),
ean: optString(32),
housework_type: optString(64),
housework_type: houseworkTypeUpdate,
name_en: optString(200),
notes: optString(2000),
article_number: optString(64),
+3 -1
View File
@@ -5702,7 +5702,9 @@
"housework_none": "None",
"housework_rot": "ROT",
"housework_rut": "RUT",
"housework_hint": "Pre-fills the work type on the invoice row for housework.",
"housework_hint": "Pre-fills the tax reduction on the invoice row when the article is picked, and the work type when one is chosen here.",
"housework_legacy_rot": "ROT (work type not chosen)",
"housework_legacy_rut": "RUT (work type not chosen)",
"notes_label": "Notes",
"notes_placeholder": "Internal notes about the article...",
"submit_save": "Save article",
+3 -1
View File
@@ -5702,7 +5702,9 @@
"housework_none": "Ingen",
"housework_rot": "ROT",
"housework_rut": "RUT",
"housework_hint": "Förifyller arbetstyp på fakturaraden för husarbete.",
"housework_hint": "Förifyller skattereduktionen på fakturaraden när artikeln väljs, och arbetstypen när en sådan är vald här.",
"housework_legacy_rot": "ROT (arbetstyp ej vald)",
"housework_legacy_rut": "RUT (arbetstyp ej vald)",
"notes_label": "Anteckningar",
"notes_placeholder": "Interna anteckningar om artikeln...",
"submit_save": "Spara artikel",