Files
accounted/lib/salary/agi/reporting-period.ts
T
cb39cded81 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>
2026-09-03 17:19:19 +02:00

62 lines
2.4 KiB
TypeScript

/**
* 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')}`
}