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
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user