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
+1
View File
@@ -1525,4 +1525,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+12 -6
View File
@@ -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
<AGIPanel
salaryRunId={id}
arbetsgivare={run.arbetsgivare ?? ''}
period={`${run.period_year}${String(run.period_month).padStart(2, '0')}`}
period={formatAgiPeriodCompact(agiReportingPeriod(run))}
agiGeneratedAt={run.agi_generated_at}
agiSubmittedAt={run.agi_submitted_at}
submission={agiSubmission}
@@ -209,28 +209,32 @@ describe('PATCH /api/salary/runs/[id]', () => {
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)
@@ -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<string, unknown> }>(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([
+9 -4
View File
@@ -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.',
},
})
+5 -21
View File
@@ -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
@@ -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 () => {
+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
@@ -285,16 +285,22 @@ describe('gnubok_update_salary_run', () => {
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<string, unknown> }
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 () => {
+1 -1
View File
@@ -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' },
},
+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,
+1
View File
@@ -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",
+1
View File
@@ -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",
@@ -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 |