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
+18 -8
View File
@@ -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[] = [
<span key="payment" className="inline-flex items-center gap-2">
@@ -177,8 +182,6 @@ export function RunHeader({
<Input
type="date"
value={paymentDateDraft}
min={`${periodMonthPrefix}-01`}
max={`${periodMonthPrefix}-${String(periodMonthLastDay).padStart(2, '0')}`}
onChange={(e) => setPaymentDateDraft(e.target.value)}
onBlur={commitPaymentDate}
onKeyDown={(e) => {
@@ -194,6 +197,13 @@ export function RunHeader({
</span>,
<span key="count">{t('header_employees', { count: employeeCount })}</span>,
]
if (agiPeriodDiffers) {
metaParts.push(
<span key="agi-period">
{t('agi_period_note', { period: formatAgiPeriodDashed(agiPeriod) })}
</span>,
)
}
if (run.is_correction && run.corrects_run_id) {
metaParts.push(
<Link