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:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
601e521584
commit
cb39cded81
@@ -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([
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user