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
@@ -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 }
@@ -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 () => {