diff --git a/DECISIONS.md b/DECISIONS.md index 2dfc964d..22a1716e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1040,3 +1040,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/articles/[id]/page.tsx b/app/(dashboard)/articles/[id]/page.tsx index c7d45eac..b2eef731 100644 --- a/app/(dashboard)/articles/[id]/page.tsx +++ b/app/(dashboard)/articles/[id]/page.tsx @@ -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 = { @@ -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 (
{/* Header: serif name over a quiet type/status kicker, quiet actions right */} @@ -307,8 +317,8 @@ export default function ArticleDetailPage({ {t('revenue_account_auto')} )} - {article.type === 'tjanst' && article.housework_type && ( - {article.housework_type} + {article.type === 'tjanst' && houseworkDisplay && ( + {houseworkDisplay} )} diff --git a/components/articles/ArticleForm.tsx b/components/articles/ArticleForm.tsx index 2692b7da..f98f2ea8 100644 --- a/components/articles/ArticleForm.tsx +++ b/components/articles/ArticleForm.tsx @@ -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)} > - - + {legacyHouseworkKind && ( + + )} + + {ROT_WORK_TYPES.map((w) => ( + + ))} + + + {RUT_WORK_TYPES.map((w) => ( + + ))} + )} /> diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index 1e635f79..c677b863 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -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) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index ed1e9b57..cf9f2613 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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).' }, diff --git a/lib/api/__tests__/schemas.test.ts b/lib/api/__tests__/schemas.test.ts index 205a4a41..c5f68824 100644 --- a/lib/api/__tests__/schemas.test.ts +++ b/lib/api/__tests__/schemas.test.ts @@ -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) + } + }) +}) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index f99f5e3b..0cbd7ea9 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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. diff --git a/lib/import/articles/__tests__/parser.test.ts b/lib/import/articles/__tests__/parser.test.ts index 5db2c425..fdb6fd0e 100644 --- a/lib/import/articles/__tests__/parser.test.ts +++ b/lib/import/articles/__tests__/parser.test.ts @@ -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) + }) +}) diff --git a/lib/import/articles/parser.ts b/lib/import/articles/parser.ts index bbdb84b1..631daaf5 100644 --- a/lib/import/articles/parser.ts +++ b/lib/import/articles/parser.ts @@ -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.`) } diff --git a/lib/invoices/__tests__/rot-rut-rules.test.ts b/lib/invoices/__tests__/rot-rut-rules.test.ts index cbf30e8c..b36b9750 100644 --- a/lib/invoices/__tests__/rot-rut-rules.test.ts +++ b/lib/invoices/__tests__/rot-rut-rules.test.ts @@ -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() + }) +}) diff --git a/lib/invoices/rot-rut-rules.ts b/lib/invoices/rot-rut-rules.ts index 02c657bb..85bb3b72 100644 --- a/lib/invoices/rot-rut-rules.ts +++ b/lib/invoices/rot-rut-rules.ts @@ -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 diff --git a/lib/pending-operations/schemas/__tests__/article.test.ts b/lib/pending-operations/schemas/__tests__/article.test.ts index 6ab1fe44..98c88a60 100644 --- a/lib/pending-operations/schemas/__tests__/article.test.ts +++ b/lib/pending-operations/schemas/__tests__/article.test.ts @@ -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() + }) +}) diff --git a/lib/pending-operations/schemas/article.ts b/lib/pending-operations/schemas/article.ts index c32f3a1a..9091f22a 100644 --- a/lib/pending-operations/schemas/article.ts +++ b/lib/pending-operations/schemas/article.ts @@ -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), diff --git a/messages/en.json b/messages/en.json index d7988d60..99ba4246 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index b982634f..8d41c125 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",