fix(payroll): declare AGI for the payout month, not the run's period month (#2191) (#2228)

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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-03 17:19:19 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 601e521584
commit cb39cded81
21 changed files with 387 additions and 134 deletions
+12 -2
View File
@@ -3158,8 +3158,11 @@ const SALARY: Record<string, StructuredErrorEntry> = {
// 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<string, StructuredErrorEntry> = {
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.',
@@ -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 () => {
@@ -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('<gem:RedovisningsPeriod faltkod="006">202609</gem:RedovisningsPeriod>')
// 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()
@@ -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')
})
})
+56 -20
View File
@@ -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,
+61
View File
@@ -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')}`
}
+5 -22
View File
@@ -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,