fix(payroll): require jamkning valid_to on every write path (#2058) (#2240)

* 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:
Mattsson
2026-09-03 20:22:41 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 22b98e0a3b
commit b07efcafd4
17 changed files with 706 additions and 117 deletions
+1
View File
@@ -1533,6 +1533,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-03] SCB name search is a picker, never a lookup: the user chooses among SCB's matches and the chosen org number is recorded as a fact with source user before any fetch; one match is shown, not auto-picked, because a trade name is not an identity (Adobe Systems Software resolves to an Irish entity and a Swedish one)
[2026-09-03] AGI redovisningsperiod = the payout month (agiReportingPeriod on payment_date), not salary_runs.period_*: Skatteverket files per the month the pay went out (kontantprincipen), so lön i efterskott (August work paid 25 September) is declared in September. The in-period payment-date guard (dashboard PATCH, lib/salary/update-run.ts, v1 PATCH, RunHeader min/max) is lifted rather than widened: its only stated reason was that the AGI keyed on period_*, and any residual month window would bite the next efterskott variant. Existing agi_declarations rows keep their stored period (no backfill): a declaration already filed under the earned month is a real-world correction with Skatteverket, not a re-key. New AGI_PERIOD_CONFLICT (409) refuses to overwrite a live run's declaration for the same payout month, since one month's AGI must cover every payment that month and the generator cannot merge runs. Issue #2191.
[2026-09-03] The cursor:// deeplink is its own allowlist provider (cursor_deeplink) rendered "Din egen dator" and never "Verifierad", after the skeptic, CodeRabbit and Superagent all made the same point: a custom scheme can be claimed by any local app (RFC 8252 section 8.4), so it carries loopback trust, not vendor trust, and the consent page must not say otherwise; https://www.cursor.com/... keeps the verified label. Same pass fixed the consent-page CSP for custom schemes: new URL('cursor://...').origin is the string "null", so form-action became `'self' null` and Chromium would have blocked the post-consent 303 (correctness skeptic refutation); the header now uses the scheme-source (`cursor:`) when the origin is opaque. Not done: rejecting a missing code_challenge at /authorize. A code minted without one is unexchangeable (verifyPkce against an empty challenge is always false, now pinned by a test), so it is fail-closed; making it fail earlier is a separate change touching every client.
[2026-09-03] Jämkning valid_to is required on every write path (web, v1, MCP staging and executors, both Zod schemas) through one shared validator (lib/salary/jamkning-rules.ts), closing #2058: the engine never applies a beslut without an end date, so the previously accepted percentage+valid_from shape stored an inert beslut behind a 200. Declined the alternative of defaulting valid_to to 31 December of the from-year: it matches most beslut but silently changes withholding on rows that today do nothing. Existing incomplete rows are listed by scripts/list-incomplete-jamkning.ts and decided per company (set an end date or clear the beslut), since either changes the next payslip.
[2026-09-03] The Lucide Building2 glyph is retired app-wide (founder, from the register walkthrough). Suppliers use Truck, companies and company-scoped things use Briefcase, banks use Landmark; the extension manifest icon name changed with it
[2026-09-03] settleInvoicePayment writes the invoice_payments row BEFORE the CAS status update and removes it in both failure branches, instead of inserting after the update: the kontantmetod cut-off reads invoice_payments only, so a paid invoice without a row is the #2019 defect itself; failing closed on the insert (voucher storno + INVOICE_PAID_BOOK_FAILED) keeps GL, sub-ledger and invoice status in step. The #2019 backfill inserts only where exactly one posted payment voucher exists (invoice_paid / invoice_cash_payment with source_id = invoice); zero or several vouchers are reported, never guessed, and every row is tagged backfill:#2019 in notes so one DELETE reverts a run.
[2026-09-03] #2019 skeptic round: the invoice_payments row is written by one helper (lib/invoices/invoice-payment-row.ts) from all four transaction-less settlement paths (dashboard, v1, MCP mark-paid, Stripe), with amount = applied amount (new paid_amount minus prior) rather than cash received, so a 3740 öre absorption never produces a negative fordran in the cut-off or a wrong storno restore. A payment row with transaction_id NULL does NOT count as "reconciled to a bank line" in the two duplicate detectors: the bank line for a manual settlement arrives later and the voucher must still be offered as a twin. The backfill dates rows from the voucher entry_date (paid_at was wall-clock before #1332), refuses rows that disagree with the voucher's 1510 credit / settlement debit, reports partially covered invoices (rows_short) instead of patching them, and records each executed run in behandlingshistorik (new event type InvoicePaymentRowBackfilled, migration 20260903180000). Not done here, pre-existing: the bank-match and pending-operations match paths still store cash received as the row amount, and the kontantmetod cut-off ignores ROT/RUT deduction_total (1513 share shows as outstanding); both filed as follow-ups.
@@ -220,6 +220,7 @@ describe('personnummer contract on /api/salary/employees/[id]', () => {
*/
describe('jämkning on PATCH /api/salary/employees/[id]', () => {
const JAMKNING_START_REQUIRED = 'Jämkningens startdatum måste anges när jämkningsprocent sätts'
const JAMKNING_END_REQUIRED = 'Jämkningens slutdatum måste anges när jämkningsprocent sätts'
const JAMKNING_ORDER = 'Jämkningens slutdatum måste vara efter startdatumet'
function useRow(existing: Record<string, unknown>) {
@@ -259,6 +260,20 @@ describe('jämkning on PATCH /api/salary/employees/[id]', () => {
expect(captured.updates).toBeNull()
})
it('400 when a percentage is set with a start date but no end date (#2058)', async () => {
const captured = useRow({ ...EXISTING_ROW, jamkning_percentage: null, jamkning_valid_from: null, jamkning_valid_to: null })
const response = await PATCH(
patchRequest({ jamkning_percentage: 20, jamkning_valid_from: '2026-01-01', jamkning_valid_to: null }),
params,
)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain(JAMKNING_END_REQUIRED)
expect(captured.updates).toBeNull()
})
it('400 when the end date precedes the start date within the body', async () => {
const captured = useRow({ ...EXISTING_ROW })
+10 -22
View File
@@ -7,6 +7,7 @@ import { getCompanyEntityType } from '@/lib/company/context'
import { encryptPersonnummer, extractLast4, maskEmployeeForResponse, validatePersonnummer } from '@/lib/salary/personnummer'
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'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -72,28 +73,15 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
if (merged.f_skatt_status === 'a_skatt' && !merged.is_sidoinkomst && !merged.tax_table_number) {
mergedErrors.push('Skattetabell krävs för A-skatt anställda')
}
// Merged-state jämkning check (same rule as the v1 route and
// employee-commands): a non-null percentage needs a start date, and the
// dates must be ordered, but the schema can only see the body. Only run
// when the PATCH touches a jamkning field: a legacy row with inconsistent
// jamkning_* state must not block unrelated updates (fixing it requires
// touching those very fields). `body` is the parsed patch: absent keys are
// absent, explicit nulls survive.
const jamkningTouched =
'jamkning_percentage' in body ||
'jamkning_valid_from' in body ||
'jamkning_valid_to' in body
if (jamkningTouched) {
if (merged.jamkning_percentage != null && !merged.jamkning_valid_from) {
mergedErrors.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 < merged.jamkning_valid_from
) {
mergedErrors.push('Jämkningens slutdatum måste vara efter startdatumet')
}
// Merged-state jämkning check through the shared validator (same rule as
// the v1 route and employee-commands): a non-null percentage needs both
// dates, and the dates must be ordered, but the schema can only see the
// body. Only run when the PATCH touches a jamkning field: a legacy row
// with inconsistent jamkning_* state must not block unrelated updates
// (fixing it requires touching those very fields). `body` is the parsed
// patch: absent keys are absent, explicit nulls survive. #2058
if (touchesJamkning(body)) {
for (const issue of validateJamkning(merged)) mergedErrors.push(issue.message)
}
if (mergedErrors.length > 0) {
return NextResponse.json({ error: mergedErrors.join('. ') }, { status: 400 })
@@ -219,6 +219,19 @@ describe('POST /api/salary/employees', () => {
})
})
it('returns 400 on a jämkning percentage without an end date, without inserting (#2058)', async () => {
const { supabase, insert } = supabaseWithInsert({ id: 'emp-new', personnummer: encryptPersonnummer(NEW_PNR) })
authed(supabase)
const res = await POST(
postRequest({ ...CREATE_BASE, jamkning_percentage: 12.5, jamkning_valid_from: '2026-01-01' }),
params,
)
expect(res.status).toBe(400)
expect(insert).not.toHaveBeenCalled()
})
it('inserts null jämkning fields when the body omits them', async () => {
const { supabase, insert } = supabaseWithInsert({ id: 'emp-new', personnummer: encryptPersonnummer(NEW_PNR) })
authed(supabase)
@@ -27,6 +27,7 @@ import { readV1JsonBody } from '@/lib/api/v1/body'
import { UpdateEmployeeSchema } from '@/lib/api/schemas'
import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
import { JAMKNING_ORDER, touchesJamkning, validateJamkning, type JamkningFields } from '@/lib/salary/jamkning-rules'
const EmploymentType = z.enum(['employee', 'company_owner', 'board_member'])
const SalaryType = z.enum(['monthly', 'hourly'])
@@ -341,46 +342,25 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Merged-state jämkning check (same pattern as växa-stöd): a non-null
// percentage needs a start date, but the schema can only see the body.
// Also validate merged date ordering when only one of the dates is
// updated. Setting jamkning_percentage to null clears the beslut and
// skips these checks. Only run when the PATCH touches a jamkning field:
// a legacy row with inconsistent jamkning_* state must not block
// unrelated updates (fixing it requires touching those very fields).
const jamkningTouched =
'jamkning_percentage' in updates ||
'jamkning_valid_from' in updates ||
'jamkning_valid_to' in updates
if (jamkningTouched) {
const mergedJamkningPct =
'jamkning_percentage' in updates
? (updates.jamkning_percentage as number | null)
: ((existing as Record<string, unknown>).jamkning_percentage as number | null)
const mergedJamkningFrom =
'jamkning_valid_from' in updates
? (updates.jamkning_valid_from as string | null)
: ((existing as Record<string, unknown>).jamkning_valid_from as string | null)
const mergedJamkningTo =
'jamkning_valid_to' in updates
? (updates.jamkning_valid_to as string | null)
: ((existing as Record<string, unknown>).jamkning_valid_to as string | null)
if (mergedJamkningPct !== null && mergedJamkningPct !== undefined && !mergedJamkningFrom) {
// Merged-state jämkning check through the shared validator (same pattern
// as växa-stöd): a non-null percentage needs both dates, but the schema
// can only see the body. Setting jamkning_percentage to null clears the
// beslut and skips these checks. Only run when the PATCH touches a
// jamkning field: a legacy row with inconsistent jamkning_* state must
// not block unrelated updates (fixing it requires touching those very
// fields). #2058
if (touchesJamkning(updates)) {
const mergedJamkning = { ...(existing as Record<string, unknown>), ...updates } as JamkningFields
const [issue] = validateJamkning(mergedJamkning)
if (issue) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'jamkning_valid_from',
field: issue.field,
message:
'Jämkningens startdatum måste anges när jämkningsprocent sätts. Skicka även `jamkning_valid_from` i samma PATCH.',
},
})
}
if (mergedJamkningFrom && mergedJamkningTo && mergedJamkningTo < mergedJamkningFrom) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'jamkning_valid_to',
message: 'Jämkningens slutdatum måste vara efter startdatumet.',
issue.message === JAMKNING_ORDER
? `${issue.message}.`
: `${issue.message}. Skicka även \`${issue.field}\` i samma PATCH.`,
},
})
}
@@ -353,6 +353,60 @@ describe('POST /api/v1/companies/:companyId/employees', () => {
expect(JSON.stringify(body)).not.toContain(SAMPLE_PERSONNUMMER)
})
it('rejects a jämkning percentage without an end date (#2058)', async () => {
// The engine applies a beslut only when BOTH dates are set; the API used
// to accept this shape and store an inert beslut.
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: SAMPLE_EMPLOYEE, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await createEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees`, {
method: 'POST',
body: JSON.stringify({
...validBody,
jamkning_percentage: 15,
jamkning_valid_from: '2026-01-01',
}),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(JSON.stringify(body.error)).toContain('jamkning_valid_to')
})
it('accepts a complete jämkning beslut on create', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: SAMPLE_EMPLOYEE, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await createEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees`, {
method: 'POST',
body: JSON.stringify({
...validBody,
jamkning_percentage: 15,
jamkning_valid_from: '2026-01-01',
jamkning_valid_to: '2026-12-31',
}),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(201)
})
it('returns 409 EMPLOYEE_DUPLICATE_PERSONNUMMER on 23505 (and does not echo the personnummer)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
@@ -799,6 +853,87 @@ describe('PATCH /api/v1/companies/:companyId/employees/:id', () => {
expect(body.error.details.field).toBe('jamkning_valid_from')
})
it('rejects a jämkning percentage without an end date (merged state, #2058)', async () => {
// Percentage + start date only: the engine would never apply it, so the
// route must refuse rather than store an inert beslut.
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: SAMPLE_EMPLOYEE, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({ jamkning_percentage: 15, jamkning_valid_from: '2026-01-01' }),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.field).toBe('jamkning_valid_to')
expect(body.error.details.message).toContain('slutdatum')
})
it('rejects clearing only the end date of a stored beslut', async () => {
const withJamkning = {
...SAMPLE_EMPLOYEE,
jamkning_percentage: 15,
jamkning_valid_from: '2026-01-01',
jamkning_valid_to: '2026-12-31',
}
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: withJamkning, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({ jamkning_valid_to: null }),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.details.field).toBe('jamkning_valid_to')
})
it('leaves a legacy row without valid_to editable in unrelated ways (touched gate)', async () => {
const legacy = {
...SAMPLE_EMPLOYEE,
jamkning_percentage: 15,
jamkning_valid_from: '2026-01-01',
jamkning_valid_to: null,
}
const updated = { ...legacy, monthly_salary: 38000 }
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: [{ data: legacy, error: null }, { data: updated, error: null }],
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({ monthly_salary: 38000 }),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
})
it('rejects jamkning_valid_to before jamkning_valid_from', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
+5 -4
View File
@@ -313,10 +313,11 @@ export default function EmployeeTaxCard({
percentage for a bounded period. The engine only applies it when
BOTH dates are set, so both are required as soon as the user
edits the beslut (native `required`: both hosts render this
inside a <form>). A seeded beslut is never blocked on: the API
keeps valid_to optional, so a row stored that way must stay
editable elsewhere. The table fields stay visible above: they
apply again once the beslut expires. */}
inside a <form>). A seeded beslut is never blocked on: rows
stored before #2058 may still lack valid_to (every write path
now requires it), and such a row must stay editable elsewhere.
The table fields stay visible above: they apply again once the
beslut expires. */}
<div className="space-y-2">
<Label htmlFor="jamkning_percentage">
<InfoTooltip content={t('tax_jamkning_tooltip')}>
@@ -401,6 +401,12 @@ describe('tools/list payload size guard', () => {
// gnubok_get_arsredovisning_filing_status to search-only: iXBRL
// filing is off until the Bolagsverket avtal exists, so no client
// polls it. Ceiling unchanged.
// * 2026-09-03, jamkning (#2240) landing on top of proforma (#2254):
// two one-line field notes on create/update_employee measured 60 010
// after merging main. Demoted gnubok_list_arsredovisning_versions to
// 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 filing_status tool. Ceiling unchanged.
expect(approxTokens).toBeLessThan(60_000)
})
@@ -560,6 +560,16 @@ describe('gnubok_create_employee', () => {
).rejects.toThrow(/Invalid employee/)
})
it('rejects a jämkning percentage without an end date (#2058)', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
createEmployee.execute(
{ ...validArgs, jamkning_percentage: 15, jamkning_valid_from: '2026-01-01' },
'company-1', 'user-1', supabase as never, { type: 'user' },
),
).rejects.toThrow(/jamkning_valid_to/)
})
it('blocks EF owners on payroll (entity-type preflight)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { entity_type: 'ef' } }) // company_settings
@@ -624,6 +634,35 @@ describe('gnubok_update_employee', () => {
expect(inserted.params.patch.default_dimensions).toEqual({})
})
it('rejects a jämkning percentage without an end date at staging time (#2058)', async () => {
// Preflight on the merged row: the agent gets the error now, not at
// approval. The executor runs the same shared validator again.
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { ...EXISTING, jamkning_percentage: null, jamkning_valid_from: null, jamkning_valid_to: null } })
await expect(
updateEmployee.execute(
{ employee_id: 'emp-1', jamkning_percentage: 15, jamkning_valid_from: '2026-01-01' },
'company-1', 'user-1', supabase as never, { type: 'user' },
),
).rejects.toThrow(/jamkning_valid_to/)
})
it('stages an unrelated edit on a legacy row that lacks valid_to (touched gate)', async () => {
const supabaseMock = makeCapturingSupabase({
employees: { data: { ...EXISTING, jamkning_percentage: 15, jamkning_valid_from: '2026-01-01', jamkning_valid_to: null } },
fiscal_periods: { data: null },
company_settings: { data: null },
pending_operations: { data: { id: 'op-legacy' }, error: null },
})
const result = (await updateEmployee.execute(
{ employee_id: 'emp-1', monthly_salary: 38000 },
'company-1', 'user-1', supabaseMock as never, { type: 'user' },
)) as { staged: boolean }
expect(result.staged).toBe(true)
})
it('rejects personnummer changes at the tool boundary', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
+19 -2
View File
@@ -15774,7 +15774,7 @@ export const tools: McpTool[] = [
vaxa_stod_eligible: { type: 'boolean' },
vaxa_stod_start: { type: 'string' },
vaxa_stod_end: { type: 'string' },
jamkning_percentage: { type: 'number' },
jamkning_percentage: { type: 'number', description: 'Requires both dates, else rejected' },
jamkning_valid_from: { type: 'string' },
jamkning_valid_to: { type: 'string' },
default_dimensions: {
@@ -15888,7 +15888,7 @@ export const tools: McpTool[] = [
vaxa_stod_eligible: { type: 'boolean' },
vaxa_stod_start: { type: 'string' },
vaxa_stod_end: { type: 'string' },
jamkning_percentage: { type: ['number', 'null'], description: 'null clears the beslut' },
jamkning_percentage: { type: ['number', 'null'], description: 'null clears; else requires both dates' },
jamkning_valid_from: { type: ['string', 'null'] },
jamkning_valid_to: { type: ['string', 'null'] },
default_dimensions: {
@@ -15936,6 +15936,15 @@ export const tools: McpTool[] = [
if (error) throw dbError(error)
if (!existing) throw new Error('Employee not found')
// Preflight the jämkning contract on the merged row so the agent gets
// the error at staging time instead of at approval (#2058). The
// executor (updateEmployee) runs the same shared validator again.
const { touchesJamkning, validateJamkning } = await import('@/lib/salary/jamkning-rules')
if (touchesJamkning(patch)) {
const [issue] = validateJamkning({ ...(existing as Record<string, unknown>), ...patch })
if (issue) throw new Error(`${issue.field}: ${issue.message}`)
}
const changes = Object.entries(patch).map(([field, to]) => ({
field,
from: (existing as Record<string, unknown>)[field] ?? null,
@@ -19926,6 +19935,14 @@ export const tools: McpTool[] = [
const versions = await listAnnualReportVersions(supabase, companyId, fiscalPeriodId)
return { fiscal_period_id: fiscalPeriodId, versions }
},
// Search-only (2026-09-03): versions exist only once an annual report is
// rendered for signing or filing, and iXBRL filing is switched off until
// the Bolagsverket avtal and certificate exist (same reason its sibling
// gnubok_get_arsredovisning_filing_status below is search-only). Demoted
// to keep tools/list under the context budget after #2254 added the
// proforma fields and #2240 the jamkning field notes (see
// payload-size.bench.test.ts). Reachable via gnubok_call_tool.
catalogVisibility: 'search',
},
{
+14 -40
View File
@@ -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({
+107
View File
@@ -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)
})
})
+21 -13
View File
@@ -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('. ') } }
+67
View File
@@ -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)
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Read-only lister for #2058: employees whose jämkningsbeslut is stored but
* can never be applied because jamkning_valid_to is null.
*
* WHY: until #2058 the v1 API and MCP accepted jamkning_percentage +
* jamkning_valid_from without jamkning_valid_to. isJamkningValid in
* lib/salary/calculation-engine.ts applies a beslut only when BOTH dates are
* set, so these rows fall back to the tax table on every payslip and AGI
* while the stored beslut says otherwise. Every write path now rejects the
* shape; this script finds the rows that were stored before that.
*
* WHAT IT DOES: lists the rows per company with employee id, percentage and
* start date. No names or other PII are selected: the employee page shows the
* name once you open the id. It writes NOTHING. The repair is a per-company
* decision (set an end date, or clear the beslut): either changes the next
* payslip, so it is made by a human through the employee page or the API,
* not by this script.
*
* Usage:
* npx tsx scripts/list-incomplete-jamkning.ts
*
* Reads NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY from .env.local.
* Treat .env.local as pointing at PRODUCTION (read-only here, still: be sure
* which project you are looking at).
*/
import { createClient } from '@supabase/supabase-js'
import { config as dotenv } from 'dotenv'
import { resolve } from 'node:path'
dotenv({ path: resolve(process.cwd(), '.env.local') })
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!SUPABASE_URL || !SERVICE_KEY) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
process.exit(1)
}
const supabase = createClient(SUPABASE_URL, SERVICE_KEY, { auth: { persistSession: false } })
interface Row {
id: string
company_id: string
is_active: boolean
jamkning_percentage: number
jamkning_valid_from: string | null
jamkning_valid_to: string | null
}
async function main() {
const PAGE = 1000
const rows: Row[] = []
for (let from = 0; ; from += PAGE) {
const { data, error } = await supabase
.from('employees')
.select('id, company_id, is_active, jamkning_percentage, jamkning_valid_from, jamkning_valid_to')
.not('jamkning_percentage', 'is', null)
.is('jamkning_valid_to', null)
.order('company_id')
.order('id')
.range(from, from + PAGE - 1)
if (error) {
console.error('employees query failed:', error.message)
process.exit(1)
}
rows.push(...((data ?? []) as Row[]))
if (!data || data.length < PAGE) break
}
if (rows.length === 0) {
console.log('No employees with a jämkning percentage but no valid_to. Nothing to decide.')
return
}
const companyIds = [...new Set(rows.map((r) => r.company_id))]
const { data: companies, error: companiesError } = await supabase
.from('companies')
.select('id, name')
.in('id', companyIds)
if (companiesError) {
console.error('companies query failed:', companiesError.message)
process.exit(1)
}
const companyName = new Map((companies ?? []).map((c) => [c.id as string, c.name as string]))
console.log(`${rows.length} employee row(s) across ${companyIds.length} company(ies) with an inert jämkningsbeslut:\n`)
for (const companyId of companyIds) {
console.log(`${companyName.get(companyId) ?? '(unknown company)'} ${companyId}`)
for (const r of rows.filter((x) => x.company_id === companyId)) {
const state = r.is_active ? 'active ' : 'inactive'
const from = r.jamkning_valid_from ?? '(no start date)'
console.log(` ${r.id} ${state} ${r.jamkning_percentage} % from ${from} to (null)`)
}
console.log('')
}
console.log('Decide per company: set jamkning_valid_to (a beslut normally runs to 31 December of')
console.log('the from-year) or clear jamkning_percentage. Either changes the next payslip; the')
console.log('engine ignores these rows until then. Inactive employees can usually be cleared.')
}
main().catch((err) => {
console.error(err)
process.exit(1)
})