From cb39cded81d701bc8685d5b4ad0d6eba4ee77563 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 3 Sep 2026 17:19:19 +0200 Subject: [PATCH] fix(payroll): declare AGI for the payout month, not the run's period month (#2191) (#2228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arbetsgivardeklarationen is filed for the calendar month the pay went out (kontantprincipen), so a run for August paid on 25 September belongs to redovisningsperiod 202609. The generator, the submit route, the run page and the run header all took run.period_year/period_month instead, and three PATCH paths refused any payment date outside that month, which made lön i efterskott impossible to set up at all. - lib/salary/agi/reporting-period.ts: one dependency-free helper (agiReportingPeriod) derives the period from payment_date, falling back to the run period only when the date is missing. - generate-declaration.ts: XML Redovisningsperiod, the agi_declarations lookup/insert and the sanity warnings key on the payout month. New AGI_PERIOD_CONFLICT (409) refuses to overwrite another live run's declaration for the same payout month; corrections still replace. - submit route, run page (AGI panel, submission hook, tax-payment fetch, XML filename) and RunHeader use the helper; the header says "AGI redovisas för 2026-09 (utbetalningsmånaden)" whenever the two differ. - The in-period payment-date guard is lifted in the dashboard PATCH, lib/salary/update-run.ts (MCP staged tool + pending-ops executor) and the v1 PATCH, plus the RunHeader min/max; its only stated reason was the period-keyed AGI. Generated API skill reference updated. Existing agi_declarations rows keep their stored period: a declaration already filed under the earned month is a correction with Skatteverket, not a re-key. Rule verified against Skatteverket's guidance on redovisningsperiod (kontantprincipen). Closes #2191 Claude-Session: https://claude.ai/code/session_01QPQLwHNEiQfiCNLSMzXMiQ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 1 + app/(dashboard)/salary/runs/[id]/page.tsx | 18 ++-- .../salary/runs/[id]/__tests__/route.test.ts | 22 +++-- .../[id]/agi/submit/__tests__/route.test.ts | 22 +++++ app/api/salary/runs/[id]/agi/submit/route.ts | 13 ++- app/api/salary/runs/[id]/route.ts | 26 ++---- .../[companyId]/salary-runs/[id]/route.ts | 27 ++---- .../salary-runs/__tests__/route.test.ts | 15 ++-- components/salary/run/RunHeader.tsx | 26 ++++-- .../__tests__/payroll-staged-tools.test.ts | 20 +++-- extensions/general/mcp-server/server.ts | 2 +- lib/errors/structured-errors.ts | 14 +++- .../__tests__/payroll-executors.test.ts | 12 ++- .../agi-generate-declaration.test.ts | 82 +++++++++++++++++++ .../__tests__/agi-reporting-period.test.ts | 53 ++++++++++++ lib/salary/agi/generate-declaration.ts | 76 ++++++++++++----- lib/salary/agi/reporting-period.ts | 61 ++++++++++++++ lib/salary/update-run.ts | 27 ++---- messages/en.json | 1 + messages/sv.json | 1 + .../accounted-api/references/salary-runs.md | 2 +- 21 files changed, 387 insertions(+), 134 deletions(-) create mode 100644 lib/salary/__tests__/agi-reporting-period.test.ts create mode 100644 lib/salary/agi/reporting-period.ts diff --git a/DECISIONS.md b/DECISIONS.md index 7e5a24c2..86d0e7c6 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1525,4 +1525,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-03] Cursor MCP OAuth callbacks are built-in allowlist entries, exact-matched: cursor://anysphere.cursor-mcp/oauth/callback and https://www.cursor.com/agents/mcp/oauth/callback (the loopback http://localhost:8787/callback already passes the local rule). Cursor's DCR sends all three in one request and /register rejects the whole set on any unknown URI, so the Cursor path advertised in Settings never worked (reported by a byrå user 2026-09-03). Exact match, not a cursor.com prefix, so no other cursor.com path can receive codes; the custom scheme is accepted despite RFC 8252 section 8.4 because the code is PKCE-bound and the loopback form carries the same local-machine trust. Users cannot self-register the cursor:// form (the settings panel requires https), which is why it is built in. Grok Bot rides on Cursor's MCP stack and stays broken on Cursor's side (forum thread 168052, open as of 2026-09-02); nothing server-side fixes that. [2026-09-03] Skattekonto through Connect = the existing data proxy plus CONNECT_SKV_CANARY_COMPANIES, not a separate sync operation: the provider logic is two GETs and the dedup keys stay on the ledger; system (certificate) auth is still not brokered because hosted has no certificate configured, so every hosted skattekonto read is a user-token call the proxy already carries. [2026-09-03] Old-address social identities are unlinked by a BEFORE UPDATE trigger on auth.users (migration 20260903110000), not by the /auth/callback done path: the callback never runs for a completing click from a browser without a session, and admin-side changes bypass it entirely; the trigger covers every path and keeps the email identity, password and BankID intact. +[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. diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index 9d03ced5..51d38403 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -44,6 +44,11 @@ import { RunEmployeesTable } from '@/components/salary/run/RunEmployeesTable' import { RunCalculationDetails } from '@/components/salary/run/RunCalculationDetails' import { RunJournalPreview, type PreviewData } from '@/components/salary/run/RunJournalPreview' import { periodLabelOf, type RunDetail } from '@/components/salary/run/types' +import { + agiReportingPeriod, + formatAgiPeriodCompact, + formatAgiPeriodDashed, +} from '@/lib/salary/agi/reporting-period' import type { EmployeeMasked, SalaryRunEmployee } from '@/types' export default function SalaryRunPage({ params }: { params: Promise<{ id: string }> }) { @@ -85,10 +90,11 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string // the progress rail and the panel's state machine (underlag submitted / // awaiting BankID signature / signed). Only booked runs can file AGI, so // the fetch is skipped (null period) for everything else. + // The period is the PAYOUT month (kontantprincipen), which for lön i + // efterskott is the month after run.period_*; every AGI and tax-payment + // surface below keys on it. const { submission: agiSubmission, refresh: refreshAgiSubmission } = useAgiSubmission( - run && run.status === 'booked' - ? `${run.period_year}${String(run.period_month).padStart(2, '0')}` - : null, + run && run.status === 'booked' ? formatAgiPeriodCompact(agiReportingPeriod(run)) : null, ) async function loadRun() { @@ -97,7 +103,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string const { data } = await res.json() setRun(data) if (data?.period_year && data?.period_month) { - const period = `${data.period_year}-${String(data.period_month).padStart(2, '0')}` + const period = formatAgiPeriodDashed(agiReportingPeriod(data)) setTaxPaymentLoading(true) void fetch(`/api/skatteverket/tax-payments/${period}`) .then(async (txRes) => (txRes.ok ? txRes.json() : null)) @@ -720,7 +726,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string } const blob = await res.blob() const url = URL.createObjectURL(blob) - const compactPeriod = `${run.period_year}${String(run.period_month).padStart(2, '0')}` + const compactPeriod = formatAgiPeriodCompact(agiReportingPeriod(run)) const a = document.createElement('a') a.href = url a.download = `AGI_${compactPeriod}.xml` @@ -930,7 +936,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string { expect(findCall('salary_run_employees', 'update')).toBeUndefined() }) - it('rejects a payment_date outside the run period month (kontantprincipen)', async () => { + it('accepts a payment_date in the month after the period (lön i efterskott, #2191)', async () => { + // The AGI redovisningsperiod follows payment_date, so a July run paid in + // August is legal: it is declared for August. const { supabase, enqueueMany, findCall } = createQueuedMockSupabase() authorize(supabase) enqueueMany([ - { data: DRAFT_RUN }, // lookup: period 2026-07 + { data: DRAFT_RUN }, // lookup: period 2026-07, paid 2026-07-25 + { data: { ...DRAFT_RUN, payment_date: '2026-08-25' } }, // update + { data: null }, // roster calculation_breakdown clear ]) const request = createMockRequest('/api/salary/runs/run-1', { method: 'PATCH', - body: { payment_date: '2026-08-01' }, + body: { payment_date: '2026-08-25' }, }) const response = await PATCH(request, createMockRouteParams({ id: 'run-1' })) - const { status, body } = await parseJsonResponse<{ error: string }>(response) + const { status } = await parseJsonResponse(response) - expect(status).toBe(400) - expect(body.error).toContain('period') - // Refused before any write. - expect(findCall('salary_runs', 'update')).toBeUndefined() + expect(status).toBe(200) + expect(findCall('salary_runs', 'update')).toEqual([ + expect.objectContaining({ payment_date: '2026-08-25' }), + ]) }) - it('grandfathers day adjustments when the current date is already outside the period', async () => { + it('still day-adjusts a run whose date already sits outside the period', async () => { const { supabase, enqueueMany, findCall } = createQueuedMockSupabase() authorize(supabase) diff --git a/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts b/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts index ca3d09df..5fe398dd 100644 --- a/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts @@ -167,6 +167,28 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { expect(body.error).toContain('redan skickats') }) + it('reports the payout month as the AGI period for lön i efterskott (#2191)', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: makeSalaryRun({ period_month: 3, payment_date: '2026-04-25' }) }, // March work, paid in April + { data: makeAgiDeclaration() }, + { data: null }, + ]) + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: { inlamningId: 'inl-124', kontrollresultat: { kontroller: [] } } }), + }) + + const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + const { status, body } = await parseJsonResponse<{ data: Record }>(response) + + expect(status).toBe(200) + expect(body.data.periodYear).toBe(2026) + expect(body.data.periodMonth).toBe(4) + }) + it('submits AGI draft and returns success', async () => { const { enqueueMany } = authed() enqueueMany([ diff --git a/app/api/salary/runs/[id]/agi/submit/route.ts b/app/api/salary/runs/[id]/agi/submit/route.ts index 33618eb4..3d44e0d0 100644 --- a/app/api/salary/runs/[id]/agi/submit/route.ts +++ b/app/api/salary/runs/[id]/agi/submit/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { withRouteContext } from '@/lib/api/with-route-context' import { eventBus } from '@/lib/events' +import { agiReportingPeriod } from '@/lib/salary/agi/reporting-period' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' ensureInitialized() @@ -115,12 +116,16 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( // (extensions/general/skatteverket/index.ts /agi/kvittenser route) when // it observes a uuidKvittens for the period, mirroring SKV's signeradTid. + // Payout month, not the earned month (kontantprincipen): the period + // the declaration was generated under and that Skatteverket answers for. + const agiPeriod = agiReportingPeriod(run) + await eventBus.emit({ type: 'agi.submitted', payload: { salaryRunId: id, - periodYear: run.period_year, - periodMonth: run.period_month, + periodYear: agiPeriod.periodYear, + periodMonth: agiPeriod.periodMonth, userId: user.id, companyId, }, @@ -130,8 +135,8 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( data: { ...submitData.data, salaryRunId: id, - periodYear: run.period_year, - periodMonth: run.period_month, + periodYear: agiPeriod.periodYear, + periodMonth: agiPeriod.periodMonth, message: 'AGI-underlag inläst hos Skatteverket. Skapa granskningsunderlag och signera med BankID i Mina Sidor.', }, }) diff --git a/app/api/salary/runs/[id]/route.ts b/app/api/salary/runs/[id]/route.ts index c29d3f67..3755afe3 100644 --- a/app/api/salary/runs/[id]/route.ts +++ b/app/api/salary/runs/[id]/route.ts @@ -225,27 +225,11 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( return NextResponse.json({ error: 'Anteckningen får vara högst 2000 tecken' }, { status: 400 }) } - // Kontantprincipen guard (SFL 26 kap): the AGI derives its - // redovisningsperiod from period_year/period_month while the verifikat - // books on payment_date, so a payment date outside the run's period month - // would post the entries in one month and declare them in another. Same - // rule as lib/salary/update-run.ts and the v1 PATCH, including the - // grandfather clause: a run created with an out-of-period payment date - // may still be day-adjusted within that same month. - if (typeof updates.payment_date === 'string') { - const periodPrefix = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` - const newMonth = updates.payment_date.slice(0, 7) - const currentMonth = String(run.payment_date).slice(0, 7) - if (newMonth !== periodPrefix && newMonth !== currentMonth) { - return NextResponse.json( - { - error: - 'Utbetalningsdagen måste ligga i lönekörningens period: AGI redovisas per utbetalningsmånad.', - }, - { status: 400 }, - ) - } - } + // The payment date may leave the run's period month: the AGI + // redovisningsperiod follows payment_date (kontantprincipen, #2191), so a + // run for August paid 25 September is declared in September, and the + // verifikat books on the same date. Same rule as lib/salary/update-run.ts + // and the v1 PATCH. // Optimistic lock on status='draft': a concurrent step advancing the run // (Beräkna → Till granskning in another tab) between the fetch above and diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts index 61ad713b..e4ff8f0f 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts @@ -150,7 +150,7 @@ registerEndpoint({ pitfalls: [ 'Returns 400 SALARY_RUN_PATCH_NOT_DRAFT if status !== "draft".', 'period_year + period_month are immutable post-create.', - 'payment_date must stay within the run\'s period month (400 SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD otherwise): the AGI is declared per payment month. A run whose current payment date already sits outside the period month may still be day-adjusted within that same month.', + 'payment_date may fall outside the run\'s period month (lön i efterskott): the AGI redovisningsperiod follows the payment month (kontantprincipen), so a run for August paid on 25 September is declared for September.', 'Supplying payment_date clears every roster row\'s calculation_breakdown, so an already-calculated run must be recalculated before :approve/:book.', ], example: { @@ -235,27 +235,10 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string return ok(existing, { requestId: ctx.requestId }) } - // Kontantprincipen guard (SFL 26 kap): the AGI derives its - // redovisningsperiod from period_year/period_month while the verifikat - // books on payment_date, so a payment date outside the run's period month - // would post the entries in one month and declare them in another. Same - // rule as lib/salary/update-run.ts and the internal dashboard PATCH, - // including the grandfather clause: a run created with an out-of-period - // payment date may still be day-adjusted within that same month, since - // creation does not (yet) enforce the coupling. No move can introduce a - // NEW wrong month. - if (typeof updates.payment_date === 'string') { - const ex = existing as { period_year: number; period_month: number; payment_date: string } - const periodPrefix = `${ex.period_year}-${String(ex.period_month).padStart(2, '0')}` - const newMonth = updates.payment_date.slice(0, 7) - const currentMonth = ex.payment_date.slice(0, 7) - if (newMonth !== periodPrefix && newMonth !== currentMonth) { - return v1ErrorResponseFromCode('SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD', ctx.log, { - requestId: ctx.requestId, - details: { period: periodPrefix, payment_date: updates.payment_date }, - }) - } - } + // The payment date may leave the run's period month: the AGI + // redovisningsperiod follows payment_date (kontantprincipen, #2191), so + // the verifikat and the declaration always share a month. Same rule as + // lib/salary/update-run.ts and the dashboard PATCH. if (ctx.dryRun) { const merged = { ...(existing as object), ...updates } diff --git a/app/api/v1/companies/[companyId]/salary-runs/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/salary-runs/__tests__/route.test.ts index 8c78876f..75706f42 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/__tests__/route.test.ts @@ -383,12 +383,14 @@ describe('PATCH /api/v1/companies/:companyId/salary-runs/:id', () => { expect(flex.from).toHaveBeenCalledWith('salary_run_employees') }) - it('returns 400 SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD for a cross-month payment_date', async () => { - // Period 2026-05: moving the payment into June would book the verifikat - // in June while the AGI still declares 202605 (kontantprincipen). + it('accepts a cross-month payment_date: the AGI follows the payout month (#2191)', async () => { + // Period 2026-05 paid 5 June (lön i efterskott): the verifikat books in + // June and the AGI is declared for 202606, so nothing is out of step. + const updated = { ...SAMPLE_RUN, payment_date: '2026-06-05' } const flex = makeFlexibleSupabase({ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, - salary_runs: { data: SAMPLE_RUN, error: null }, + salary_runs: [{ data: SAMPLE_RUN, error: null }, { data: updated, error: null }], + salary_run_employees: { data: null, error: null }, idempotency_keys: { data: null, error: null }, }) mockServiceClient.mockReturnValue(flex) @@ -401,10 +403,9 @@ describe('PATCH /api/v1/companies/:companyId/salary-runs/:id', () => { detailParams(COMPANY_ID, RUN_ID), ) - expect(res.status).toBe(400) + expect(res.status).toBe(200) const body = await res.json() - expect(body.error.code).toBe('SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD') - expect(flex.from).not.toHaveBeenCalledWith('salary_run_employees') + expect(body.data.payment_date).toBe('2026-06-05') }) it('grandfathers day adjustments when the current date is already outside the period', async () => { diff --git a/components/salary/run/RunHeader.tsx b/components/salary/run/RunHeader.tsx index 329a9828..b89d84d2 100644 --- a/components/salary/run/RunHeader.tsx +++ b/components/salary/run/RunHeader.tsx @@ -3,6 +3,11 @@ import { useState } from 'react' import Link from 'next/link' import { useTranslations } from 'next-intl' +import { + agiPeriodDiffersFromRunPeriod, + agiReportingPeriod, + formatAgiPeriodDashed, +} from '@/lib/salary/agi/reporting-period' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -163,12 +168,12 @@ export function RunHeader({ if (!saved) setPaymentDateDraft(run.payment_date) } - // The payment date must stay within the run's period month: the AGI is - // declared per payment month (kontantprincipen), so the API refuses dates - // outside it. min/max keeps the native picker inside the month; typed - // values outside it still get the server's 400 toast. - const periodMonthPrefix = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` - const periodMonthLastDay = new Date(run.period_year, run.period_month, 0).getDate() + // The AGI is declared for the PAYOUT month (kontantprincipen), so a run + // paid the month after its period (lön i efterskott) files under that + // later month. Say so in the meta line whenever the two differ: nothing + // else on the page explains why "augusti" is declared in September. + const agiPeriod = agiReportingPeriod(run) + const agiPeriodDiffers = agiPeriodDiffersFromRunPeriod(run) const metaParts: React.ReactNode[] = [ @@ -177,8 +182,6 @@ export function RunHeader({ setPaymentDateDraft(e.target.value)} onBlur={commitPaymentDate} onKeyDown={(e) => { @@ -194,6 +197,13 @@ export function RunHeader({ , {t('header_employees', { count: employeeCount })}, ] + if (agiPeriodDiffers) { + metaParts.push( + + {t('agi_period_note', { period: formatAgiPeriodDashed(agiPeriod) })} + , + ) + } if (run.is_correction && run.corrects_run_id) { metaParts.push( { expect(result.preview.new_payment_date).toBe('2026-04-07') }) - it('rejects a payment_date outside the run period month (kontantprincipen)', async () => { + it('stages a payment_date in the month after the period (lön i efterskott, #2191)', async () => { + // The AGI redovisningsperiod follows payment_date, so a March run paid + // in April is declared for April; no period guard refuses it. const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: RUN_ROW }) // draft gate passes; period is 2026-03 + enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings + enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods + enqueue({ data: { id: 'op-1' }, error: null }) // pending_operations insert - await expect( - updateSalaryRun.execute( - { salary_run_id: 'run-1', payment_date: '2026-04-05' }, - 'company-1', 'user-1', supabase as never, { type: 'user' }, - ), - ).rejects.toThrow(/SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD/) + const result = (await updateSalaryRun.execute( + { salary_run_id: 'run-1', payment_date: '2026-04-05' }, + 'company-1', 'user-1', supabase as never, { type: 'user' }, + )) as { staged: boolean; preview: Record } + + expect(result.staged).toBe(true) + expect(result.preview.new_payment_date).toBe('2026-04-05') }) it('throws SALARY_RUN_NOT_FOUND for a foreign or unknown run', async () => { diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 3fa9ceae..b1c97f01 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -15438,7 +15438,7 @@ export const tools: McpTool[] = [ additionalProperties: false, properties: { salary_run_id: { type: 'string', description: 'UUID of the salary run (must be draft)' }, - payment_date: { type: 'string', description: 'New payment date (YYYY-MM-DD); the date the salary verifikat will be booked on. Must stay within the run\'s period month (AGI is declared per payment month); supplying it clears the run\'s calculation.' }, + payment_date: { type: 'string', description: 'New payment date (YYYY-MM-DD); the date the salary verifikat will be booked on. May fall outside the run\'s period month (lön i efterskott): the AGI is declared for the payment month. Supplying it clears the run\'s calculation.' }, voucher_series: { type: 'string', description: 'Voucher series letter (single A-Z)' }, notes: { type: ['string', 'null'], description: 'Free-text note on the run (max 2000 chars); null clears it' }, }, diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 03e0ad55..b22b4c43 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -3158,8 +3158,11 @@ const SALARY: Record = { // month, the run itself belongs in that period. SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD: { httpStatus: 400, - message_sv: 'Utbetalningsdagen måste ligga i lönekörningens period: AGI redovisas per utbetalningsmånad. Skapa en lönekörning för rätt period i stället.', - message_en: 'The payment date must fall within the salary run\'s period month: the AGI is declared per payment month. Create a salary run for the correct period instead.', + // No longer raised (#2191): the AGI period follows payment_date, so a + // payout in another month is legal. Kept so clients mapping the code + // keep compiling. + message_sv: 'Utbetalningsdagen måste ligga i lönekörningens period.', + message_en: 'The payment date must fall within the salary run\'s period month.', }, SALARY_RUN_DELETE_NOT_DRAFT: { httpStatus: 400, @@ -3211,6 +3214,13 @@ const SALARY: Record = { message_sv: 'AGI kan endast genereras för lönekörningar i status review, approved, paid, booked eller corrected.', message_en: 'AGI can only be generated for salary runs in review, approved, paid, booked, or corrected status.', }, + AGI_PERIOD_CONFLICT: { + httpStatus: 409, + message_sv: + 'En annan lönekörning är redan deklarerad för samma redovisningsperiod (utbetalningsmånad). En arbetsgivardeklaration per månad ska omfatta alla utbetalningar den månaden: slå ihop körningarna eller rätta den befintliga deklarationen.', + message_en: + 'Another salary run is already declared for the same reporting period (payout month). One employer declaration per month must cover every payment made that month: merge the runs or correct the existing declaration.', + }, AGI_INCOMPLETE_DATA: { httpStatus: 400, message_sv: 'AGI-data ofullständig: kontrollera att företaget har organisationsnummer, kontaktnamn, telefon och e-post.', diff --git a/lib/pending-operations/__tests__/payroll-executors.test.ts b/lib/pending-operations/__tests__/payroll-executors.test.ts index 5f68f83d..64012543 100644 --- a/lib/pending-operations/__tests__/payroll-executors.test.ts +++ b/lib/pending-operations/__tests__/payroll-executors.test.ts @@ -249,11 +249,15 @@ describe('commitPendingOperation: update_salary_run', () => { expect(findCall('salary_run_employees', 'update')).toEqual([{ calculation_breakdown: null }]) }) - it('rejects a payment_date outside the run period month with 400', async () => { + it('commits a payment_date in the month after the period (lön i efterskott, #2191)', async () => { + // The AGI redovisningsperiod follows payment_date, so a March run paid + // in April is declared for April; no period guard refuses it. const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: RUN_ROW }) // draft gate passes; period is 2026-03 - enqueue({ data: null, error: null }) // finalize (rejected) + enqueue({ data: { ...RUN_ROW, payment_date: '2026-04-05' } }) // optimistic-locked update + enqueue({ data: null, error: null }) // roster calculation_breakdown clear + enqueue({ data: null, error: null }) // finalize const op = makePendingOp({ operation_type: 'update_salary_run', @@ -261,8 +265,8 @@ describe('commitPendingOperation: update_salary_run', () => { }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) - expect(result.status).not.toBe('committed') - expect(result.http_status).toBe(400) + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ payment_date: '2026-04-05' }) }) it('fails when the calculation-invalidation clear errors (guard is not best-effort)', async () => { diff --git a/lib/salary/__tests__/agi-generate-declaration.test.ts b/lib/salary/__tests__/agi-generate-declaration.test.ts index 9ed6a4c1..5ea14882 100644 --- a/lib/salary/__tests__/agi-generate-declaration.test.ts +++ b/lib/salary/__tests__/agi-generate-declaration.test.ts @@ -34,6 +34,7 @@ const RUN = { status: 'approved', period_year: 2026, period_month: 6, + payment_date: '2026-06-25', total_gross: 55000, total_tax: 12000, calculation_params: {}, @@ -125,6 +126,87 @@ beforeEach(() => { eventBus.clear() }) +describe('generateAgiDeclaration: redovisningsperiod follows the payout month (#2191)', () => { + it('declares an August run paid 25 September under 202609, in the XML and the stored row', async () => { + const { supabase, enqueueMany, findCall, findCalls } = createQueuedMockSupabase() + enqueueMany([ + { data: { ...RUN, period_month: 8, payment_date: '2026-09-25' } }, // salary_runs select + { data: COMPANY }, + { data: SETTINGS }, + { data: PROFILE }, + { data: [REGULAR_ROW] }, + { data: [] }, // salary_absence_days + { data: null }, // agi_declarations maybeSingle (first generation) + { data: { id: 'agi-1' } }, // agi_declarations insert + { data: null }, // salary_runs update + ]) + + const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.periodYear).toBe(2026) + expect(result.periodMonth).toBe(9) + expect(result.xml).toContain('202609') + + // The lookup and the stored declaration key on the payout month too, so + // the kvittens and skattekonto flows (which read agi_declarations) agree + // with what Skatteverket answers for. + const eqCalls = findCalls('agi_declarations', 'eq') + expect(eqCalls).toContainEqual(['period_month', 9]) + expect(findCall('agi_declarations', 'insert')).toEqual([ + expect.objectContaining({ period_year: 2026, period_month: 9, salary_run_id: 'run-1' }), + ]) + }) + + it('refuses to overwrite another live run declared for the same payout month', async () => { + const { supabase, enqueueMany, findCall } = createQueuedMockSupabase() + enqueueMany([ + { data: { ...RUN, period_month: 8, payment_date: '2026-09-25' } }, + { data: COMPANY }, + { data: SETTINGS }, + { data: PROFILE }, + { data: [REGULAR_ROW] }, + { data: [] }, + { data: { id: 'agi-other', salary_run_id: 'run-2' } }, // 202609 already declared by run-2 + { data: { id: 'run-2', status: 'booked', period_year: 2026, period_month: 9 } }, + ]) + + const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS }) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.code).toBe('AGI_PERIOD_CONFLICT') + expect(result.details).toMatchObject({ period: '2026-09', other_salary_run_id: 'run-2' }) + expect(findCall('agi_declarations', 'update')).toBeUndefined() + expect(findCall('agi_declarations', 'insert')).toBeUndefined() + }) + + it('still treats a corrected run\'s declaration as the one to replace', async () => { + const { supabase, enqueueMany, findCall } = createQueuedMockSupabase() + enqueueMany([ + { data: { ...RUN, period_month: 8, payment_date: '2026-09-25', is_correction: true, corrects_run_id: 'run-0' } }, + { data: COMPANY }, + { data: SETTINGS }, + { data: PROFILE }, + { data: [REGULAR_ROW] }, + { data: [] }, + { data: { id: 'agi-0', salary_run_id: 'run-0' } }, // declared by the run this one corrects + { data: null }, // agi_declarations update + { data: null }, // salary_runs update + ]) + + const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.isCorrection).toBe(true) + expect(findCall('agi_declarations', 'update')).toEqual([ + expect.objectContaining({ is_correction: true, salary_run_id: 'run-1' }), + ]) + }) +}) + describe('generateAgiDeclaration: F-skatt payee (FK131 only, issue #315)', () => { it('reports F-skatt cash on FK131 only, never FK011/FK001, in a mixed roster', async () => { const { supabase, enqueueMany } = createQueuedMockSupabase() diff --git a/lib/salary/__tests__/agi-reporting-period.test.ts b/lib/salary/__tests__/agi-reporting-period.test.ts new file mode 100644 index 00000000..1d84d7af --- /dev/null +++ b/lib/salary/__tests__/agi-reporting-period.test.ts @@ -0,0 +1,53 @@ +/** + * The AGI redovisningsperiod follows the payout month (kontantprincipen), + * not the earned month on the run (#2191). + */ +import { describe, it, expect } from 'vitest' +import { + agiPeriodDiffersFromRunPeriod, + agiReportingPeriod, + formatAgiPeriodCompact, + formatAgiPeriodDashed, +} from '../agi/reporting-period' + +describe('agiReportingPeriod', () => { + it('uses the payout month for lön i efterskott (August work paid 25 September)', () => { + const run = { period_year: 2026, period_month: 8, payment_date: '2026-09-25' } + expect(agiReportingPeriod(run)).toEqual({ periodYear: 2026, periodMonth: 9 }) + expect(agiPeriodDiffersFromRunPeriod(run)).toBe(true) + }) + + it('crosses the year boundary with the payout date (December work paid in January)', () => { + const run = { period_year: 2026, period_month: 12, payment_date: '2027-01-25' } + expect(agiReportingPeriod(run)).toEqual({ periodYear: 2027, periodMonth: 1 }) + }) + + it('equals the run period when the pay goes out inside the earned month', () => { + const run = { period_year: 2026, period_month: 3, payment_date: '2026-03-25' } + expect(agiReportingPeriod(run)).toEqual({ periodYear: 2026, periodMonth: 3 }) + expect(agiPeriodDiffersFromRunPeriod(run)).toBe(false) + }) + + it('falls back to the run period when payment_date is missing or malformed', () => { + expect(agiReportingPeriod({ period_year: 2026, period_month: 6 })).toEqual({ + periodYear: 2026, + periodMonth: 6, + }) + expect(agiReportingPeriod({ period_year: 2026, period_month: 6, payment_date: null })).toEqual({ + periodYear: 2026, + periodMonth: 6, + }) + expect( + agiReportingPeriod({ period_year: 2026, period_month: 6, payment_date: '25/06/2026' }), + ).toEqual({ periodYear: 2026, periodMonth: 6 }) + expect( + agiReportingPeriod({ period_year: 2026, period_month: 6, payment_date: '2026-13-01' }), + ).toEqual({ periodYear: 2026, periodMonth: 6 }) + }) + + it('formats the compact and dashed forms with a zero-padded month', () => { + const period = { periodYear: 2026, periodMonth: 9 } + expect(formatAgiPeriodCompact(period)).toBe('202609') + expect(formatAgiPeriodDashed(period)).toBe('2026-09') + }) +}) diff --git a/lib/salary/agi/generate-declaration.ts b/lib/salary/agi/generate-declaration.ts index 331bfa9f..2e800254 100644 --- a/lib/salary/agi/generate-declaration.ts +++ b/lib/salary/agi/generate-declaration.ts @@ -30,6 +30,7 @@ import { AGIPayloadTooLargeError, } from './xml-generator' import type { AGIEmployeeData, AGICompanyData, AGITotals } from './xml-generator' +import { agiReportingPeriod, formatAgiPeriodDashed } from './reporting-period' import { eventBus } from '@/lib/events' import { truncateToWholeKronor } from '@/lib/money' import { @@ -169,6 +170,10 @@ export async function generateAgiDeclaration( } } + // The redovisningsperiod is the PAYOUT month (kontantprincipen), which for + // lön i efterskott is the month after run.period_*; see reporting-period.ts. + const agiPeriod = agiReportingPeriod(run) + // 2. Company + settings + profile (for contact info). const { data: company } = await supabase .from('companies') @@ -214,8 +219,8 @@ export async function generateAgiDeclaration( const companyData: AGICompanyData = { orgNumber: (settings?.org_number || company.org_number || '').trim(), companyName, - periodYear: run.period_year, - periodMonth: run.period_month, + periodYear: agiPeriod.periodYear, + periodMonth: agiPeriod.periodMonth, contactName: (profile?.full_name || companyName || '').trim(), contactPhone: (settings?.phone || '').trim(), contactEmail: (settings?.email || profile?.email || userEmail || '').trim(), @@ -504,18 +509,18 @@ export async function generateAgiDeclaration( { const now = new Date() const currentYM = now.getUTCFullYear() * 100 + (now.getUTCMonth() + 1) - const periodYM = run.period_year * 100 + run.period_month + const periodYM = agiPeriod.periodYear * 100 + agiPeriod.periodMonth if (periodYM > currentYM) { opLog.warn('AGI generated for future period', { companyId, - periodYear: run.period_year, - periodMonth: run.period_month, + periodYear: agiPeriod.periodYear, + periodMonth: agiPeriod.periodMonth, }) } else if (currentYM - periodYM > 13) { opLog.warn('AGI generated for period > 13 months past', { companyId, - periodYear: run.period_year, - periodMonth: run.period_month, + periodYear: agiPeriod.periodYear, + periodMonth: agiPeriod.periodMonth, }) } } @@ -526,12 +531,43 @@ export async function generateAgiDeclaration( // PGRST116 row-not-found error and abort what should be a clean insert. const { data: existingAgi } = await supabase .from('agi_declarations') - .select('id') + .select('id, salary_run_id') .eq('company_id', companyId) - .eq('period_year', run.period_year) - .eq('period_month', run.period_month) + .eq('period_year', agiPeriod.periodYear) + .eq('period_month', agiPeriod.periodMonth) .maybeSingle() + // Two runs may share a redovisningsperiod only when one corrects the + // other (a correction replaces the month's declaration wholesale). Any + // other run paid out in the same calendar month would have to be MERGED + // into one declaration, which this generator cannot do: overwriting the + // existing XML would file the wrong amounts, so refuse instead. Reachable + // now that the period follows payment_date (#2191): an August run paid + // 25 Sept and a September run paid 30 Sept both land in 202609. + if ( + existingAgi?.salary_run_id && + existingAgi.salary_run_id !== run.id && + existingAgi.salary_run_id !== run.corrects_run_id + ) { + const { data: otherRun } = await supabase + .from('salary_runs') + .select('id, status, period_year, period_month') + .eq('id', existingAgi.salary_run_id) + .eq('company_id', companyId) + .maybeSingle() + if (otherRun && otherRun.status !== 'corrected') { + return { + ok: false, + code: 'AGI_PERIOD_CONFLICT', + details: { + period: formatAgiPeriodDashed(agiPeriod), + other_salary_run_id: otherRun.id, + other_run_period: `${otherRun.period_year}-${String(otherRun.period_month).padStart(2, '0')}`, + }, + } + } + } + const isCorrection = !!existingAgi // 7. Generate XML. @@ -596,8 +632,8 @@ export async function generateAgiDeclaration( company_id: companyId, user_id: userId, salary_run_id: run.id, - period_year: run.period_year, - period_month: run.period_month, + period_year: agiPeriod.periodYear, + period_month: agiPeriod.periodMonth, xml_content: xml, individuppgifter, total_gross: run.total_gross, @@ -623,8 +659,8 @@ export async function generateAgiDeclaration( .from('agi_declarations') .select('id') .eq('company_id', companyId) - .eq('period_year', run.period_year) - .eq('period_month', run.period_month) + .eq('period_year', agiPeriod.periodYear) + .eq('period_month', agiPeriod.periodMonth) .maybeSingle() if (refetchErr || !nowExisting) { return { ok: false, code: 'DATABASE_ERROR', details: refetchErr || insErr } @@ -650,8 +686,8 @@ export async function generateAgiDeclaration( agiDeclarationId = nowExisting.id as string opLog.warn('agi_declarations insert raced; recovered via update', { companyId, - periodYear: run.period_year, - periodMonth: run.period_month, + periodYear: agiPeriod.periodYear, + periodMonth: agiPeriod.periodMonth, }) // Note: the caller-facing `isCorrection` flag (set above based on // the pre-INSERT existingAgi lookup) reports `false` even though @@ -681,8 +717,8 @@ export async function generateAgiDeclaration( type: 'agi.generated', payload: { agiId: agiDeclarationId, - periodYear: run.period_year, - periodMonth: run.period_month, + periodYear: agiPeriod.periodYear, + periodMonth: agiPeriod.periodMonth, userId, companyId, }, @@ -711,8 +747,8 @@ export async function generateAgiDeclaration( ok: true, xml, agiDeclarationId, - periodYear: run.period_year, - periodMonth: run.period_month, + periodYear: agiPeriod.periodYear, + periodMonth: agiPeriod.periodMonth, employeeCount: employeeData.length, isCorrection, totals, diff --git a/lib/salary/agi/reporting-period.ts b/lib/salary/agi/reporting-period.ts new file mode 100644 index 00000000..9b6d431e --- /dev/null +++ b/lib/salary/agi/reporting-period.ts @@ -0,0 +1,61 @@ +/** + * The AGI redovisningsperiod of a salary run. + * + * Arbetsgivardeklarationen is filed for the calendar month in which the pay + * was PAID OUT (kontantprincipen, SFL 26 kap.), not the month the work was + * done. A run for August paid on 25 September is declared in September. + * + * salary_runs.period_year/period_month is the earned month (what the payslip + * says and what the run list groups by); payment_date is the day the money + * left the account and is therefore what decides the AGI period. The two + * coincide for lön i förskott (paid inside the earned month) and differ by a + * month for lön i efterskott (hourly pay settled the month after). + * + * Dependency-free on purpose: it is read by the generator, the submit route, + * the run page and the run header, so it must be safe in client bundles. + */ + +export interface AgiReportingPeriod { + periodYear: number + periodMonth: number +} + +type RunPeriodSource = { + payment_date?: string | null + period_year: number + period_month: number +} + +const ISO_YEAR_MONTH_RE = /^(\d{4})-(\d{2})/ + +/** + * Payout month of the run, falling back to the earned month only when + * payment_date is missing or unparseable (legacy or half-created rows). + */ +export function agiReportingPeriod(run: RunPeriodSource): AgiReportingPeriod { + const match = typeof run.payment_date === 'string' ? ISO_YEAR_MONTH_RE.exec(run.payment_date) : null + if (match) { + const year = Number(match[1]) + const month = Number(match[2]) + if (Number.isInteger(year) && month >= 1 && month <= 12) { + return { periodYear: year, periodMonth: month } + } + } + return { periodYear: run.period_year, periodMonth: run.period_month } +} + +/** True when the AGI period is not the earned month (lön i efterskott or förskott). */ +export function agiPeriodDiffersFromRunPeriod(run: RunPeriodSource): boolean { + const period = agiReportingPeriod(run) + return period.periodYear !== run.period_year || period.periodMonth !== run.period_month +} + +/** "202609": the compact form Skatteverket's AGI endpoints and settings keys use. */ +export function formatAgiPeriodCompact(period: AgiReportingPeriod): string { + return `${period.periodYear}${String(period.periodMonth).padStart(2, '0')}` +} + +/** "2026-09": the dashed form the tax-payment routes and user-facing copy use. */ +export function formatAgiPeriodDashed(period: AgiReportingPeriod): string { + return `${period.periodYear}-${String(period.periodMonth).padStart(2, '0')}` +} diff --git a/lib/salary/update-run.ts b/lib/salary/update-run.ts index 19b08508..202c210d 100644 --- a/lib/salary/update-run.ts +++ b/lib/salary/update-run.ts @@ -141,28 +141,11 @@ export async function updateDraftSalaryRun( } } - // Kontantprincipen guard (SFL 26 kap): the AGI derives its - // redovisningsperiod from period_year/period_month while the verifikat - // books on payment_date. A payment date outside the run's period month - // would post the entries in one month and declare them in another, so it - // is refused; a payment truly landing in another month belongs to a run - // for that period. Grandfather clause: creation does not (yet) enforce - // this coupling, so a run whose CURRENT payment date already sits outside - // the period month may still be day-adjusted within that same month - // (otherwise a legal create state would be uncorrectable). No move can - // introduce a NEW wrong month. - if (changes.payment_date !== undefined) { - const periodPrefix = `${row.period_year}-${String(row.period_month).padStart(2, '0')}` - const newMonth = changes.payment_date.slice(0, 7) - const currentMonth = row.payment_date.slice(0, 7) - if (newMonth !== periodPrefix && newMonth !== currentMonth) { - return { - ok: false, - code: 'SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD', - details: { period: periodPrefix, payment_date: changes.payment_date }, - } - } - } + // The payment date may leave the run's period month (lön i efterskott): + // the AGI redovisningsperiod follows payment_date (kontantprincipen, + // lib/salary/agi/reporting-period.ts), so the verifikat and the + // declaration always land in the same month. The former in-period guard + // (#2191) existed only because the AGI used to be keyed by period_*. const previous: SalaryRunHeaderValues = { payment_date: row.payment_date, diff --git a/messages/en.json b/messages/en.json index 0d38b8c0..171590c7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6782,6 +6782,7 @@ "back_to_salary": "Back to payroll", "payment_date_label": "Payment", "payment_date_edit_aria": "Change payment date", + "agi_period_note": "AGI is declared for {period} (the payout month)", "toast_payment_date_updated": "Payment date updated", "header_employees": "{count, plural, one {# employee} other {# employees}}", "not_found": "Payroll run not found", diff --git a/messages/sv.json b/messages/sv.json index e9fc9f0e..684794be 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -6782,6 +6782,7 @@ "back_to_salary": "Tillbaka till löner", "payment_date_label": "Utbetalning", "payment_date_edit_aria": "Ändra utbetalningsdag", + "agi_period_note": "AGI redovisas för {period} (utbetalningsmånaden)", "toast_payment_date_updated": "Utbetalningsdag uppdaterad", "header_employees": "{count, plural, one {# anställd} other {# anställda}}", "not_found": "Lönekörning hittades inte", diff --git a/skills/accounted-api/references/salary-runs.md b/skills/accounted-api/references/salary-runs.md index 5d95731c..9f489ee7 100644 --- a/skills/accounted-api/references/salary-runs.md +++ b/skills/accounted-api/references/salary-runs.md @@ -262,7 +262,7 @@ Updates payment_date, voucher_series, or notes on a draft salary run. ONLY allow **Pitfalls:** - Returns 400 SALARY_RUN_PATCH_NOT_DRAFT if status !== "draft". - period_year + period_month are immutable post-create. -- payment_date must stay within the run's period month (400 SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD otherwise): the AGI is declared per payment month. A run whose current payment date already sits outside the period month may still be day-adjusted within that same month. +- payment_date may fall outside the run's period month (lön i efterskott): the AGI redovisningsperiod follows the payment month (kontantprincipen), so a run for August paid on 25 September is declared for September. - Supplying payment_date clears every roster row's calculation_breakdown, so an already-calculated run must be recalculated before :approve/:book. | Parameter | In | Type | Required | Notes |