* fix(payroll): require jamkning valid_to on every write path (#2058) A jamkningsbeslut saved through the v1 API or MCP with a percentage and a start date but no end date was stored and returned 200, yet the engine (isJamkningValid) never applies a beslut without both dates: the payslip and the AGI carried the table tax while the caller believed the beslut was live. One shared validator (lib/salary/jamkning-rules.ts) now requires both dates whenever a percentage is set and checks their ordering. Every write path runs it: CreateEmployeeSchema and UpdateEmployeeSchema, the web POST and PATCH routes, the v1 PATCH route (its private copy is deleted), the MCP create and update executors in employee-commands, and the MCP update tool preflights the merged row at staging time so the agent sees the error before approval. The update paths keep the existing touched gate, so legacy rows stored without valid_to stay editable in unrelated ways. The MCP tool descriptions state that both dates are required for the beslut to apply. scripts/list-incomplete-jamkning.ts lists the existing rows (percentage set, valid_to null) per company, read-only; setting an end date or clearing the beslut is decided per company since either changes the next payslip. Declined: defaulting valid_to to 31 December of the from-year. It matches most beslut but silently changes withholding on rows that today do nothing. Closes #2058 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0161fHpCX3rnWtidwwdGfCdB * fix(payroll): keep the jamkning PR inside the type and tools/list budgets CI on the first push failed on two ratchets this PR itself tripped: - Typecheck ratchet: the three staging tests added here reused the untyped 'agent_chat' actor literal the file already carried, which raised that file's error count above its baseline. They now pass { type: 'user' }. - tools/list payload budget: the first jamkning field descriptions on gnubok_create_employee and gnubok_update_employee pushed the projected catalog to 60 113 tokens against the 60 000 ceiling. The percentage fields keep a one-line "needs both dates or never applied" note; the date fields drop theirs. Also acts on the compliance swarm's GDPR Art.32 note: the read-only lister no longer selects employee names at all (the employee id is what the per-company decision needs), so the script touches no PII. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0161fHpCX3rnWtidwwdGfCdB * docs(mcp): say the jamkning percentage is rejected without both dates CodeRabbit on #2240: "never applied" described the pre-fix engine behaviour; the contract now is that a create or update with a percentage and a missing date is rejected before staging. Same length, so the tools/list payload budget is unchanged. The concurrency finding is tracked in #2256 instead of this PR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(mcp): keep tools/list under budget after proforma landed on main After merging main (#2254 proforma fields) the projected tools/list measured 60 010 tokens against the 60 000 ceiling with this PR's two jamkning field notes. Per the budget test's own rule, demote a read tool instead of bumping the ceiling: gnubok_list_arsredovisning_versions goes search-only. Versions exist only once a report is rendered for signing or filing, which is the same switched-off iXBRL path as its sibling gnubok_get_arsredovisning_filing_status, already search-only since 2026-09-02. Still reachable via gnubok_call_tool. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
22b98e0a3b
commit
b07efcafd4
+14
-40
@@ -12,6 +12,7 @@ import { ISO_DATE_RE, ISO_DATE_MESSAGE_SV } from '@/lib/invariants/iso-date'
|
||||
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
|
||||
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
|
||||
import { validateJamkning } from '@/lib/salary/jamkning-rules'
|
||||
import { MAX_INVOICE_EMAIL_COPY_RECIPIENTS } from '@/lib/invoices/email-recipients'
|
||||
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
|
||||
import { computeLineNet } from '@/lib/invoices/line-amounts'
|
||||
@@ -3067,31 +3068,12 @@ export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) =
|
||||
})
|
||||
}
|
||||
|
||||
// Jämkning: a percentage without a start date is meaningless. Note that the
|
||||
// engine (isJamkningValid in lib/salary/calculation-engine.ts) applies the
|
||||
// beslut only when BOTH dates are set; the API keeps valid_to optional for
|
||||
// compatibility and the UI requires it.
|
||||
if (
|
||||
data.jamkning_percentage !== null &&
|
||||
data.jamkning_percentage !== undefined &&
|
||||
!data.jamkning_valid_from
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Jämkningens startdatum måste anges när jämkningsprocent sätts',
|
||||
path: ['jamkning_valid_from'],
|
||||
})
|
||||
}
|
||||
if (
|
||||
data.jamkning_valid_from &&
|
||||
data.jamkning_valid_to &&
|
||||
data.jamkning_valid_to < data.jamkning_valid_from
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Jämkningens slutdatum måste vara efter startdatumet',
|
||||
path: ['jamkning_valid_to'],
|
||||
})
|
||||
// Jämkning: the engine (isJamkningValid in lib/salary/calculation-engine.ts)
|
||||
// applies the beslut only when BOTH dates are set, so a percentage needs
|
||||
// both a start and an end date on every write path (#2058). The create body
|
||||
// is the whole row, so the shared validator sees the final state here.
|
||||
for (const issue of validateJamkning(data)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: issue.message, path: [issue.field] })
|
||||
}
|
||||
|
||||
// Bank details: validate clearing/kontonummer structure at entry so a typo is
|
||||
@@ -3197,21 +3179,13 @@ export const UpdateEmployeeSchema = EmployeeSchemaPatchBase.partial().superRefin
|
||||
})
|
||||
}
|
||||
|
||||
// Jämkning: same schema-visibility caveat as växa-stöd above. What the
|
||||
// schema CAN see: a non-null percentage sent WITHOUT any start date in the
|
||||
// same body is only valid if a start date already exists on the row: the
|
||||
// route layer does the merged-state check. Within-body date ordering is
|
||||
// checkable here.
|
||||
if (
|
||||
data.jamkning_valid_from &&
|
||||
data.jamkning_valid_to &&
|
||||
data.jamkning_valid_to < data.jamkning_valid_from
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Jämkningens slutdatum måste vara efter startdatumet',
|
||||
path: ['jamkning_valid_to'],
|
||||
})
|
||||
// Jämkning: same schema-visibility caveat as växa-stöd above. A non-null
|
||||
// percentage sent WITHOUT dates in the same body is only valid if the dates
|
||||
// already exist on the row, so the route layer runs validateJamkning on the
|
||||
// merged state. Within-body date ordering is checkable here: pass the
|
||||
// percentage as null so only the ordering rule fires.
|
||||
for (const issue of validateJamkning({ ...data, jamkning_percentage: null })) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: issue.message, path: [issue.field] })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -634,6 +634,36 @@ describe('commitPendingOperation: create_employee', () => {
|
||||
expect(JSON.stringify(result.data)).not.toContain(encrypted)
|
||||
})
|
||||
|
||||
it('rejects a jämkning percentage without an end date before inserting (#2058)', async () => {
|
||||
const { encryptPersonnummer } = await import('@/lib/salary/personnummer')
|
||||
const { supabase, enqueue, calls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { entity_type: 'ab' } }) // entity type
|
||||
enqueue({ data: null, error: null }) // finalize
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'create_employee',
|
||||
params: {
|
||||
first_name: 'Anna',
|
||||
last_name: 'Andersson',
|
||||
personnummer_encrypted: encryptPersonnummer('190001010000'),
|
||||
personnummer_last4: '0000',
|
||||
employment_start: '2026-01-15',
|
||||
salary_type: 'monthly',
|
||||
monthly_salary: 35000,
|
||||
tax_table_number: 33,
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-01-01',
|
||||
jamkning_valid_to: null,
|
||||
},
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).not.toBe('committed')
|
||||
expect(result.error).toMatch(/slutdatum/)
|
||||
expect(calls.some((c) => c.table === 'employees' && c.method === 'insert')).toBe(false)
|
||||
})
|
||||
|
||||
it('maps duplicate personnummer to a clean rejection', async () => {
|
||||
const { encryptPersonnummer } = await import('@/lib/salary/personnummer')
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
@@ -815,6 +845,78 @@ describe('commitPendingOperation: update_employee', () => {
|
||||
expect(result.http_status).toBe(409)
|
||||
})
|
||||
|
||||
it('rejects a jämkning percentage without an end date on the merged row (#2058)', async () => {
|
||||
const { supabase, enqueue, calls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'emp-1',
|
||||
first_name: 'Anna',
|
||||
last_name: 'Andersson',
|
||||
salary_type: 'monthly',
|
||||
monthly_salary: 35000,
|
||||
tax_table_number: 33,
|
||||
is_sidoinkomst: false,
|
||||
f_skatt_status: 'a_skatt',
|
||||
vaxa_stod_eligible: false,
|
||||
jamkning_percentage: null,
|
||||
jamkning_valid_from: null,
|
||||
jamkning_valid_to: null,
|
||||
},
|
||||
}) // fetch existing
|
||||
enqueue({ data: null, error: null }) // finalize
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'update_employee',
|
||||
params: {
|
||||
employee_id: 'emp-1',
|
||||
patch: { jamkning_percentage: 15, jamkning_valid_from: '2026-01-01' },
|
||||
},
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).not.toBe('committed')
|
||||
expect(result.error).toMatch(/slutdatum/)
|
||||
expect(calls.some((c) => c.table === 'employees' && c.method === 'update')).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves a legacy row without valid_to editable in unrelated ways (touched gate)', async () => {
|
||||
const { encryptPersonnummer } = await import('@/lib/salary/personnummer')
|
||||
const encrypted = encryptPersonnummer('190001010000')
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'emp-1',
|
||||
first_name: 'Anna',
|
||||
last_name: 'Andersson',
|
||||
personnummer: encrypted,
|
||||
salary_type: 'monthly',
|
||||
monthly_salary: 35000,
|
||||
tax_table_number: 33,
|
||||
is_sidoinkomst: false,
|
||||
f_skatt_status: 'a_skatt',
|
||||
vaxa_stod_eligible: false,
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-01-01',
|
||||
jamkning_valid_to: null,
|
||||
is_active: true,
|
||||
},
|
||||
}) // fetch existing
|
||||
enqueue({
|
||||
data: { id: 'emp-1', first_name: 'Anna', last_name: 'Andersson', personnummer: encrypted, is_active: true },
|
||||
}) // update
|
||||
enqueue({ data: null, error: null }) // finalize
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'update_employee',
|
||||
params: { employee_id: 'emp-1', patch: { monthly_salary: 38000 } },
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
})
|
||||
|
||||
it('fails merged validation when clearing salary below zero-state', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
|
||||
@@ -193,6 +193,38 @@ describe('calculateSalary', () => {
|
||||
expect(result.taxWithheld).toBe(6000) // 15% of 40000
|
||||
})
|
||||
|
||||
it('does not apply jämkning when valid_to is null (#2058: an incomplete beslut is inert)', () => {
|
||||
// Every write path now rejects this shape; rows stored before that fix
|
||||
// still exist and must keep falling back to the table (or the 30 % here).
|
||||
const result = calculateSalary(
|
||||
makeBasicInput({
|
||||
jamkningPercentage: 15,
|
||||
jamkningValidFrom: '2026-01-01',
|
||||
jamkningValidTo: null,
|
||||
paymentDate: '2026-04-25',
|
||||
}),
|
||||
config2026,
|
||||
emptyTaxRates
|
||||
)
|
||||
|
||||
expect(result.taxWithheld).toBe(12000)
|
||||
})
|
||||
|
||||
it('does not apply jämkning when valid_from is null', () => {
|
||||
const result = calculateSalary(
|
||||
makeBasicInput({
|
||||
jamkningPercentage: 15,
|
||||
jamkningValidFrom: null,
|
||||
jamkningValidTo: '2026-12-31',
|
||||
paymentDate: '2026-04-25',
|
||||
}),
|
||||
config2026,
|
||||
emptyTaxRates
|
||||
)
|
||||
|
||||
expect(result.taxWithheld).toBe(12000)
|
||||
})
|
||||
|
||||
it('does not apply jämkning when outside date range', () => {
|
||||
const result = calculateSalary(
|
||||
makeBasicInput({
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
JAMKNING_END_REQUIRED,
|
||||
JAMKNING_ORDER,
|
||||
JAMKNING_START_REQUIRED,
|
||||
touchesJamkning,
|
||||
validateJamkning,
|
||||
} from '../jamkning-rules'
|
||||
|
||||
describe('validateJamkning', () => {
|
||||
it('accepts a complete beslut', () => {
|
||||
expect(
|
||||
validateJamkning({
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-01-01',
|
||||
jamkning_valid_to: '2026-12-31',
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts no beslut at all, with or without stray dates', () => {
|
||||
expect(validateJamkning({})).toEqual([])
|
||||
expect(
|
||||
validateJamkning({ jamkning_percentage: null, jamkning_valid_from: null, jamkning_valid_to: null }),
|
||||
).toEqual([])
|
||||
expect(
|
||||
validateJamkning({ jamkning_percentage: null, jamkning_valid_from: '2026-01-01', jamkning_valid_to: null }),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('requires the start date when a percentage is set', () => {
|
||||
const issues = validateJamkning({
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: null,
|
||||
jamkning_valid_to: '2026-12-31',
|
||||
})
|
||||
expect(issues).toEqual([{ field: 'jamkning_valid_from', message: JAMKNING_START_REQUIRED }])
|
||||
})
|
||||
|
||||
it('requires the end date when a percentage is set (#2058: the engine never applies a beslut without it)', () => {
|
||||
const issues = validateJamkning({
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-01-01',
|
||||
jamkning_valid_to: null,
|
||||
})
|
||||
expect(issues).toEqual([{ field: 'jamkning_valid_to', message: JAMKNING_END_REQUIRED }])
|
||||
})
|
||||
|
||||
it('treats an undefined end date like a null one', () => {
|
||||
const issues = validateJamkning({ jamkning_percentage: 15, jamkning_valid_from: '2026-01-01' })
|
||||
expect(issues.map((i) => i.field)).toEqual(['jamkning_valid_to'])
|
||||
})
|
||||
|
||||
it('treats 0 % as a beslut (Skatteverket can decide on zero withholding)', () => {
|
||||
const issues = validateJamkning({ jamkning_percentage: 0, jamkning_valid_from: null, jamkning_valid_to: null })
|
||||
expect(issues.map((i) => i.field)).toEqual(['jamkning_valid_from', 'jamkning_valid_to'])
|
||||
})
|
||||
|
||||
it('reports both missing dates in field order', () => {
|
||||
const issues = validateJamkning({ jamkning_percentage: 15 })
|
||||
expect(issues).toEqual([
|
||||
{ field: 'jamkning_valid_from', message: JAMKNING_START_REQUIRED },
|
||||
{ field: 'jamkning_valid_to', message: JAMKNING_END_REQUIRED },
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an end date before the start date', () => {
|
||||
const issues = validateJamkning({
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-06-01',
|
||||
jamkning_valid_to: '2026-01-31',
|
||||
})
|
||||
expect(issues).toEqual([{ field: 'jamkning_valid_to', message: JAMKNING_ORDER }])
|
||||
})
|
||||
|
||||
it('checks date ordering even without a percentage (schema body-only use)', () => {
|
||||
const issues = validateJamkning({
|
||||
jamkning_percentage: null,
|
||||
jamkning_valid_from: '2026-06-01',
|
||||
jamkning_valid_to: '2026-01-31',
|
||||
})
|
||||
expect(issues).toEqual([{ field: 'jamkning_valid_to', message: JAMKNING_ORDER }])
|
||||
})
|
||||
|
||||
it('accepts a one-day window', () => {
|
||||
expect(
|
||||
validateJamkning({
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-03-01',
|
||||
jamkning_valid_to: '2026-03-01',
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('touchesJamkning', () => {
|
||||
it('is true for any jämkning key, including an explicit null', () => {
|
||||
expect(touchesJamkning({ jamkning_percentage: 15 })).toBe(true)
|
||||
expect(touchesJamkning({ jamkning_valid_from: null })).toBe(true)
|
||||
expect(touchesJamkning({ jamkning_valid_to: '2026-12-31' })).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for an unrelated patch, so legacy rows stay editable', () => {
|
||||
expect(touchesJamkning({ first_name: 'Ny', monthly_salary: 38000 })).toBe(false)
|
||||
expect(touchesJamkning({})).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,7 @@ import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer
|
||||
import { getCompanyEntityType } from '@/lib/company/context'
|
||||
import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules'
|
||||
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
|
||||
import { touchesJamkning, validateJamkning } from '@/lib/salary/jamkning-rules'
|
||||
|
||||
export type EmployeeCommandResult<T> =
|
||||
| { ok: true; data: T }
|
||||
@@ -131,6 +132,21 @@ export async function createEmployee(
|
||||
}
|
||||
}
|
||||
|
||||
// The staging tool already ran CreateEmployeeSchema, but the executor is the
|
||||
// last gate before the row exists: an incomplete beslut must never be
|
||||
// stored, since the engine silently ignores it (#2058).
|
||||
const jamkningIssues = validateJamkning(fields)
|
||||
if (jamkningIssues.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'VALIDATION_ERROR',
|
||||
details: {
|
||||
field: jamkningIssues[0].field,
|
||||
message: jamkningIssues.map((i) => i.message).join('. '),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('employees')
|
||||
.insert({
|
||||
@@ -223,19 +239,11 @@ export async function updateEmployee(
|
||||
if (merged.vaxa_stod_eligible && !merged.vaxa_stod_start) {
|
||||
issues.push('Startdatum för Växa-stöd måste anges när Växa-stöd är aktiverat')
|
||||
}
|
||||
if (
|
||||
merged.jamkning_percentage !== null &&
|
||||
merged.jamkning_percentage !== undefined &&
|
||||
!merged.jamkning_valid_from
|
||||
) {
|
||||
issues.push('Jämkningens startdatum måste anges när jämkningsprocent sätts')
|
||||
}
|
||||
if (
|
||||
merged.jamkning_valid_from &&
|
||||
merged.jamkning_valid_to &&
|
||||
(merged.jamkning_valid_to as string) < (merged.jamkning_valid_from as string)
|
||||
) {
|
||||
issues.push('Jämkningens slutdatum måste vara efter startdatumet')
|
||||
// Jämkning is validated on the merged row through the shared validator,
|
||||
// and only when the patch touches a jämkning key: a legacy row stored with
|
||||
// an incomplete beslut must stay editable in unrelated ways. #2058
|
||||
if (touchesJamkning(updates)) {
|
||||
for (const issue of validateJamkning(merged)) issues.push(issue.message)
|
||||
}
|
||||
if (issues.length > 0) {
|
||||
return { ok: false, code: 'VALIDATION_ERROR', details: { message: issues.join('. ') } }
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The one validator for a jämkningsbeslut (Skatteverket beslut om ändrad
|
||||
* beräkning av skatteavdrag) on an employee row.
|
||||
*
|
||||
* The calculation engine (isJamkningValid in calculation-engine.ts) applies
|
||||
* the beslut only when BOTH dates are set and the payment date falls inside
|
||||
* them. A percentage stored with a missing date is therefore inert: the
|
||||
* payslip and the AGI carry the table tax while the caller was told 200.
|
||||
* Every write path (web routes, v1 REST, MCP staging and executors, the Zod
|
||||
* schemas) runs the merged row through this function so that shape can no
|
||||
* longer be stored. #2058
|
||||
*
|
||||
* Setting the percentage to null clears the beslut; the dates are then free.
|
||||
*/
|
||||
|
||||
export const JAMKNING_FIELDS = ['jamkning_percentage', 'jamkning_valid_from', 'jamkning_valid_to'] as const
|
||||
|
||||
export type JamkningField = (typeof JAMKNING_FIELDS)[number]
|
||||
|
||||
export interface JamkningFields {
|
||||
jamkning_percentage?: number | null
|
||||
jamkning_valid_from?: string | null
|
||||
jamkning_valid_to?: string | null
|
||||
}
|
||||
|
||||
export interface JamkningIssue {
|
||||
field: 'jamkning_valid_from' | 'jamkning_valid_to'
|
||||
message: string
|
||||
}
|
||||
|
||||
export const JAMKNING_START_REQUIRED = 'Jämkningens startdatum måste anges när jämkningsprocent sätts'
|
||||
export const JAMKNING_END_REQUIRED = 'Jämkningens slutdatum måste anges när jämkningsprocent sätts'
|
||||
export const JAMKNING_ORDER = 'Jämkningens slutdatum måste vara efter startdatumet'
|
||||
|
||||
/**
|
||||
* Validates the MERGED jämkning state of a row (existing row + patch for an
|
||||
* update, the full body for a create). Returns every issue found, in field
|
||||
* order, so callers can either join the messages or surface the first one.
|
||||
*/
|
||||
export function validateJamkning(fields: JamkningFields): JamkningIssue[] {
|
||||
const issues: JamkningIssue[] = []
|
||||
const percentage = fields.jamkning_percentage
|
||||
const from = fields.jamkning_valid_from
|
||||
const to = fields.jamkning_valid_to
|
||||
const hasBeslut = percentage !== null && percentage !== undefined
|
||||
|
||||
if (hasBeslut && !from) {
|
||||
issues.push({ field: 'jamkning_valid_from', message: JAMKNING_START_REQUIRED })
|
||||
}
|
||||
if (hasBeslut && !to) {
|
||||
issues.push({ field: 'jamkning_valid_to', message: JAMKNING_END_REQUIRED })
|
||||
}
|
||||
if (from && to && to < from) {
|
||||
issues.push({ field: 'jamkning_valid_to', message: JAMKNING_ORDER })
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a sparse patch names any jämkning key (an explicit null counts).
|
||||
* Update paths only validate when this holds: a legacy row stored with an
|
||||
* incomplete beslut must stay editable in unrelated ways, since fixing it
|
||||
* requires touching these very fields.
|
||||
*/
|
||||
export function touchesJamkning(patch: Record<string, unknown>): boolean {
|
||||
return JAMKNING_FIELDS.some((key) => key in patch)
|
||||
}
|
||||
Reference in New Issue
Block a user