Fix/skv connection flow (#1015)

* feat(salary): one-click AGI submission with filing state machine and success feedback

The AGI panel required users to know that "Ladda ner AGI-fil" was the
generate step, then click submit, signing link, and kvittens manually.
A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path
that does not exist.

- New primary button "Lamna in till Skatteverket" chains the existing
  endpoints client-side: generate XML if missing, POST underlag, poll
  kontrollresultat, create signing link, open Mina Sidor in a tab opened
  synchronously at click (popup-blocker safe). Inline stepper shows each
  step; the four old buttons become collapsed advanced/recovery actions,
  auto-expanded in stale-draft and rejected states. XML download stays
  visible and free for manual filing.
- deriveAgiFilingState() + useAgiSubmission() lift the per-period
  submission record to the run page: the progress rail and salary hero
  now render the real state machine (generated, underlag inskickat,
  vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of
  telling users to "lamna in" an already-submitted declaration.
- Success card with kvittensnummer and signature metadata once signed,
  plus a toast when a poll flips the state while the page is open.
- AGI kvittens cron every 15 min instead of every 2 h so filings signed
  on another device get stamped and emailed promptly.
- Advanced submit also auto-generates, and the stale "Lon -> AGI ->
  Generera" error text now points at the real buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup

The bank redirect landed on a blank page for the several seconds the
callback spent exchanging the PSD2 session and mirroring accounts, and
every failed connect attempt left a status='error' row that rendered
forever as an "Atgard kravs" card next to a successful retry, showing
duplicate connections to the same bank.

- Stream a branded "Slutfor bankanslutningen" progress page from the
  callback: the shell flushes before the session exchange starts and a
  script/meta redirect follows when the work completes, with a 30s
  slow-work escape hatch. Fast outcomes (denial, bad params, unknown
  state) keep their plain redirects.
- Delete never-activated connection rows (no session_id, no
  accounts_data) on denial or exchange failure, and sweep leftovers for
  the same bank on the next connect. Established connections keep their
  "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE
  SET NULL so deletion has no dependents.
- Show "Banken ar ansluten: hamtar dina konton" while the settings
  panel loads after the callback instead of an anonymous spinner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip

A direct POST to /api/invoices/[id]/send against an already-issued
invoice re-emailed the customer and posted a second revenue verifikat
(createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
and orphaning the first entry. Only the UI hid the button; the v1 route
and the MCP commit executor already rejected non-drafts.

- Non-draft invoices now return 409 INVOICE_ALREADY_SENT.
- The draft to sent status flip is an optimistic lock (status guard plus
  row-count check); journal entry, accrual schedules, PDF archival and
  the invoice.sent event only run for the request that won the flip.
- On a flip failure the journal entry is deferred: the row stays draft
  and a retry re-runs the pipeline, ending with exactly one verifikat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send

- sendInvoiceFromSchedule now auto-creates an online payment link via
  applyPaymentLinkToInvoice before rendering and passes the payment
  link QR to the PDF: parity with the dashboard and v1 send routes,
  which recurring invoices silently lacked.
- The recurring cron persists last_run_warning both when a claimed run
  throws (hourly retries stay visible on the schedule) and when a stale
  schedule is rolled forward, so a deterministic failure can no longer
  skip a month silently.
- Auto-send is blocked for sandbox companies at the email chokepoint
  (freeze-and-retain: the invoice is still generated as a draft),
  covering both the cron and the run-now route with one guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(salary): close the Fortnox payroll API gaps (phases 1-4)

Payroll now runs end-to-end through the open API, including onboarding a
client from another payroll system, with every write staged for approval.

- v1: per-employee payslips (list/detail/PDF), payslip line writes,
  run roster attach/remove, absence ranges (per-day storage), jamkning
  fields, cutover opening balances (single + atomic bulk PUT), vacation
  balance + vacation-year-close. PUT added to the wrapper's idempotency/
  test-key set (test keys could otherwise write through PUT).
- MCP: 10 new tools (get_employee/get_payslip/list_absence/
  get_vacation_balance reads + staged update_payslip_line,
  register_absence, create_employee, update_employee,
  set_employee_opening_balances, close_vacation_year), executors, risk
  tiers, op-type CHECK expansions. create_employee encrypts personnummer
  at staging: pending_operations never holds plaintext.
- Scope-map audit retrofit: 11 formerly unmapped tools now scoped;
  BREAKING for keys that relied on the 4 default-allow writes.
- Cutover: employee_opening_balances (derived lock trigger, self-unlocks
  on run correction), engine YTD/karens/liability integration,
  Ingaende saldon section in the employee editor.
- Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the
  hourly/daily divisors; legacy 173/21 preserved exactly at defaults so
  existing pay math is byte-identical.
- Vacation ledger + semesterberedning/arsavslut: recomputed per-year day
  balances (synced on book/correct, non-fatal), year-close with the
  min-20 floor, 5-year sparade-dagar expiry to forced payout, and a
  2920/2940 drift adjustment via the bookkeeping engine; Semester
  dashboard card with preview-then-confirm dialog.
- Fix: Zod 4 defaults leak through .partial(), which made every sparse
  employee PATCH fail validation and reset defaulted columns.

Migrations 20260713100000/101000/110000/121000/122000 (applied to
staging with version rows; prod via merge). vacation_ledger renamed from
20260713120000 to avoid colliding with vat_declaration_totals_rpc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC)

The dominant cost was infrastructure: Vercel functions ran in iad1
(Washington D.C.) while Supabase (DB + auth) lives in eu-north-1
(Stockholm), so every request paid 4-5 transatlantic round trips of
auth + company resolution before doing any real work (measured
530-1900ms for single-query GETs in prod logs). Pin functions to arn1
and cut the redundant work on top:

- vercel.json: functions to arn1, same city as the database
- getActiveCompanyId: preference + first-membership queries run in
  parallel; the fallback result doubles as validation in the common
  single-company case (one round trip instead of two sequential)
- withRouteContext: Server-Timing header and authMs/companyMs/handlerMs
  in the op-completed log, so latency is attributable per phase
- dashboard layout: nav badge counts off the critical path; DashboardNav
  loads them client-side via the new use-worklist-badges SWR hook with
  debounced realtime revalidation
- swr (new dependency, approved): global provider; useCompanySettings
  shares one cache entry across consumers and renders from cache on
  back-navigation instead of re-showing skeletons
- /pending: realtime refetch debounced; bulk operations previously
  fired 4 requests per row-change event
- VAT declaration: new get_vat_declaration_totals RPC returns
  per-account totals, settlement-shape detection (#984) and
  source_type counts in ONE round trip instead of paging every
  entry+line through PostgREST. Account lists stay TS-side parameters
  so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion
  coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts;
  DDL already applied to staging.
- bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat
  dynamic-imports the markdown parser, @vercel/speed-insights (new
  dependency, approved) added for real-user timings

The /salary fetch-waterfall fix from the same effort already landed
inside 2084a756.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): settle öre-rounded payments from the mark-paid flow

An invoice with öresavrundning shows a rounded "Att betala" on the PDF;
the customer pays that amount (up to 50 öre off the stored öre total) and
the invoice-page mark-paid flow rejected it with
MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction
match flow already absorbed the residual to 3740.

- PaymentBookingDialog now proposes the rounded bank leg plus the 3740
  residual line (credit when rounded up, debit when rounded down),
  resolved via getDisplayTotal from the per-invoice override and
  company_settings.ore_rounding.
- settleInvoicePayment and the v1 mark-paid route absorb the sub-krona
  residual, gated by planInvoicePaymentForLines: absorption applies ONLY
  when the caller lines carry the exact residual on 3740; otherwise the
  strict plan applies (sub-krona partials stay partial, no-3740
  overshoots keep the 400), so the GL can never diverge from the AR
  sub-ledger.
- planInvoicePayment absorb-band boundary tightened to >= 1 kr: an
  exactly-1-kr overshoot used to slip past both the guard and the absorb
  branch and silently over-record paid_amount (pre-existing on the
  bank-match path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): resolve all 7 PR compliance findings

- ASVS V3.3: per-request CSP nonce on the enable-banking finalize page
  (mirrors the mcp-oauth consent page); inline scripts are nonce-bound
- ASVS V16: decouple callback finalize work from the response stream
  (eager promise + next/server after()) so a client disconnect cannot
  drop session persistence or the consent_granted audit emit
- ISO 27001 A.8.15: failed audit-event emits log through the structured
  logger with a stable message for log-based alerting
- ASVS V2.3: recurring-invoice cron and run-now routes resolve
  isSandboxCompany themselves and pass an explicit suppressAutoSend flag
  (defence in depth around the email chokepoint, freeze-and-retain kept)
- ISO 27001 A.8.11: stagePendingOperation rejects plaintext
  personnummer-bearing keys in params/preview_data (key-based guard;
  EF org numbers make value-matching unsafe)
- ASVS V4.5: employee PATCH body is truly sparse; cleared number fields
  are omitted instead of resetting DB values to hardcoded fallbacks
- ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by
  convention, not 403) on the payslip PDF endpoint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: implement vacation-year basis change validation and error handling

- Added tests to block vacation-year basis changes when open balances exist.
- Implemented error handling for open-balances guard query failures in the settings route.
- Enhanced absence route to reject reversed date ranges with a validation error.
- Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability.
- Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules.
- Improved error messaging for vacation year closure adjustments.
- Adjusted employee opening balances handling to preserve audit information during upserts.

* feat(settings): add validation to block vacation-year basis change with open balances

feat(absence): reject reversed date ranges in absence queries

fix(absence): update absence handling to use atomic upserts instead of delete+insert

fix(employee): improve validation for jamkning dates in employee updates

fix(opening-balances): ensure created_by field is preserved during upserts

test(absence): enhance tests for absence range and date validations

test(calculation): add tests for age-based avgifter rates and edge cases

test(semesterberedning): validate vacation year closure adjustments and error handling

test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema

* fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-13 22:54:33 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e7e3c35f9e
commit b6332e9ff4
154 changed files with 18364 additions and 1867 deletions
+243
View File
@@ -0,0 +1,243 @@
/**
* Unit tests for lib/salary/absence.ts (payroll gap-closure 1.4).
*
* Range expansion (weekend skipping, 92-day cap), natural-key upsert flow,
* 24h-trigger mapping, and range deletes with counts.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
ABSENCE_RANGE_MAX_DAYS,
deleteAbsenceRange,
expandDateRange,
listAbsenceDays,
upsertAbsenceDay,
upsertAbsenceRange,
} from '@/lib/salary/absence'
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
let mock: ReturnType<typeof createQueuedMockSupabase>
let supabase: SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
mock = createQueuedMockSupabase()
supabase = mock.supabase as unknown as SupabaseClient
})
describe('expandDateRange', () => {
it('expands an inclusive range and skips weekends by default', () => {
// 2026-03-02 is a Monday; 2026-03-08 a Sunday.
const days = expandDateRange('2026-03-02', '2026-03-08')
expect(days).toEqual([
'2026-03-02',
'2026-03-03',
'2026-03-04',
'2026-03-05',
'2026-03-06',
])
})
it('includes weekends when asked', () => {
const days = expandDateRange('2026-03-06', '2026-03-08', { includeWeekends: true })
expect(days).toEqual(['2026-03-06', '2026-03-07', '2026-03-08'])
})
it('handles a single day (from == to)', () => {
expect(expandDateRange('2026-03-03', '2026-03-03')).toEqual(['2026-03-03'])
})
it('returns null for inverted ranges', () => {
expect(expandDateRange('2026-03-08', '2026-03-02')).toBeNull()
})
it('returns null when the span exceeds the cap', () => {
expect(expandDateRange('2026-01-01', '2026-06-30')).toBeNull()
// Exactly at the cap is fine.
expect(expandDateRange('2026-01-01', '2026-04-02')).not.toBeNull()
expect(ABSENCE_RANGE_MAX_DAYS).toBe(92)
})
it('crosses DST transitions without dropping or duplicating days', () => {
// Swedish DST switch on 2026-03-29: UTC-based math must stay per-day exact.
const days = expandDateRange('2026-03-27', '2026-03-31', { includeWeekends: true })
expect(days).toEqual(['2026-03-27', '2026-03-28', '2026-03-29', '2026-03-30', '2026-03-31'])
})
})
describe('upsertAbsenceRange', () => {
it('returns EMPLOYEE_NOT_FOUND for an unknown employee', async () => {
mock.enqueue({ data: null })
const result = await upsertAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-02',
to: '2026-03-06',
absenceType: 'sick',
})
expect(result).toEqual({ ok: false, code: 'EMPLOYEE_NOT_FOUND' })
})
it('returns ABSENCE_RANGE_TOO_LARGE beyond the cap', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
const result = await upsertAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-01-01',
to: '2026-12-31',
absenceType: 'sick',
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('ABSENCE_RANGE_TOO_LARGE')
})
it('bulk-upserts the expanded weekday rows in one statement', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({
data: [
{ id: '1', absence_date: '2026-03-02', absence_type: 'sick', hours: 8, notes: null, salary_run_employee_id: null, created_at: '', updated_at: '' },
{ id: '2', absence_date: '2026-03-03', absence_type: 'sick', hours: 8, notes: null, salary_run_employee_id: null, created_at: '', updated_at: '' },
],
})
const result = await upsertAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-02',
to: '2026-03-03',
absenceType: 'sick',
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.count).toBe(2)
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['employees', 'salary_absence_days'])
})
it('dry-run expands without touching salary_absence_days', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
const result = await upsertAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-02',
to: '2026-03-06',
absenceType: 'vab',
hoursPerDay: 4,
dryRun: true,
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.data.count).toBe(5)
expect(result.data.days[0]).toEqual({
absence_date: '2026-03-02',
absence_type: 'vab',
hours: 4,
})
}
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['employees'])
})
it('maps the 24h-cap trigger (23514) to ABSENCE_HOURS_CONFLICT', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({ data: null, error: { code: '23514', message: 'Total tid över 24h' } })
const result = await upsertAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-02',
to: '2026-03-02',
absenceType: 'sick',
hoursPerDay: 20,
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('ABSENCE_HOURS_CONFLICT')
})
})
describe('upsertAbsenceDay', () => {
it('replaces the (date, type) row via an atomic upsert', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({
data: {
id: '1',
absence_date: '2026-03-02',
absence_type: 'sick',
hours: 8,
notes: null,
salary_run_employee_id: null,
created_at: '',
updated_at: '',
},
})
const result = await upsertAbsenceDay(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
day: { absence_date: '2026-03-02', absence_type: 'sick', hours: 8 },
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.absence_date).toBe('2026-03-02')
})
})
describe('listAbsenceDays / deleteAbsenceRange', () => {
it('lists rows within the range', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({
data: [
{ id: '1', absence_date: '2026-03-02', absence_type: 'sick', hours: 8, notes: null, salary_run_employee_id: null, created_at: '', updated_at: '' },
],
})
const result = await listAbsenceDays(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-01',
to: '2026-03-31',
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data).toHaveLength(1)
})
it('deletes a range and reports the count', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({ data: null, count: 3 })
const result = await deleteAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-01',
to: '2026-03-31',
absenceType: 'sick',
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.deleted_count).toBe(3)
})
it('dry-run delete counts without deleting', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({ data: null, count: 2 })
const result = await deleteAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-01',
to: '2026-03-31',
dryRun: true,
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.deleted_count).toBe(2)
})
})
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { deriveAgiFilingState } from '../agi-submission-state'
describe('deriveAgiFilingState', () => {
const bare = { agi_generated_at: null, agi_submitted_at: null }
const generated = { agi_generated_at: '2026-07-13T09:48:58Z', agi_submitted_at: null }
it('returns none when nothing has happened', () => {
expect(deriveAgiFilingState(bare, null)).toBe('none')
expect(deriveAgiFilingState(bare, undefined)).toBe('none')
expect(deriveAgiFilingState(bare, {})).toBe('none')
})
it('returns generated when only the XML exists', () => {
expect(deriveAgiFilingState(generated, null)).toBe('generated')
})
it('follows the submission record through the filing steps', () => {
expect(deriveAgiFilingState(generated, { status: 'underlag_submitted' })).toBe(
'underlag_submitted',
)
expect(deriveAgiFilingState(generated, { status: 'awaiting_signing' })).toBe(
'awaiting_signing',
)
expect(deriveAgiFilingState(generated, { status: 'signed' })).toBe('signed')
})
it('treats a rejected underlag as back-to-generated', () => {
expect(deriveAgiFilingState(generated, { status: 'underlag_rejected' })).toBe('generated')
})
it('a rejected underlag with no generated XML falls back to none', () => {
expect(deriveAgiFilingState(bare, { status: 'underlag_rejected' })).toBe('none')
})
it('run.agi_submitted_at is authoritative over a stale submission record', () => {
const filed = { agi_generated_at: '2026-07-13T09:48:58Z', agi_submitted_at: '2026-07-13T10:02:11Z' }
expect(deriveAgiFilingState(filed, null)).toBe('signed')
expect(deriveAgiFilingState(filed, { status: 'awaiting_signing' })).toBe('signed')
expect(deriveAgiFilingState(filed, { status: 'underlag_submitted' })).toBe('signed')
})
it('a signed submission record counts even before the run row is stamped', () => {
expect(deriveAgiFilingState(generated, { status: 'signed', kvittensnummer: 'abc-123' })).toBe(
'signed',
)
})
})
+31 -14
View File
@@ -25,8 +25,10 @@ vi.mock('../personnummer', () => ({
return '199001011234' // Default: born 1990
},
calculateAgeAtYearStart: (pnr: string, year: number) => {
// Mirrors the real implementation: birth-year based (age attained by
// Dec 31 of the prior year), matching Skatteverket's cohort ranges.
const birthYear = parseInt(pnr.slice(0, 4))
return year - birthYear
return year - 1 - birthYear
},
}))
@@ -1029,6 +1031,19 @@ describe('calculateAvgifterRate', () => {
expect(result.category).toBe('reduced_65plus')
})
it('keeps the standard rate for born 1959 (turns 67 during 2026)', () => {
// "Fyllt 67 vid årets ingång" 2026 = born 1958 or earlier; born 1959
// gets the reduced rate from 2027. Jan-1 birthday, the exact edge.
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_1959' }),
config2026,
2026
)
expect(result.rate).toBe(0.3142)
expect(result.category).toBe('standard')
})
it('returns 0% for born ≤1937', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_old_person' }),
@@ -1056,12 +1071,14 @@ describe('calculateAvgifterRate', () => {
})
// Ungdomsrabatt 2026-2027 (Prop. 2025/26:66). Eligibility test is
// age >= 18 AND age < 23 at årets ingång. Cases below pin all four age
// boundaries plus the period-window edges.
// age >= 18 AND age < 23 at årets ingång, applied by Skatteverket as
// birth-year cohorts (2026: born 2003-2007). Cases below pin all four
// cohort boundaries plus the period-window edges. Fixture birthdays are
// Jan 1, the exact edge the old birthday-inclusive age math misread.
describe('youth rate (ungdomsrabatt 2026-2027)', () => {
it('NOT eligible: age 17 at year start (too young)', () => {
it('NOT eligible: born 2008 (17 vid årets ingång 2026, too young)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2009', paymentDate: '2026-05-25' }),
makeBasicInput({ personnummer: 'mock_born_2008', paymentDate: '2026-05-25' }),
config2026,
2026,
)
@@ -1069,9 +1086,9 @@ describe('calculateAvgifterRate', () => {
expect(result.rate).toBe(0.3142)
})
it('eligible: age 18 at year start (lower boundary)', () => {
it('eligible: born 2007 (18 vid årets ingång, lower boundary)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2008', paymentDate: '2026-05-25' }),
makeBasicInput({ personnummer: 'mock_born_2007', paymentDate: '2026-05-25' }),
config2026,
2026,
)
@@ -1079,9 +1096,9 @@ describe('calculateAvgifterRate', () => {
expect(result.rate).toBe(0.2081)
})
it('eligible: age 22 at year start (upper boundary)', () => {
it('eligible: born 2003 (22 vid årets ingång, upper boundary)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2004', paymentDate: '2026-05-25' }),
makeBasicInput({ personnummer: 'mock_born_2003', paymentDate: '2026-05-25' }),
config2026,
2026,
)
@@ -1091,9 +1108,9 @@ describe('calculateAvgifterRate', () => {
// Regression: this is the case Skatteverket's AGI validator rejected.
// The previous implementation incorrectly accepted age 23 at year start.
it('NOT eligible: age 23 at year start (just over)', () => {
it('NOT eligible: born 2002 (23 vid årets ingång, just over)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2003', paymentDate: '2026-05-25' }),
makeBasicInput({ personnummer: 'mock_born_2002', paymentDate: '2026-05-25' }),
config2026,
2026,
)
@@ -1101,7 +1118,7 @@ describe('calculateAvgifterRate', () => {
expect(result.rate).toBe(0.3142)
})
it('NOT eligible: age 22 but paid March 2026 (before period starts)', () => {
it('NOT eligible: in cohort but paid March 2026 (before period starts)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2004', paymentDate: '2026-03-15' }),
config2026,
@@ -1111,7 +1128,7 @@ describe('calculateAvgifterRate', () => {
expect(result.rate).toBe(0.3142)
})
it('NOT eligible: age 22 but paid October 2027 (after period ends)', () => {
it('NOT eligible: in cohort but paid October 2027 (after period ends)', () => {
const config2027: PayrollConfig = { ...config2026, configYear: 2027 }
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2005', paymentDate: '2027-10-10' }),
@@ -1152,7 +1169,7 @@ describe('calculateSalary: youth cap', () => {
it('applies 20.81% on first 25 000 SEK and 31.42% on the excess', () => {
const result = calculateSalary(
makeBasicInput({
personnummer: 'mock_born_2004', // age 22 at year start 2026
personnummer: 'mock_born_2004', // age 21 at year start 2026
paymentDate: '2026-06-25',
monthlySalary: 30000,
}),
@@ -190,6 +190,61 @@ describe('deriveAbsenceLineItems: sick', () => {
})
})
describe('deriveAbsenceLineItems: cutover karensPeriodsAdjustment', () => {
it('suppresses karens when the adjustment alone reaches the cap', () => {
// Mid-year switcher with 10 karens periods in the previous system and no
// imported absence rows: the 11th period must be suppressed even though
// the lookback here is empty.
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([['2026-04-06', 'sick']]),
karensPeriodsAdjustment: 10,
}),
)
expect(result.lineItems.find(li => li.item_type === 'sick_karens')).toBeUndefined()
// Day 1 with suppressed karens is paid normal: no deduction lines at all.
expect(result.aggregated.sickDays).toBe(1)
})
it('combines the adjustment with real lookback segments', () => {
// 8 imported periods + adjustment 2 = 10: cap reached, karens suppressed.
const lookback: string[] = []
for (let i = 0; i < 8; i++) {
const month = ((4 - 1 + 12 - i - 1) % 12) + 1
const year = i < 3 ? 2026 : 2025
lookback.push(`${year}-${String(month).padStart(2, '0')}-01`)
}
const capped = deriveAbsenceLineItems(
baseInput({
periodDays: days([['2026-04-15', 'sick']]),
lookbackSickDates: lookback,
karensPeriodsAdjustment: 2,
}),
)
expect(capped.lineItems.find(li => li.item_type === 'sick_karens')).toBeUndefined()
// Adjustment 1 leaves the count at 9: karens still deducted.
const belowCap = deriveAbsenceLineItems(
baseInput({
periodDays: days([['2026-04-15', 'sick']]),
lookbackSickDates: lookback,
karensPeriodsAdjustment: 1,
}),
)
expect(belowCap.lineItems.find(li => li.item_type === 'sick_karens')).toBeDefined()
})
it('zero/absent adjustment changes nothing', () => {
const withZero = deriveAbsenceLineItems(
baseInput({ periodDays: days([['2026-04-06', 'sick']]), karensPeriodsAdjustment: 0 }),
)
const without = deriveAbsenceLineItems(
baseInput({ periodDays: days([['2026-04-06', 'sick']]) }),
)
expect(withZero.lineItems).toEqual(without.lineItems)
})
})
describe('deriveAbsenceLineItems: VAB', () => {
it('emits VAB line item with deduction', () => {
const result = deriveAbsenceLineItems(
+264
View File
@@ -0,0 +1,264 @@
/**
* Unit tests for lib/salary/payslip-lines.ts (payroll gap-closure 1.2).
*
* The service is the single source of truth for payslip line commands across
* internal routes, v1, and the MCP executor: draft gate, run-membership
* verification, roundOre money math, account auto-resolution.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
createPayslipLine,
updatePayslipLine,
deletePayslipLine,
} from '@/lib/salary/payslip-lines'
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const SRE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const LINE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'
const BASE_INPUT = {
item_type: 'bonus' as const,
description: 'Kvartalsbonus',
amount: 5000,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: false,
is_net_deduction: false,
sort_order: 0,
}
const EXISTING_LINE = {
id: LINE_ID,
salary_run_employee_id: SRE_ID,
company_id: COMPANY_ID,
item_type: 'bonus',
description: 'Kvartalsbonus',
quantity: null,
unit_price: null,
amount: 5000,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: false,
is_net_deduction: false,
account_number: '7210',
sort_order: 0,
created_at: '2026-05-01T08:00:00Z',
updated_at: '2026-05-01T08:00:00Z',
salary_run_employee: { salary_run_id: RUN_ID },
}
let mock: ReturnType<typeof createQueuedMockSupabase>
let supabase: SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
mock = createQueuedMockSupabase()
supabase = mock.supabase as unknown as SupabaseClient
})
describe('createPayslipLine', () => {
it('returns SALARY_RUN_NOT_FOUND when the run is missing', async () => {
mock.enqueue({ data: null })
const result = await createPayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
target: { employeeId: EMPLOYEE_ID },
input: BASE_INPUT,
})
expect(result).toEqual({ ok: false, code: 'SALARY_RUN_NOT_FOUND' })
})
it('returns SALARY_RUN_LINE_NOT_DRAFT with the current status once the run advanced', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'review' } })
const result = await createPayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
target: { employeeId: EMPLOYEE_ID },
input: BASE_INPUT,
})
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('SALARY_RUN_LINE_NOT_DRAFT')
expect(result.details).toEqual({ current_status: 'review' })
}
})
it('returns SALARY_RUN_EMPLOYEE_NOT_FOUND when the employee is not in the run', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: null })
const result = await createPayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
target: { employeeId: EMPLOYEE_ID },
input: BASE_INPUT,
})
expect(result).toEqual({ ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' })
})
it('creates a line with roundOre money math and auto-resolved account', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: { id: SRE_ID, employee_id: EMPLOYEE_ID } })
mock.enqueue({ data: { ...EXISTING_LINE, amount: 1.01 } })
// 1.005 is the exact-half value where naive Math.round(x*100)/100 fails
// (rounds down to 1.00); roundOre must produce 1.01.
const result = await createPayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
target: { salaryRunEmployeeId: SRE_ID },
input: { ...BASE_INPUT, amount: 1.005 },
})
expect(result.ok).toBe(true)
// Assert what the service sent to the DB, not just what the mock echoed.
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['salary_runs', 'salary_run_employees', 'salary_line_items'])
})
it('dry-run validates and returns the would-be row without inserting', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: { id: SRE_ID, employee_id: EMPLOYEE_ID } })
const result = await createPayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
target: { employeeId: EMPLOYEE_ID },
input: { ...BASE_INPUT, amount: 1.005 },
dryRun: true,
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.data.id).toBeNull()
expect(result.data.amount).toBe(1.01)
// Auto-resolved from item_type 'bonus'.
expect(result.data.account_number).toBe('7210')
}
// Only the gate + sre lookups hit the DB; no insert.
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['salary_runs', 'salary_run_employees'])
})
})
describe('updatePayslipLine', () => {
it('returns SALARY_LINE_NOT_FOUND when the line belongs to a different run', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({
data: {
...EXISTING_LINE,
salary_run_employee: { salary_run_id: '99999999-9999-4999-8999-999999999999' },
},
})
const result = await updatePayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
lineId: LINE_ID,
patch: { amount: 6000 },
})
expect(result).toEqual({ ok: false, code: 'SALARY_LINE_NOT_FOUND' })
})
it('rounds a patched amount via roundOre and returns the updated row', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: EXISTING_LINE })
mock.enqueue({ data: { ...EXISTING_LINE, amount: 1.01, salary_run_employee: undefined } })
const result = await updatePayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
lineId: LINE_ID,
patch: { amount: 1.005 },
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.amount).toBe(1.01)
})
it('dry-run returns the merged row without writing', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: EXISTING_LINE })
const result = await updatePayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
lineId: LINE_ID,
patch: { amount: 1.005, description: 'Justerad bonus' },
dryRun: true,
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.data.amount).toBe(1.01)
expect(result.data.description).toBe('Justerad bonus')
}
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['salary_runs', 'salary_line_items'])
})
it('gates on draft status', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'booked' } })
const result = await updatePayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
lineId: LINE_ID,
patch: { amount: 6000 },
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('SALARY_RUN_LINE_NOT_DRAFT')
})
})
describe('deletePayslipLine', () => {
it('deletes a line in a draft run', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: EXISTING_LINE })
mock.enqueue({ data: null })
const result = await deletePayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
lineId: LINE_ID,
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.data.deleted).toBe(true)
expect(result.data.salary_line_item_id).toBe(LINE_ID)
}
})
it('dry-run verifies gates without deleting', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: EXISTING_LINE })
const result = await deletePayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
lineId: LINE_ID,
dryRun: true,
})
expect(result.ok).toBe(true)
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['salary_runs', 'salary_line_items'])
})
it('returns SALARY_LINE_NOT_FOUND for a missing line', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: null })
const result = await deletePayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
lineId: LINE_ID,
})
expect(result).toEqual({ ok: false, code: 'SALARY_LINE_NOT_FOUND' })
})
})
+12 -2
View File
@@ -73,10 +73,20 @@ describe('calculateAge', () => {
})
describe('calculateAgeAtYearStart', () => {
it('calculates age at January 1 of given year', () => {
expect(calculateAgeAtYearStart('199001019802', 2026)).toBe(36)
// Skatteverket applies "vid årets ingång fyllt X" rules as birth-year
// ranges, so the age is the one attained by December 31 of the prior year.
it('is birth-year based: same birth year means same year-start age', () => {
expect(calculateAgeAtYearStart('199006159802', 2026)).toBe(35)
expect(calculateAgeAtYearStart('199012319802', 2026)).toBe(35)
})
it('does not count a January 1 birthday as attained at årets ingång', () => {
// The 2026 youth cohort is born 2003-2007: born 2003-01-01 must read
// as 22 (eligible) and born 2008-01-01 as 17 (not eligible).
expect(calculateAgeAtYearStart('199001019802', 2026)).toBe(35)
expect(calculateAgeAtYearStart('200301011234', 2026)).toBe(22)
expect(calculateAgeAtYearStart('200801011234', 2026)).toBe(17)
})
})
describe('maskPersonnummer', () => {
+219
View File
@@ -0,0 +1,219 @@
/**
* Unit tests for lib/salary/run-employees.ts (payroll gap-closure 1.3).
*
* Attach/remove employees on a draft salary run: draft gate, active-employee
* check, duplicate 409, snapshot semantics, base-line seeding with roundOre.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { addEmployeeToRun, removeEmployeeFromRun } from '@/lib/salary/run-employees'
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const SRE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
const MONTHLY_EMPLOYEE = {
id: EMPLOYEE_ID,
employment_degree: 80,
monthly_salary: 35000,
hourly_rate: null,
salary_type: 'monthly',
employment_type: 'employee',
tax_table_number: 33,
tax_column: 1,
}
let mock: ReturnType<typeof createQueuedMockSupabase>
let supabase: SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
mock = createQueuedMockSupabase()
supabase = mock.supabase as unknown as SupabaseClient
})
describe('addEmployeeToRun', () => {
it('returns SALARY_RUN_NOT_FOUND for a missing run', async () => {
mock.enqueue({ data: null })
const result = await addEmployeeToRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result).toEqual({ ok: false, code: 'SALARY_RUN_NOT_FOUND' })
})
it('returns SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'approved' } })
const result = await addEmployeeToRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('SALARY_RUN_EMPLOYEES_NOT_DRAFT')
expect(result.details).toEqual({ current_status: 'approved' })
}
})
it('returns EMPLOYEE_NOT_FOUND for an inactive or unknown employee', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: null })
const result = await addEmployeeToRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result).toEqual({ ok: false, code: 'EMPLOYEE_NOT_FOUND' })
})
it('returns SALARY_RUN_EMPLOYEE_DUPLICATE when already attached', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: MONTHLY_EMPLOYEE })
mock.enqueue({ data: { id: SRE_ID } })
const result = await addEmployeeToRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('SALARY_RUN_EMPLOYEE_DUPLICATE')
})
it('attaches with a pay snapshot and seeds the base line (happy path)', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: MONTHLY_EMPLOYEE })
mock.enqueue({ data: null }) // duplicate check: none
mock.enqueue({
data: {
id: SRE_ID,
salary_run_id: RUN_ID,
employee_id: EMPLOYEE_ID,
company_id: COMPANY_ID,
employment_degree: 80,
monthly_salary: 35000,
salary_type: 'monthly',
hours_worked: null,
tax_table_number: 33,
tax_column: 1,
created_at: '2026-05-01T08:00:00Z',
updated_at: '2026-05-01T08:00:00Z',
},
})
mock.enqueue({ data: null }) // line insert
const result = await addEmployeeToRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result.ok).toBe(true)
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual([
'salary_runs',
'employees',
'salary_run_employees',
'salary_run_employees',
'salary_line_items',
])
})
it('dry-run returns the would-be snapshot without inserting', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: MONTHLY_EMPLOYEE })
mock.enqueue({ data: null }) // duplicate check: none
const result = await addEmployeeToRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
dryRun: true,
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.data.id).toBeNull()
expect(result.data.employee_id).toBe(EMPLOYEE_ID)
expect(result.data.employment_degree).toBe(80)
}
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
// Gate, employee lookup, duplicate check: no inserts.
expect(fromCalls).toEqual(['salary_runs', 'employees', 'salary_run_employees'])
})
it('maps a 23505 insert race to SALARY_RUN_EMPLOYEE_DUPLICATE', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: MONTHLY_EMPLOYEE })
mock.enqueue({ data: null }) // duplicate check passes...
mock.enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } }) // ...but insert races
const result = await addEmployeeToRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('SALARY_RUN_EMPLOYEE_DUPLICATE')
})
})
describe('removeEmployeeFromRun', () => {
it('removes an attached employee from a draft run', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: { id: SRE_ID } })
mock.enqueue({ data: null }) // delete
const result = await removeEmployeeFromRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.deleted).toBe(true)
})
it('returns SALARY_RUN_EMPLOYEE_NOT_FOUND when not attached', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: null })
const result = await removeEmployeeFromRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result).toEqual({ ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' })
})
it('gates on draft status', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'booked' } })
const result = await removeEmployeeFromRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('SALARY_RUN_EMPLOYEES_NOT_DRAFT')
})
it('dry-run verifies without deleting', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: { id: SRE_ID } })
const result = await removeEmployeeFromRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
dryRun: true,
})
expect(result.ok).toBe(true)
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['salary_runs', 'salary_run_employees'])
})
})
@@ -0,0 +1,353 @@
/**
* Semesterberedning + semesterårsavslut (payroll gap-closure 3.3).
*
* Table-driven beredning math (min-20 floor, 5-year expiry, proration) and
* the SEK reconcile + commit flow with mocked trial balance / engine.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
const mockTrialBalance = vi.fn()
vi.mock('@/lib/reports/trial-balance', () => ({
generateTrialBalance: (...a: unknown[]) => mockTrialBalance(...a),
}))
const mockSync = vi.fn()
vi.mock('@/lib/salary/vacation-ledger', async () => {
const actual = await vi.importActual<typeof import('@/lib/salary/vacation-ledger')>(
'@/lib/salary/vacation-ledger',
)
return {
...actual,
getVacationYearBasis: vi.fn().mockResolvedValue('calendar'),
syncVacationLedgerForEmployees: (...a: unknown[]) => mockSync(...a),
}
})
const mockCreateJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...a: unknown[]) => mockCreateJournalEntry(...a),
}))
const mockCheckPeriodLock = vi.fn()
vi.mock('@/lib/api/v1/check-period-lock', () => ({
checkPeriodLock: (...a: unknown[]) => mockCheckPeriodLock(...a),
}))
// Only decryption is mocked ('mock_born_YYYY' resolves to a mid-year birth
// in YYYY); the age-tier math runs the real calculateAgeAtYearStart.
vi.mock('@/lib/salary/personnummer', async () => {
const actual = await vi.importActual<typeof import('@/lib/salary/personnummer')>(
'@/lib/salary/personnummer',
)
return {
...actual,
decryptPersonnummer: (encrypted: string) => {
const m = /^mock_born_(\d{4})$/.exec(encrypted)
if (!m) throw new Error('not an encrypted fixture')
return `${m[1]}06151234`
},
}
})
import { previewVacationYearClose, commitVacationYearClose } from '@/lib/salary/semesterberedning'
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const ROSTER_ROW = {
id: EMPLOYEE_ID,
first_name: 'Anna',
last_name: 'Andersson',
vacation_rule: 'sammaloneregeln',
vacation_days_per_year: 25,
salary_type: 'monthly',
monthly_salary: 30000,
hourly_rate: null,
hours_per_week: 40,
workdays_per_week: 5,
employment_start: '2024-01-01',
employment_end: null,
}
let mock: ReturnType<typeof createQueuedMockSupabase>
let supabase: SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
mock = createQueuedMockSupabase()
supabase = mock.supabase as unknown as SupabaseClient
mockSync.mockResolvedValue({ ok: true })
mockCheckPeriodLock.mockResolvedValue({ locked: false })
mockTrialBalance.mockResolvedValue({
rows: [
{ account_number: '2920', closing_credit: 10000, closing_debit: 0 },
{ account_number: '2940', closing_credit: 3142, closing_debit: 0 },
],
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
})
})
/** Queue the preview's supabase calls: closure check, roster, ledger rows,
* fiscal period (for the booked-balance read). */
function queuePreview(over: {
ledger?: Array<Record<string, unknown>>
closure?: { id: string } | null
} = {}) {
mock.enqueue({ data: over.closure ?? null }) // vacation_year_closures check
mock.enqueue({ data: [ROSTER_ROW] }) // roster
mock.enqueue({ data: over.ledger ?? [] }) // ledger rows for the closing year
mock.enqueue({ data: { id: 'fp-2025', period_start: '2025-01-01', period_end: '2025-12-31' } }) // fiscal period
}
describe('previewVacationYearClose: beredning math', () => {
it('rolls only days above the 20-day floor and flags the rest', async () => {
queuePreview({
ledger: [
{
id: 'vb-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2025-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 15,
saved_days: {},
forced_payout_days: 0,
status: 'open',
},
],
})
const result = await previewVacationYearClose(supabase, COMPANY_ID, '2025-01-01')
expect(result.ok).toBe(true)
if (!result.ok) return
const row = result.data.rows[0]
// remaining 10, but only entitled - 20 = 5 are saveable.
expect(row.remaining_days).toBe(10)
expect(row.saveable_days).toBe(5)
expect(row.untaken_below_floor_days).toBe(5)
expect(row.saved_days_after).toEqual({ '2025': 5 })
expect(row.next_year_entitled).toBe(25)
})
it('expires saved days older than 5 years into forced payout', async () => {
queuePreview({
ledger: [
{
id: 'vb-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2025-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 25,
// 2020 was saveable through 2025: expires at THIS close.
saved_days: { '2020': 3, '2022': 2 },
forced_payout_days: 0,
status: 'open',
},
],
})
const result = await previewVacationYearClose(supabase, COMPANY_ID, '2025-01-01')
expect(result.ok).toBe(true)
if (!result.ok) return
const row = result.data.rows[0]
expect(row.expiring_days).toBe(3)
expect(row.saved_days_after).toEqual({ '2022': 2 })
})
it('computes the SEK drift against the booked 2920/2940', async () => {
queuePreview({
ledger: [
{
id: 'vb-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2025-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 15,
saved_days: { '2023': 2 },
forced_payout_days: 0,
status: 'open',
},
],
})
const result = await previewVacationYearClose(supabase, COMPANY_ID, '2025-01-01')
expect(result.ok).toBe(true)
if (!result.ok) return
const { sek, rows } = result.data
// Day value sammalöneregeln: 30000/21 + 30000 x 0.0043 = 1428.57 + 129 = 1557.57.
expect(rows[0].day_value_sek).toBe(1557.57)
// Liability days = remaining 10 + saved 2 = 12.
expect(rows[0].computed_liability_sek).toBe(18690.84)
expect(sek.computed_liability).toBe(18690.84)
expect(sek.booked_2920).toBe(10000)
expect(sek.drift_2920).toBe(8690.84)
expect(sek.adjustment_needed).toBe(true)
})
it('applies per-employee age-tier avgifter to the 2940 target', async () => {
const EMPLOYEE_2 = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const EMPLOYEE_3 = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
const ledgerRow = (employeeId: string) => ({
id: `vb-${employeeId}`,
employee_id: employeeId,
vacation_year_start: '2025-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 15,
saved_days: {},
forced_payout_days: 0,
status: 'open',
})
mock.enqueue({ data: null }) // closure check
mock.enqueue({
data: [
{ ...ROSTER_ROW, personnummer: 'mock_born_1990' },
{ ...ROSTER_ROW, id: EMPLOYEE_2, first_name: 'Sven', personnummer: 'mock_born_1957' },
{ ...ROSTER_ROW, id: EMPLOYEE_3, first_name: 'Ulla', personnummer: 'mock_born_1935' },
],
}) // roster
mock.enqueue({
data: [ledgerRow(EMPLOYEE_ID), ledgerRow(EMPLOYEE_2), ledgerRow(EMPLOYEE_3)],
}) // ledger rows
mock.enqueue({ data: { id: 'fp-2025', period_start: '2025-01-01', period_end: '2025-12-31' } })
const result = await previewVacationYearClose(supabase, COMPANY_ID, '2025-01-01')
expect(result.ok).toBe(true)
if (!result.ok) return
const { rows, sek } = result.data
// Settlement year 2026: born 1990 standard, born 1957 fyllt 67 vid
// årets ingång (10.21%), born 1935 exempt (0%).
expect(rows.map((r) => r.avgifter_rate)).toEqual([0.3142, 0.1021, 0])
// Each employee: 10 remaining days x 1557.57 = 15 575.70 liability.
// 15575.70 x 0.3142 = 4893.88, 15575.70 x 0.1021 = 1590.28, exempt 0.
expect(sek.computed_avgifter).toBe(6484.16)
expect(sek.drift_2940).toBe(3342.16)
})
it('refuses to close a year that has not ended', async () => {
const currentYear = new Date().getFullYear()
const result = await previewVacationYearClose(supabase, COMPANY_ID, `${currentYear}-01-01`)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('VACATION_YEAR_NOT_ENDED')
})
it('refuses to preview an already-closed year', async () => {
queuePreview({ closure: { id: 'closure-1' } })
const result = await previewVacationYearClose(supabase, COMPANY_ID, '2025-01-01')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('VACATION_YEAR_ALREADY_CLOSED')
})
})
describe('commitVacationYearClose', () => {
function queueCommitAfterPreview() {
mock.enqueue({ data: { id: 'closure-1' } }) // closure insert
mock.enqueue({ data: null }) // ledger close update
mock.enqueue({ data: null }) // next-year upsert
mock.enqueue({ data: { id: 'fp-2025' } }) // fiscal period for adjustment
mock.enqueue({ data: null }) // closure update with entry id
}
it('closes the year, rolls balances, and books the drift adjustment', async () => {
queuePreview({
ledger: [
{
id: 'vb-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2025-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 15,
saved_days: {},
forced_payout_days: 0,
status: 'open',
},
],
})
queueCommitAfterPreview()
mockCreateJournalEntry.mockResolvedValue({ id: 'je-adjust-1' })
const result = await commitVacationYearClose(supabase, COMPANY_ID, 'user-1', '2025-01-01', {
bookAdjustment: true,
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.data.closure_id).toBe('closure-1')
expect(result.data.adjustment_entry_id).toBe('je-adjust-1')
// The adjustment entry goes through the bookkeeping engine with balanced
// 7290/2920 + 7519/2940 legs.
const input = mockCreateJournalEntry.mock.calls[0][3] as {
entry_date: string
source_type: string
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}
expect(input.entry_date).toBe('2025-12-31')
expect(input.source_type).toBe('salary_payment')
const totalDebit = input.lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = input.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(totalDebit).toBeCloseTo(totalCredit, 2)
expect(input.lines.map((l) => l.account_number).sort()).toEqual(['2920', '2940', '7290', '7519'])
})
it('fails cleanly when the adjustment period is locked (before any writes)', async () => {
queuePreview({
ledger: [
{
id: 'vb-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2025-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 15,
saved_days: {},
forced_payout_days: 0,
status: 'open',
},
],
})
mockCheckPeriodLock.mockResolvedValue({ locked: true, reason: 'period_closed' })
const result = await commitVacationYearClose(supabase, COMPANY_ID, 'user-1', '2025-01-01', {
bookAdjustment: true,
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('PERIOD_LOCKED')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('maps a closure-insert conflict to VACATION_YEAR_ALREADY_CLOSED (replay)', async () => {
queuePreview({
ledger: [
{
id: 'vb-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2025-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 25,
saved_days: {},
forced_payout_days: 0,
status: 'open',
},
],
})
mock.enqueue({ data: null, error: { code: '23505', message: 'duplicate' } }) // closure insert races
const result = await commitVacationYearClose(supabase, COMPANY_ID, 'user-1', '2025-01-01', {
bookAdjustment: false,
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('VACATION_YEAR_ALREADY_CLOSED')
})
})
@@ -0,0 +1,181 @@
/**
* Vacation-year boundaries + ledger sync (payroll gap-closure 3.2).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
getClosableYearStart,
getVacationYearBounds,
getVacationYearStart,
} from '@/lib/salary/vacation-year'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
describe('vacation-year helpers', () => {
it('calendar basis: Jan 1 boundary', () => {
expect(getVacationYearStart('2026-07-13', 'calendar')).toBe('2026-01-01')
expect(getVacationYearStart('2026-01-01', 'calendar')).toBe('2026-01-01')
expect(getVacationYearBounds('2026-01-01')).toEqual({ start: '2026-01-01', end: '2027-01-01' })
expect(getClosableYearStart('2026-07-13', 'calendar')).toBe('2025-01-01')
})
it('statutory basis: Apr 1 boundary, Jan-Mar belongs to the previous start', () => {
expect(getVacationYearStart('2026-07-13', 'statutory_apr_mar')).toBe('2026-04-01')
expect(getVacationYearStart('2026-03-31', 'statutory_apr_mar')).toBe('2025-04-01')
expect(getVacationYearStart('2026-04-01', 'statutory_apr_mar')).toBe('2026-04-01')
expect(getVacationYearBounds('2025-04-01')).toEqual({ start: '2025-04-01', end: '2026-04-01' })
expect(getClosableYearStart('2026-02-15', 'statutory_apr_mar')).toBe('2024-04-01')
})
})
describe('syncVacationLedgerForEmployees', () => {
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
let mock: ReturnType<typeof createQueuedMockSupabase>
let supabase: SupabaseClient
let upserted: Array<Record<string, unknown>> | null
beforeEach(() => {
vi.clearAllMocks()
upserted = null
mock = createQueuedMockSupabase()
// Wrap from() to capture the upsert payload while keeping queue behavior.
const originalFrom = mock.supabase.from
mock.supabase.from = vi.fn((table: string) => {
const chain = originalFrom(table) as Record<string, unknown>
return new Proxy(chain as object, {
get(target, prop) {
if (prop === 'upsert' && table === 'employee_vacation_balances') {
return (rows: Array<Record<string, unknown>>) => {
upserted = rows
return (target as Record<string, (...a: unknown[]) => unknown>).upsert?.(rows) ?? target
}
}
return (target as Record<string | symbol, unknown>)[prop]
},
})
}) as never
supabase = mock.supabase as unknown as SupabaseClient
})
const queueBase = (over: {
basis?: string
booked?: Array<{ employee_id: string; vacation_days_taken: number; salary_run: { period_year: number; period_month: number; status: string } }>
openRows?: Array<Record<string, unknown>>
opening?: Array<Record<string, unknown>>
savedLegacy?: number
}) => {
mock.enqueue({ data: { salary_vacation_year_basis: over.basis ?? 'calendar' } }) // company_settings
mock.enqueue({
data: [
{
id: EMPLOYEE_ID,
vacation_days_per_year: 25,
vacation_days_saved: over.savedLegacy ?? 0,
vacation_rule: 'procentregeln',
},
],
}) // employees
mock.enqueue({ data: over.opening ?? [] }) // opening balances
mock.enqueue({ data: over.openRows ?? [] }) // existing open ledger rows
mock.enqueue({ data: over.booked ?? [] }) // booked sre rows
mock.enqueue({ data: null }) // upsert result
}
it('lazy-seeds the current year and recomputes taken from booked runs', async () => {
queueBase({
booked: [
{ employee_id: EMPLOYEE_ID, vacation_days_taken: 3, salary_run: { period_year: 2026, period_month: 6, status: 'booked' } },
{ employee_id: EMPLOYEE_ID, vacation_days_taken: 2, salary_run: { period_year: 2026, period_month: 7, status: 'booked' } },
// Prior year: outside the current vacation year bounds.
{ employee_id: EMPLOYEE_ID, vacation_days_taken: 5, salary_run: { period_year: 2025, period_month: 7, status: 'booked' } },
],
})
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
expect(upserted).toHaveLength(1)
const row = upserted![0]
expect(row.vacation_year_start).toBe('2026-01-01')
expect(row.entitled_days).toBe(25)
expect(row.taken_days).toBe(5)
// Calendar basis: sammanfallande year, accrued stays 0.
expect(row.accrued_days).toBe(0)
})
it('seeds entitled + saved days from the cutover opening row', async () => {
queueBase({
opening: [
{
employee_id: EMPLOYEE_ID,
cutover_date: '2026-07-01',
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: { '2025': 5 },
},
],
})
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
const row = upserted![0]
expect(row.entitled_days).toBe(12.5)
expect(row.saved_days).toEqual({ '2025': 5 })
})
it('seeds legacy vacation_days_saved under the previous year when no cutover row exists', async () => {
queueBase({ savedLegacy: 4 })
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
const row = upserted![0]
expect(row.saved_days).toEqual({ '2025': 4 })
})
it('recomputes existing open rows instead of duplicating them', async () => {
queueBase({
openRows: [
{
id: 'row-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2026-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 99, // stale: recompute must overwrite from booked runs
saved_days: { '2025': 2 },
forced_payout_days: 0,
status: 'open',
},
],
booked: [
{ employee_id: EMPLOYEE_ID, vacation_days_taken: 1, salary_run: { period_year: 2026, period_month: 5, status: 'booked' } },
],
})
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
expect(upserted).toHaveLength(1)
const row = upserted![0]
expect(row.taken_days).toBe(1)
expect(row.saved_days).toEqual({ '2025': 2 })
})
it('accrues toward next year on the statutory basis (elapsed months / 12)', async () => {
queueBase({ basis: 'statutory_apr_mar' })
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-10-15')
expect(result.ok).toBe(true)
const row = upserted![0]
expect(row.vacation_year_start).toBe('2026-04-01')
// Apr -> Oct = 6 whole months: 6/12 x 25 = 12.5.
expect(row.accrued_days).toBe(12.5)
})
it('never throws: DB errors return ok:false (non-fatal contract)', async () => {
mock.enqueue({ data: null }) // company_settings (defaults calendar)
mock.enqueue({ data: null, error: { message: 'boom' } }) // employees fails
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(false)
})
})
@@ -0,0 +1,104 @@
/**
* Non-default work-schedule threading (payroll gap-closure 4.2).
*
* The default-schedule regression proof is the EXISTING absence/accrual
* suites passing unchanged (they exercise the legacy 21 divisor). These
* tests cover the new path: a part-time schedule's divisor flows into sick,
* VAB, parental, unpaid-leave, and sammalöneregeln day valuations.
*/
import { describe, expect, it } from 'vitest'
import {
calculateVabDeduction,
calculateParentalLeaveDeduction,
calculateSjuklon,
} from '@/lib/salary/absence-calculator'
import { calculateVacationAccrual } from '@/lib/salary/calculation-engine'
import { deriveAbsenceLineItems, type AbsenceDay, type DeriveInput } from '@/lib/salary/derive-absence-line-items'
import { dailyDivisor } from '@/lib/salary/work-schedule'
import type { PayrollConfig } from '@/lib/salary/payroll-config'
const config = {
sjuklonRate: 0.8,
karensavdragFactor: 0.2,
maxKarensavdragPerYear: 10,
} as PayrollConfig
const days = (entries: Array<[string, AbsenceDay['absence_type']]>): AbsenceDay[] =>
entries.map(([d, t]) => ({ absence_date: d, absence_type: t, hours: 8 }))
const baseInput = (over: Partial<DeriveInput> = {}): DeriveInput => ({
monthlySalary: 30000,
payrollConfig: config,
periodDays: [],
lookbackSickDates: [],
vabDaysYtd: 0,
parentalDaysPregnancyYtd: 0,
...over,
})
describe('non-default schedule: 4-day week (divisor 17.33)', () => {
const divisor = dailyDivisor(4)
it('VAB deduction uses the schedule divisor', () => {
const fourDay = calculateVabDeduction(30000, 2, 0, divisor)
const fiveDay = calculateVabDeduction(30000, 2, 0)
// 30000 / 17.33 = 1731.1 per day vs 30000 / 21 = 1428.57.
expect(fourDay.deduction).toBe(3462.2)
expect(fiveDay.deduction).toBe(2857.14)
expect(fourDay.deduction).toBeGreaterThan(fiveDay.deduction)
})
it('parental leave deduction uses the schedule divisor', () => {
const result = calculateParentalLeaveDeduction(30000, 1, 0, divisor)
expect(result.deduction).toBe(1731.1)
})
it('sjuklön daily rate scales while the weekly karens base does not', () => {
const fourDay = calculateSjuklon(30000, 3, config, false, divisor)
const fiveDay = calculateSjuklon(30000, 3, config, false)
expect(fourDay.dailyRate).toBe(1731.1)
expect(fiveDay.dailyRate).toBe(1428.57)
// Karensavdrag derives from the WEEKLY rate (monthly x 12/52 x 80%),
// which is schedule-independent by construction.
expect(fourDay.karensavdrag).toBe(fiveDay.karensavdrag)
})
it('derived sick + unpaid-leave line items use the schedule divisor', () => {
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([
['2026-04-06', 'sick'],
['2026-04-07', 'sick'],
['2026-04-08', 'unpaid_leave'],
]),
dailyDivisor: divisor,
}),
)
const day2 = result.lineItems.find((li) => li.item_type === 'sick_day2_14')!
// Day 2 net deduction = dailyRate - dailyRate x 80% = 20% of 1731.1.
expect(Math.abs(day2.amount)).toBeCloseTo(1731.1 - 1731.1 * 0.8, 1)
const unpaid = result.lineItems.find((li) => li.item_type === 'unpaid_leave')!
expect(unpaid.amount).toBe(-1731.1)
})
it('sammalöneregeln accrual values days by the schedule divisor', () => {
const fourDay = calculateVacationAccrual({
monthlySalary: 30000,
vacationRule: 'sammaloneregeln',
vacationDaysPerYear: 25,
semestertillaggRate: 0.0043,
vacationBasis: 30000,
dailyDivisor: divisor,
})
const fiveDay = calculateVacationAccrual({
monthlySalary: 30000,
vacationRule: 'sammaloneregeln',
vacationDaysPerYear: 25,
semestertillaggRate: 0.0043,
vacationBasis: 30000,
})
// dailyRate 1731.1 vs 1428.57; tillägg = dailyRate x 0.43% x 25 days.
expect(fourDay.accrual).toBe(186.09)
expect(fiveDay.accrual).toBe(153.57)
})
})
@@ -0,0 +1,47 @@
/**
* Divisor helpers for arbetsschema-lite (payroll gap-closure 4.1).
*
* The load-bearing assertion is the LEGACY-CONSTANT contract: at the default
* schedule the helpers return 173/21 exactly (not the exact formulas), so
* existing companies' pay math is byte-identical after the feature lands.
*/
import { describe, expect, it } from 'vitest'
import { dailyDivisor, hourlyDivisor } from '@/lib/salary/work-schedule'
describe('hourlyDivisor', () => {
it('returns the legacy constant 173 at the 40h default (compat contract)', () => {
expect(hourlyDivisor(40)).toBe(173)
// Exact formula would be 173.33: asserting the difference keeps the
// discontinuity deliberate rather than accidental.
expect(hourlyDivisor(40)).not.toBeCloseTo((40 * 52) / 12, 2)
})
it('uses the exact formula for non-default schedules', () => {
expect(hourlyDivisor(32)).toBe(138.67)
expect(hourlyDivisor(20)).toBe(86.67)
expect(hourlyDivisor(60)).toBe(260)
})
it('treats null/undefined as the default schedule', () => {
expect(hourlyDivisor(null)).toBe(173)
expect(hourlyDivisor(undefined)).toBe(173)
})
})
describe('dailyDivisor', () => {
it('returns the legacy constant 21 at the 5-day default (compat contract)', () => {
expect(dailyDivisor(5)).toBe(21)
expect(dailyDivisor(5)).not.toBeCloseTo((5 * 52) / 12, 2)
})
it('uses the exact formula for non-default schedules', () => {
expect(dailyDivisor(4)).toBe(17.33)
expect(dailyDivisor(3)).toBe(13)
expect(dailyDivisor(6)).toBe(26)
})
it('treats null/undefined as the default schedule', () => {
expect(dailyDivisor(null)).toBe(21)
expect(dailyDivisor(undefined)).toBe(21)
})
})
+13 -8
View File
@@ -36,16 +36,19 @@ export function calculateSjuklon(
monthlySalary: number,
sickDays: number,
config: PayrollConfig,
isAterinsjuknande: boolean = false
isAterinsjuknande: boolean = false,
// Arbetsschema-lite: legacy 21 (5-day week) unless the employee's schedule
// says otherwise (dailyDivisor(workdays_per_week) from work-schedule.ts).
dailyDivisor: number = 21
): SjuklonResult {
const steps: AbsenceStep[] = []
const r = (x: number) => Math.round(x * 100) / 100
// Daily rate = monthly / 21 working days
const dailyRate = r(monthlySalary / 21)
// Daily rate = monthly / workdays-per-month divisor
const dailyRate = r(monthlySalary / dailyDivisor)
steps.push({
label: 'Dagslön',
formula: 'monthly_salary / 21',
formula: `monthly_salary / ${dailyDivisor}`,
input: { monthly_salary: monthlySalary },
output: dailyRate,
})
@@ -119,10 +122,11 @@ export function calculateSjuklon(
export function calculateVabDeduction(
monthlySalary: number,
vabDays: number,
totalVabDaysThisYear: number = 0
totalVabDaysThisYear: number = 0,
dailyDivisor: number = 21
): { deduction: number; semesterGrundande: boolean; steps: AbsenceStep[] } {
const r = (x: number) => Math.round(x * 100) / 100
const dailyRate = r(monthlySalary / 21)
const dailyRate = r(monthlySalary / dailyDivisor)
const deduction = r(dailyRate * vabDays)
const semesterGrundande = totalVabDaysThisYear + vabDays <= 120
@@ -145,10 +149,11 @@ export function calculateVabDeduction(
export function calculateParentalLeaveDeduction(
monthlySalary: number,
parentalDays: number,
totalParentalDaysThisPregnancy: number = 0
totalParentalDaysThisPregnancy: number = 0,
dailyDivisor: number = 21
): { deduction: number; semesterGrundande: boolean; steps: AbsenceStep[] } {
const r = (x: number) => Math.round(x * 100) / 100
const dailyRate = r(monthlySalary / 21)
const dailyRate = r(monthlySalary / dailyDivisor)
const deduction = r(dailyRate * parentalDays)
const semesterGrundande = totalParentalDaysThisPregnancy + parentalDays <= 120
+301
View File
@@ -0,0 +1,301 @@
/**
* Shared absence (frånvaro) commands.
*
* Single source of truth for reading and writing salary_absence_days rows,
* consumed by the internal dashboard route
* (app/api/salary/employees/[id]/absence), the v1 REST routes, and the MCP
* staged-operation executor (register_absence).
*
* Storage is strictly PER-DAY rows: sjuklönelagen mechanics (karensavdrag
* boundary, återinsjuknande 5-day merge, högriskskydd 12-month cap, day 14/15
* FK transition) and AGI 2025+ per-event Frånvarouppgift are all derived from
* day rows by the calculation engine. The API accepts ranges for ergonomics
* and expands them server-side.
*
* Upserts use a native ON CONFLICT upsert on the natural key (employee,
* date, type): atomic and truly idempotent, so PUT retries are safe and a
* rejected write (e.g. the 24h cap) never drops existing rows.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
export type AbsenceResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown> }
export interface AbsenceDayRow {
id: string
absence_date: string
absence_type: string
hours: number
notes: string | null
salary_run_employee_id: string | null
created_at: string
updated_at: string
}
/** Hard cap on range size: one quarter + buffer. Keeps payloads bounded and
* makes the range the pagination (no cursor needed on the GET). */
export const ABSENCE_RANGE_MAX_DAYS = 92
const ABSENCE_COLUMNS =
'id, absence_date, absence_type, hours, notes, salary_run_employee_id, created_at, updated_at'
async function assertEmployee(
supabase: SupabaseClient,
companyId: string,
employeeId: string,
): Promise<AbsenceResult<{ id: string }>> {
const { data, error } = await supabase
.from('employees')
.select('id')
.eq('id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
if (!data) {
return { ok: false, code: 'EMPLOYEE_NOT_FOUND' }
}
return { ok: true, data: data as { id: string } }
}
/** Expand [from, to] (inclusive, ISO dates) to per-day ISO strings.
* Returns null when the range is inverted or exceeds the cap. */
export function expandDateRange(
from: string,
to: string,
opts: { includeWeekends?: boolean } = {},
): string[] | null {
const start = Date.parse(`${from}T00:00:00Z`)
const end = Date.parse(`${to}T00:00:00Z`)
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) return null
const DAY_MS = 86_400_000
const spanDays = Math.round((end - start) / DAY_MS) + 1
if (spanDays > ABSENCE_RANGE_MAX_DAYS) return null
const dates: string[] = []
for (let t = start; t <= end; t += DAY_MS) {
const d = new Date(t)
const dow = d.getUTCDay() // 0 = Sunday, 6 = Saturday
if (!opts.includeWeekends && (dow === 0 || dow === 6)) continue
dates.push(d.toISOString().slice(0, 10))
}
return dates
}
function mapInsertError(error: { code?: string; message?: string }): {
code: string
details?: Record<string, unknown>
} {
// The 24h cap trigger raises check_violation when worked + absence > 24h
// for the same date.
if (error.code === '23514' || error.message?.includes('Total tid')) {
return { code: 'ABSENCE_HOURS_CONFLICT', details: { message: error.message } }
}
return { code: 'INTERNAL_ERROR', details: { message: error.message } }
}
export async function listAbsenceDays(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
from: string
to: string
absenceType?: string
},
): Promise<AbsenceResult<AbsenceDayRow[]>> {
const emp = await assertEmployee(supabase, args.companyId, args.employeeId)
if (!emp.ok) return emp
let query = supabase
.from('salary_absence_days')
.select(ABSENCE_COLUMNS)
.eq('company_id', args.companyId)
.eq('employee_id', args.employeeId)
.gte('absence_date', args.from)
.lte('absence_date', args.to)
.order('absence_date', { ascending: true })
if (args.absenceType) {
query = query.eq('absence_type', args.absenceType)
}
const { data, error } = await query
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: (data ?? []) as unknown as AbsenceDayRow[] }
}
export async function upsertAbsenceDay(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
day: {
absence_date: string
absence_type: string
hours: number
notes?: string | null
salary_run_employee_id?: string | null
}
},
): Promise<AbsenceResult<AbsenceDayRow>> {
const emp = await assertEmployee(supabase, args.companyId, args.employeeId)
if (!emp.ok) return emp
// Atomic upsert on the natural-key unique index: a rejected write (24h
// cap, constraint) leaves any existing row untouched.
const { data, error } = await supabase
.from('salary_absence_days')
.upsert(
{
company_id: args.companyId,
employee_id: args.employeeId,
absence_date: args.day.absence_date,
absence_type: args.day.absence_type,
hours: args.day.hours,
notes: args.day.notes ?? null,
salary_run_employee_id: args.day.salary_run_employee_id ?? null,
},
{ onConflict: 'employee_id,absence_date,absence_type' },
)
.select(ABSENCE_COLUMNS)
.single()
if (error) {
const mapped = mapInsertError(error)
return { ok: false, ...mapped }
}
return { ok: true, data: data as unknown as AbsenceDayRow }
}
export async function upsertAbsenceRange(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
from: string
to: string
absenceType: string
hoursPerDay?: number
notes?: string | null
includeWeekends?: boolean
/** Validate + expand only; return the would-be days without writing. */
dryRun?: boolean
},
): Promise<AbsenceResult<{ count: number; days: AbsenceDayRow[] | Array<{ absence_date: string; absence_type: string; hours: number }> }>> {
const emp = await assertEmployee(supabase, args.companyId, args.employeeId)
if (!emp.ok) return emp
const dates = expandDateRange(args.from, args.to, { includeWeekends: args.includeWeekends })
if (dates === null) {
return {
ok: false,
code: 'ABSENCE_RANGE_TOO_LARGE',
details: { from: args.from, to: args.to, max_days: ABSENCE_RANGE_MAX_DAYS },
}
}
const hours = args.hoursPerDay ?? 8
const rows = dates.map((absence_date) => ({
company_id: args.companyId,
employee_id: args.employeeId,
absence_date,
absence_type: args.absenceType,
hours,
notes: args.notes ?? null,
salary_run_employee_id: null,
}))
if (args.dryRun) {
return {
ok: true,
data: {
count: rows.length,
days: rows.map((r) => ({
absence_date: r.absence_date,
absence_type: r.absence_type,
hours: r.hours,
})),
},
}
}
if (rows.length === 0) {
return { ok: true, data: { count: 0, days: [] } }
}
// Bulk atomic upsert on the natural-key unique index (employee, date,
// type). One statement: a retry converges on the same end state, and a
// rejected write (e.g. the 24h cap on one day) rolls back the whole range
// without dropping the previously stored rows.
const { data, error } = await supabase
.from('salary_absence_days')
.upsert(rows, { onConflict: 'employee_id,absence_date,absence_type' })
.select(ABSENCE_COLUMNS)
if (error) {
const mapped = mapInsertError(error)
return { ok: false, ...mapped }
}
const inserted = (data ?? []) as unknown as AbsenceDayRow[]
return { ok: true, data: { count: inserted.length, days: inserted } }
}
export async function deleteAbsenceRange(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
from: string
to: string
absenceType?: string
/** Validate only; do not delete. */
dryRun?: boolean
},
): Promise<AbsenceResult<{ deleted_count: number }>> {
const emp = await assertEmployee(supabase, args.companyId, args.employeeId)
if (!emp.ok) return emp
if (args.dryRun) {
// Count what WOULD be deleted so the preview is informative.
let countQuery = supabase
.from('salary_absence_days')
.select('id', { count: 'exact', head: true })
.eq('company_id', args.companyId)
.eq('employee_id', args.employeeId)
.gte('absence_date', args.from)
.lte('absence_date', args.to)
if (args.absenceType) countQuery = countQuery.eq('absence_type', args.absenceType)
const { count, error } = await countQuery
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: { deleted_count: count ?? 0 } }
}
let query = supabase
.from('salary_absence_days')
.delete({ count: 'exact' })
.eq('company_id', args.companyId)
.eq('employee_id', args.employeeId)
.gte('absence_date', args.from)
.lte('absence_date', args.to)
if (args.absenceType) {
query = query.eq('absence_type', args.absenceType)
}
const { count, error } = await query
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: { deleted_count: count ?? 0 } }
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Derived AGI filing state for a salary run.
*
* Combines the run row's authoritative timestamps (agi_generated_at,
* agi_submitted_at) with the Skatteverket extension's per-period submission
* record (extension_data key `agi_submission_{period}`, surfaced via
* GET /api/extensions/ext/skatteverket/agi/status).
*
* The submission record is optional: self-hosted installs without the
* Skatteverket extension, users without the capability, and periods that
* were never submitted have none. The derivation then falls back to what
* the run row alone can tell ('none' | 'generated' | 'signed').
*/
/**
* Per-period submission state mirrored in extension_data under
* `agi_submission_{period}`. Matches the status enum the Skatteverket
* extension handlers write back.
*/
export interface AgiSubmissionState {
status?:
| 'underlag_submitted' // POST /underlag returned an inlamningId
| 'underlag_rejected' // kontrollresultat surfaced stoppande fel
| 'awaiting_signing' // skapaGranskningsunderlag returned a link
| 'signed' // kvittenser shows uuidKvittens for the period
signeringslank?: string
kvittensnummer?: string
signeradAv?: string
signeradTid?: string
inlamningId?: number
tillstand?: string
meddelande?: string
/** ISO timestamp the submission record was last written by the extension. */
updatedAt?: string
}
export type AgiFilingState =
| 'none'
| 'generated'
| 'underlag_submitted'
| 'awaiting_signing'
| 'signed'
export function deriveAgiFilingState(
run: { agi_generated_at?: string | null; agi_submitted_at?: string | null },
submission: AgiSubmissionState | null | undefined,
): AgiFilingState {
// agi_submitted_at is stamped when a kvittens is observed (the canonical
// filing receipt), so it is authoritative over the cached submission state.
if (run.agi_submitted_at || submission?.status === 'signed') return 'signed'
if (submission?.status === 'awaiting_signing') return 'awaiting_signing'
if (submission?.status === 'underlag_submitted') return 'underlag_submitted'
// underlag_rejected: the underlag at Skatteverket is dead; the user starts
// over from the generated XML, so it renders the same as plain 'generated'.
if (run.agi_generated_at) return 'generated'
return 'none'
}
+15 -4
View File
@@ -34,6 +34,11 @@ export interface SalaryCalculationInput {
vacationRule: 'procentregeln' | 'sammaloneregeln' | 'none' | 'semesterersattning'
vacationDaysPerYear: number
semestertillaggRate: number
/** Work-schedule daily-rate divisor (arbetsschema-lite). Defaults to the
* legacy 21 (5-day week); callers with a part-time schedule pass
* dailyDivisor(workdays_per_week) from lib/salary/work-schedule. Used by
* the sammalöneregeln day valuation. */
dailyDivisor?: number
/** Växa-stöd */
vaxaStodEligible: boolean
@@ -546,7 +551,7 @@ export function calculateSalary(
// since the base salary is expensed monthly regardless of vacation.
// Use baseSalary (degree-adjusted): a 50% part-timer's tillägg should be
// half a full-timer's, not the same.
const dailyRate = r(baseSalary / 21)
const dailyRate = r(baseSalary / (input.dailyDivisor ?? 21))
const tillagg = r(dailyRate * input.semestertillaggRate * input.vacationDaysPerYear)
vacationAccrual = tillagg
steps.push({
@@ -674,6 +679,8 @@ export function calculateAvgifterRate(
// eligible at year start (18-22) become 19-23 during the year. We test the
// year-start age, not the during-year age. Skatteverket's AGI validator
// rejects 23-year-olds at year start as not eligible.
// calculateAgeAtYearStart is birth-year based (2026: born 2003-2007), so
// January 1 birthdays land in the correct Skatteverket cohort.
// Active period: 1 April 2026 - 30 September 2027.
if (config.avgifterYouthRate !== null && ageAtYearStart >= 18 && ageAtYearStart <= 22) {
const [, monthStr] = input.paymentDate.split('-')
@@ -720,10 +727,12 @@ export function calculateKarensavdrag(monthlySalary: number, config: PayrollConf
export function calculateSjuklon(
monthlySalary: number,
sickDays: number,
config: PayrollConfig
config: PayrollConfig,
// Arbetsschema-lite: legacy 21 unless the employee's schedule differs.
dailyDivisor: number = 21
): { karensavdrag: number; sjuklon: number; totalDeduction: number; steps: CalculationStep[] } {
const steps: CalculationStep[] = []
const dailyRate = r(monthlySalary / 21)
const dailyRate = r(monthlySalary / dailyDivisor)
// Karensavdrag
const karensavdrag = calculateKarensavdrag(monthlySalary, config)
@@ -766,6 +775,8 @@ export function calculateVacationAccrual(params: {
vacationDaysPerYear: number
semestertillaggRate: number
vacationBasis: number
/** Arbetsschema-lite daily-rate divisor; legacy 21 when omitted. */
dailyDivisor?: number
}): { accrual: number; steps: CalculationStep[] } {
const steps: CalculationStep[] = []
@@ -803,7 +814,7 @@ export function calculateVacationAccrual(params: {
// Sammalöneregeln: tillägg per vacation day. Use vacationBasis as the
// degree-adjusted reference: callers must pass the part-time-adjusted
// monthly amount, never the raw full-time monthlySalary.
const dailyRate = r(params.vacationBasis / 21)
const dailyRate = r(params.vacationBasis / (params.dailyDivisor ?? 21))
const accrual = r(dailyRate * params.semestertillaggRate * params.vacationDaysPerYear)
steps.push({
label: `Semesteravsättning (sammalöneregeln ${fmtPct(params.semestertillaggRate)})`,
+26 -4
View File
@@ -163,6 +163,16 @@ export interface DeriveInput {
/** Parental leave days in the current pregnancy window (best-effort:
* defaults to calendar-year aggregate). */
parentalDaysPregnancyYtd: number
/** Cutover state (payroll gap-closure 2.2): karens periods in the 12
* months before cutover NOT represented by imported salary_absence_days
* rows. Added to the högriskskydd window count so a mid-year switcher's
* cap position carries over. The caller zeroes this once the lookback
* window no longer overlaps pre-cutover time. */
karensPeriodsAdjustment?: number
/** Work-schedule daily-rate divisor (arbetsschema-lite). Defaults to the
* legacy 21 (5-day week); part-time schedules pass
* dailyDivisor(workdays_per_week) from lib/salary/work-schedule. */
dailyDivisor?: number
}
export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
@@ -209,9 +219,14 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
const lookbackOnlySegments = buildSjukloneperioder(
input.lookbackSickDates.filter(d => d >= cutoff),
)
let karensInWindow = lookbackOnlySegments.length
// Cutover adjustment: karens periods from the previous payroll system
// that were never imported as day rows. Over-suppression of karens is
// the softer error (consistent with the period-count reading above).
let karensInWindow = lookbackOnlySegments.length + (input.karensPeriodsAdjustment ?? 0)
const dailyRate = r(monthlySalary / 21)
// weeklyRate stays monthly x 12/52 by construction (schedule-independent);
// only the DAILY rate scales with the workday schedule.
const dailyRate = r(monthlySalary / (input.dailyDivisor ?? 21))
const weeklyRate = r(monthlySalary * 12 / 52 * payrollConfig.sjuklonRate)
const karensAmount = r(weeklyRate * payrollConfig.karensavdragFactor)
@@ -304,7 +319,7 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
// ── VAB ────────────────────────────────────────────────────────────────
const vabCount = vabDays.length
if (vabCount > 0) {
const vab = calculateVabDeduction(monthlySalary, vabCount, input.vabDaysYtd)
const vab = calculateVabDeduction(monthlySalary, vabCount, input.vabDaysYtd, input.dailyDivisor)
lineItems.push({
item_type: 'vab',
description: `VAB (${vabCount} dagar)`,
@@ -324,6 +339,7 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
monthlySalary,
parentalCount,
input.parentalDaysPregnancyYtd,
input.dailyDivisor,
)
lineItems.push({
item_type: 'parental_leave',
@@ -347,7 +363,7 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
// the flag would double-count the amount in Step 4's gross_deduction sum.
const unpaidLeaveCount = unpaidLeaveDays.length
if (unpaidLeaveCount > 0) {
const dailyRate = r(monthlySalary / 21)
const dailyRate = r(monthlySalary / (input.dailyDivisor ?? 21))
const deduction = r(dailyRate * unpaidLeaveCount)
lineItems.push({
item_type: 'unpaid_leave',
@@ -386,6 +402,10 @@ export async function loadAndDeriveAbsence(params: {
payrollConfig: PayrollConfig
periodStart: string
periodEnd: string
/** See DeriveInput.karensPeriodsAdjustment. */
karensPeriodsAdjustment?: number
/** See DeriveInput.dailyDivisor. */
dailyDivisor?: number
}): Promise<DeriveResult> {
const { supabase, companyId, employeeId, periodStart, periodEnd } = params
@@ -440,5 +460,7 @@ export async function loadAndDeriveAbsence(params: {
lookbackSickDates,
vabDaysYtd,
parentalDaysPregnancyYtd,
karensPeriodsAdjustment: params.karensPeriodsAdjustment,
dailyDivisor: params.dailyDivisor,
})
}
+297
View File
@@ -0,0 +1,297 @@
/**
* Shared employee master-data commands for the MCP staged-operation
* executors (create_employee / update_employee).
*
* PII contract: these functions NEVER receive a plaintext personnummer. The
* staging tool validates the caller's input with CreateEmployeeSchema,
* encrypts the personnummer at staging time, and pending_operations.params
* carries only { personnummer_encrypted, personnummer_last4 }. Masked forms
* for previews/results are derived by decrypting in-process.
*
* The internal dashboard route (app/api/salary/employees) and the v1 routes
* have their own request-shaped handlers today; this module is the executor-
* facing command layer. (Future dedup opportunity noted in the gap-closure
* plan: fold all three onto this service.)
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
import { getCompanyEntityType } from '@/lib/company/context'
import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
export type EmployeeCommandResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown> }
export interface EmployeeSummaryResult {
employee_id: string
first_name: string
last_name: string
personnummer_masked: string
is_active: boolean
}
/** Columns an executor is allowed to write. Anything else in params is
* ignored (defense in depth against a tampered pending_operations row). */
const WRITABLE_COLUMNS = new Set([
'first_name',
'last_name',
'employment_type',
'employment_start',
'employment_end',
'employment_degree',
'hours_per_week',
'workdays_per_week',
'salary_type',
'monthly_salary',
'hourly_rate',
'tax_table_number',
'tax_column',
'tax_municipality',
'is_sidoinkomst',
'f_skatt_status',
'clearing_number',
'bank_account_number',
'vacation_rule',
'vacation_days_per_year',
'semestertillagg_rate',
'email',
'phone',
'address_line1',
'postal_code',
'city',
'vaxa_stod_eligible',
'vaxa_stod_start',
'vaxa_stod_end',
'jamkning_percentage',
'jamkning_valid_from',
'jamkning_valid_to',
'default_dimensions',
])
function pickWritable(fields: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [key, value] of Object.entries(fields)) {
if (WRITABLE_COLUMNS.has(key) && value !== undefined) {
out[key] = value
}
}
return out
}
export async function createEmployee(
supabase: SupabaseClient,
args: {
companyId: string
userId: string
/** Validated CreateEmployeeSchema fields, personnummer replaced by the
* encrypted pair at staging time. */
input: Record<string, unknown> & {
personnummer_encrypted: string
personnummer_last4: string
}
},
): Promise<EmployeeCommandResult<EmployeeSummaryResult>> {
const { personnummer_encrypted, personnummer_last4, ...rest } = args.input
if (!personnummer_encrypted || !personnummer_last4) {
return { ok: false, code: 'VALIDATION_ERROR', details: { field: 'personnummer_encrypted' } }
}
const fields = pickWritable(rest)
if (!fields.first_name || !fields.last_name || !fields.employment_start) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { message: 'first_name, last_name and employment_start are required' },
}
}
// EF owners cannot be on payroll (egna uttag, not lön). The DB trigger is
// the all-paths backstop; checking here gives a clean error. #782
const entityType = await getCompanyEntityType(supabase, args.companyId)
const employmentType = (fields.employment_type as string | undefined) ?? 'employee'
if (!isEmploymentTypeAllowedForEntity(entityType, employmentType as never)) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { field: 'employment_type', message: EF_OWNER_EMPLOYMENT_ERROR },
}
}
const bankIssues = validateEmployeeBankAccount(
fields.clearing_number as string | undefined,
fields.bank_account_number as string | undefined,
)
if (bankIssues.length > 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { issues: bankIssues.map((i) => ({ field: i.field, message: i.message })) },
}
}
const { data, error } = await supabase
.from('employees')
.insert({
company_id: args.companyId,
user_id: args.userId,
personnummer: personnummer_encrypted,
personnummer_last4,
employment_type: employmentType,
...fields,
})
.select('id, first_name, last_name, personnummer, is_active')
.single()
if (error) {
if (error.code === '23505') {
const constraint = (error as { constraint?: string }).constraint
if (!constraint || constraint.includes('personnummer')) {
return { ok: false, code: 'EMPLOYEE_DUPLICATE_PERSONNUMMER' }
}
}
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
const row = data as { id: string; first_name: string; last_name: string; personnummer: string; is_active: boolean }
return {
ok: true,
data: {
employee_id: row.id,
first_name: row.first_name,
last_name: row.last_name,
personnummer_masked: maskPersonnummer(decryptPersonnummer(row.personnummer)),
is_active: row.is_active,
},
}
}
export async function updateEmployee(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
/** Validated UpdateEmployeeSchema fields. Personnummer is rejected at the
* tool boundary (identity immutable); it is never accepted here either. */
patch: Record<string, unknown>
},
): Promise<EmployeeCommandResult<EmployeeSummaryResult>> {
if ('personnummer' in args.patch || 'personnummer_encrypted' in args.patch) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { field: 'personnummer', message: 'Identity is immutable post-create.' },
}
}
const updates = pickWritable(args.patch)
if (Object.keys(updates).length === 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { message: 'At least one updatable field is required.' },
}
}
const { data: existing, error: fetchError } = await supabase
.from('employees')
.select('*')
.eq('id', args.employeeId)
.eq('company_id', args.companyId)
.maybeSingle()
if (fetchError) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: fetchError.message } }
}
if (!existing) {
return { ok: false, code: 'EMPLOYEE_NOT_FOUND' }
}
// Merged-state validation (same rules as the internal PATCH route).
const merged = { ...(existing as Record<string, unknown>), ...updates }
const issues: string[] = []
if (merged.salary_type === 'monthly' && (!merged.monthly_salary || (merged.monthly_salary as number) <= 0)) {
issues.push('Månadslön krävs och måste vara större än 0 för månadslöneform')
}
if (merged.salary_type === 'hourly' && (!merged.hourly_rate || (merged.hourly_rate as number) <= 0)) {
issues.push('Timlön krävs och måste vara större än 0 för timlöneform')
}
if (merged.f_skatt_status === 'a_skatt' && !merged.is_sidoinkomst && !merged.tax_table_number) {
issues.push('Skattetabell krävs för A-skatt anställda')
}
if (merged.vaxa_stod_eligible && !merged.vaxa_stod_start) {
issues.push('Startdatum för Växa-stöd måste anges när Växa-stöd är aktiverat')
}
if (
merged.jamkning_percentage !== null &&
merged.jamkning_percentage !== undefined &&
!merged.jamkning_valid_from
) {
issues.push('Jämkningens startdatum måste anges när jämkningsprocent sätts')
}
if (
merged.jamkning_valid_from &&
merged.jamkning_valid_to &&
(merged.jamkning_valid_to as string) < (merged.jamkning_valid_from as string)
) {
issues.push('Jämkningens slutdatum måste vara efter startdatumet')
}
if (issues.length > 0) {
return { ok: false, code: 'VALIDATION_ERROR', details: { message: issues.join('. ') } }
}
const clearingChanged =
'clearing_number' in updates && updates.clearing_number !== (existing as Record<string, unknown>).clearing_number
const accountChanged =
'bank_account_number' in updates &&
updates.bank_account_number !== (existing as Record<string, unknown>).bank_account_number
if (clearingChanged || accountChanged) {
const bankIssues = validateEmployeeBankAccount(
merged.clearing_number as string | undefined,
merged.bank_account_number as string | undefined,
)
if (bankIssues.length > 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { issues: bankIssues.map((i) => ({ field: i.field, message: i.message })) },
}
}
}
if ('employment_type' in updates) {
const entityType = await getCompanyEntityType(supabase, args.companyId)
if (!isEmploymentTypeAllowedForEntity(entityType, updates.employment_type as never)) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { field: 'employment_type', message: EF_OWNER_EMPLOYMENT_ERROR },
}
}
}
const { data, error } = await supabase
.from('employees')
.update(updates)
.eq('id', args.employeeId)
.eq('company_id', args.companyId)
.select('id, first_name, last_name, personnummer, is_active')
.single()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
const row = data as { id: string; first_name: string; last_name: string; personnummer: string; is_active: boolean }
return {
ok: true,
data: {
employee_id: row.id,
first_name: row.first_name,
last_name: row.last_name,
personnummer_masked: maskPersonnummer(decryptPersonnummer(row.personnummer)),
is_active: row.is_active,
},
}
}
+314
View File
@@ -0,0 +1,314 @@
/**
* Employee opening balances (payroll cutover) commands.
*
* Shared by the v1 REST routes, the internal UI route, and the MCP
* staged-operation executor (set_employee_opening_balances). See migration
* 20260713101000 for the data model rationale.
*
* Lifecycle: one row per (company, employee), full-replace upsert, editable
* until the employee appears in a BOOKED salary run. The lock is derived
* (checked here for a clean 409; the DB trigger is the all-paths backstop).
*
* Bulk semantics are ATOMIC all-or-nothing: byrå onboarding wants "all
* imported or fix the file"; partial success would force callers to diff.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
export type OpeningBalancesResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown> }
export interface OpeningBalancesInput {
employee_id: string
cutover_date: string
ytd_gross: number
ytd_tax: number
ytd_net: number
vacation_paid_days_remaining: number
vacation_saved_days_by_year: Record<string, number>
opening_semester_liability: number
opening_semester_liability_avgifter: number
karens_periods_adjustment: number
}
export interface OpeningBalancesRow extends OpeningBalancesInput {
employee_opening_balances_id: string
locked: boolean
locked_by_run_id: string | null
created_at: string
updated_at: string
}
const ROW_COLUMNS =
'id, employee_id, cutover_date, ytd_gross, ytd_tax, ytd_net, ' +
'vacation_paid_days_remaining, vacation_saved_days_by_year, ' +
'opening_semester_liability, opening_semester_liability_avgifter, ' +
'karens_periods_adjustment, created_at, updated_at'
/** Booked-run lock lookup for a set of employees. Returns a map of
* employee_id -> blocking booked run id (absent = unlocked). */
export async function getLockingRuns(
supabase: SupabaseClient,
companyId: string,
employeeIds: string[],
): Promise<OpeningBalancesResult<Map<string, string>>> {
if (employeeIds.length === 0) return { ok: true, data: new Map() }
const { data, error } = await supabase
.from('salary_run_employees')
.select('employee_id, salary_run:salary_runs!inner(id, status)')
.eq('company_id', companyId)
.eq('salary_run.status', 'booked')
.in('employee_id', employeeIds)
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
const locks = new Map<string, string>()
for (const row of (data ?? []) as unknown as Array<{
employee_id: string
salary_run: { id: string; status: string } | null
}>) {
if (row.salary_run && !locks.has(row.employee_id)) {
locks.set(row.employee_id, row.salary_run.id)
}
}
return { ok: true, data: locks }
}
function toRow(
raw: Record<string, unknown>,
locks: Map<string, string>,
): OpeningBalancesRow {
const { id, ...rest } = raw as { id: string } & Record<string, unknown>
const employeeId = rest.employee_id as string
return {
...(rest as unknown as OpeningBalancesInput),
employee_opening_balances_id: id,
locked: locks.has(employeeId),
locked_by_run_id: locks.get(employeeId) ?? null,
created_at: raw.created_at as string,
updated_at: raw.updated_at as string,
}
}
export async function getOpeningBalances(
supabase: SupabaseClient,
args: { companyId: string; employeeId: string },
): Promise<OpeningBalancesResult<OpeningBalancesRow | null>> {
const { data: employee, error: empErr } = await supabase
.from('employees')
.select('id')
.eq('id', args.employeeId)
.eq('company_id', args.companyId)
.maybeSingle()
if (empErr) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: empErr.message } }
}
if (!employee) {
return { ok: false, code: 'EMPLOYEE_NOT_FOUND' }
}
const { data, error } = await supabase
.from('employee_opening_balances')
.select(ROW_COLUMNS)
.eq('company_id', args.companyId)
.eq('employee_id', args.employeeId)
.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
if (!data) {
return { ok: true, data: null }
}
const locks = await getLockingRuns(supabase, args.companyId, [args.employeeId])
if (!locks.ok) return locks
return { ok: true, data: toRow(data as unknown as Record<string, unknown>, locks.data) }
}
export interface BulkItemError {
index: number
employee_id: string
code: string
message: string
}
/**
* Atomic bulk upsert. Validates EVERY item against live state first
* (employee exists + active, employment_start <= cutover_date, not locked);
* any failure returns the full per-item error list with ZERO writes.
*/
export async function setOpeningBalancesBulk(
supabase: SupabaseClient,
args: {
companyId: string
userId: string
items: OpeningBalancesInput[]
/** Validate everything, return the would-be rows, write nothing. */
dryRun?: boolean
},
): Promise<
OpeningBalancesResult<{ count: number; rows: OpeningBalancesRow[] }> & {
itemErrors?: BulkItemError[]
}
> {
if (args.items.length === 0) {
return { ok: true, data: { count: 0, rows: [] } }
}
const employeeIds = args.items.map((i) => i.employee_id)
const duplicateIds = employeeIds.filter((id, idx) => employeeIds.indexOf(id) !== idx)
if (duplicateIds.length > 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { message: 'Duplicate employee_id in items', duplicates: duplicateIds },
}
}
const { data: employees, error: empErr } = await supabase
.from('employees')
.select('id, employment_start, is_active')
.eq('company_id', args.companyId)
.in('id', employeeIds)
if (empErr) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: empErr.message } }
}
const employeeById = new Map(
((employees ?? []) as Array<{ id: string; employment_start: string; is_active: boolean }>).map(
(e) => [e.id, e],
),
)
const locks = await getLockingRuns(supabase, args.companyId, employeeIds)
if (!locks.ok) return locks
const itemErrors: BulkItemError[] = []
args.items.forEach((item, index) => {
const employee = employeeById.get(item.employee_id)
if (!employee) {
itemErrors.push({
index,
employee_id: item.employee_id,
code: 'EMPLOYEE_NOT_FOUND',
message: 'Employee not found in this company.',
})
return
}
if (!employee.is_active) {
itemErrors.push({
index,
employee_id: item.employee_id,
code: 'EMPLOYEE_NOT_FOUND',
message: 'Employee is inactive; opening balances are for active employees.',
})
return
}
if (employee.employment_start > item.cutover_date) {
itemErrors.push({
index,
employee_id: item.employee_id,
code: 'VALIDATION_ERROR',
message: `cutover_date must be on or after employment_start (${employee.employment_start}).`,
})
return
}
if (locks.data.has(item.employee_id)) {
itemErrors.push({
index,
employee_id: item.employee_id,
code: 'OPENING_BALANCES_LOCKED',
message: `Locked by booked salary run ${locks.data.get(item.employee_id)}.`,
})
}
})
if (itemErrors.length > 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { item_errors: itemErrors },
itemErrors,
}
}
const rows = args.items.map((item) => ({
company_id: args.companyId,
employee_id: item.employee_id,
cutover_date: item.cutover_date,
ytd_gross: roundOre(item.ytd_gross),
ytd_tax: roundOre(item.ytd_tax),
ytd_net: roundOre(item.ytd_net),
vacation_paid_days_remaining: item.vacation_paid_days_remaining,
vacation_saved_days_by_year: item.vacation_saved_days_by_year,
opening_semester_liability: roundOre(item.opening_semester_liability),
opening_semester_liability_avgifter: roundOre(item.opening_semester_liability_avgifter),
karens_periods_adjustment: item.karens_periods_adjustment,
updated_by: args.userId,
}))
if (args.dryRun) {
return {
ok: true,
data: {
count: rows.length,
rows: rows.map((r) =>
toRow(
{
id: null as unknown as string,
...r,
created_at: null as unknown as string,
updated_at: null as unknown as string,
},
locks.data,
),
),
},
}
}
// created_by is an audit column: it must survive a re-upsert of an
// existing row, so carry the stored value forward and only stamp the
// caller on genuinely new rows.
const { data: existingRows, error: existingErr } = await supabase
.from('employee_opening_balances')
.select('employee_id, created_by')
.eq('company_id', args.companyId)
.in('employee_id', employeeIds)
if (existingErr) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: existingErr.message } }
}
const createdByByEmployee = new Map(
((existingRows ?? []) as Array<{ employee_id: string; created_by: string | null }>).map(
(r) => [r.employee_id, r.created_by],
),
)
// Single multi-row upsert on the natural key: atomic by construction.
const { data: upserted, error } = await supabase
.from('employee_opening_balances')
.upsert(
rows.map((r) => ({
...r,
created_by: createdByByEmployee.get(r.employee_id) ?? args.userId,
})),
{ onConflict: 'company_id,employee_id' },
)
.select(ROW_COLUMNS)
if (error) {
// The DB lock trigger is the all-paths backstop for the race where a run
// books between our pre-flight and the write.
if (error.code === '23514' || error.message?.includes('låsta')) {
return { ok: false, code: 'OPENING_BALANCES_LOCKED', details: { message: error.message } }
}
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
const resultRows = ((upserted ?? []) as unknown as Array<Record<string, unknown>>).map((r) =>
toRow(r, locks.data),
)
return { ok: true, data: { count: resultRows.length, rows: resultRows } }
}
+281
View File
@@ -0,0 +1,281 @@
/**
* Shared payslip line-item commands.
*
* Single source of truth for creating, updating, and deleting
* salary_line_items rows, consumed by:
* - the internal dashboard routes (app/api/salary/runs/[id]/lines/**)
* - the v1 REST routes (app/api/v1/.../salary-runs/[id]/.../lines/**)
* - the MCP staged-operation executor (update_payslip_line)
*
* Rules enforced here so they cannot drift between surfaces:
* - Lines are only editable while the run is in `draft` (BFL 5 kap: once
* the run advances, its numbers feed a verifikation).
* - The target salary_run_employee must belong to the given run.
* - Money is rounded via roundOre() (never naive Math.round(x*100)/100).
* - account_number auto-resolves from the item type when not supplied.
*
* Result-object convention mirrors lib/salary/run-calculation.ts:
* `{ ok: true, data } | { ok: false, code, details? }` where `code` is a key
* in lib/errors/structured-errors.ts.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { z } from 'zod'
import type { CreateSalaryLineItemSchema, UpdateSalaryLineItemSchema } from '@/lib/api/schemas'
import { getLineItemAccount } from '@/lib/salary/account-mapping'
import { roundOre } from '@/lib/money'
import type { SalaryLineItemType } from '@/types'
export type PayslipLineResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown> }
export interface SalaryLineItemRow {
id: string
salary_run_employee_id: string
company_id: string
item_type: string
description: string
quantity: number | null
unit_price: number | null
amount: number
is_taxable: boolean
is_avgift_basis: boolean
is_vacation_basis: boolean
is_gross_deduction: boolean
is_net_deduction: boolean
account_number: string | null
sort_order: number
created_at: string
updated_at: string
}
export type CreatePayslipLineInput = Omit<
z.infer<typeof CreateSalaryLineItemSchema>,
'salary_run_employee_id'
>
export type UpdatePayslipLineInput = z.infer<typeof UpdateSalaryLineItemSchema>
/** Address the target row either by the join-row id (internal UI) or by the
* employee id (v1/MCP callers, who know employee_id but not the sre id). */
export type PayslipLineTarget = { salaryRunEmployeeId: string } | { employeeId: string }
const LINE_COLUMNS =
'id, salary_run_employee_id, company_id, item_type, description, quantity, unit_price, amount, ' +
'is_taxable, is_avgift_basis, is_vacation_basis, is_gross_deduction, is_net_deduction, ' +
'account_number, sort_order, created_at, updated_at'
/**
* Verify the run exists in this company and is still a draft.
* Exported so surfaces that only need the gate (dry-run previews) can reuse it.
*/
export async function assertRunDraft(
supabase: SupabaseClient,
companyId: string,
salaryRunId: string,
): Promise<PayslipLineResult<{ id: string; status: string }>> {
const { data: run, error } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', salaryRunId)
.eq('company_id', companyId)
.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
if (!run) {
return { ok: false, code: 'SALARY_RUN_NOT_FOUND' }
}
if ((run as { status: string }).status !== 'draft') {
return {
ok: false,
code: 'SALARY_RUN_LINE_NOT_DRAFT',
details: { current_status: (run as { status: string }).status },
}
}
return { ok: true, data: run as { id: string; status: string } }
}
/** Resolve the salary_run_employees row for a target within a run. */
export async function resolveRunEmployee(
supabase: SupabaseClient,
companyId: string,
salaryRunId: string,
target: PayslipLineTarget,
): Promise<PayslipLineResult<{ id: string; employee_id: string }>> {
let query = supabase
.from('salary_run_employees')
.select('id, employee_id')
.eq('salary_run_id', salaryRunId)
.eq('company_id', companyId)
if ('salaryRunEmployeeId' in target) {
query = query.eq('id', target.salaryRunEmployeeId)
} else {
query = query.eq('employee_id', target.employeeId)
}
const { data: sre, error } = await query.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
if (!sre) {
return { ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' }
}
return { ok: true, data: sre as { id: string; employee_id: string } }
}
/** Load a line and verify it belongs to the given run (via its sre). */
async function loadLineInRun(
supabase: SupabaseClient,
companyId: string,
salaryRunId: string,
lineId: string,
): Promise<PayslipLineResult<SalaryLineItemRow>> {
const { data, error } = await supabase
.from('salary_line_items')
.select(`${LINE_COLUMNS}, salary_run_employee:salary_run_employees(salary_run_id)`)
.eq('id', lineId)
.eq('company_id', companyId)
.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
const row = data as (SalaryLineItemRow & { salary_run_employee?: { salary_run_id: string } | null }) | null
if (!row || row.salary_run_employee?.salary_run_id !== salaryRunId) {
return { ok: false, code: 'SALARY_LINE_NOT_FOUND' }
}
const { salary_run_employee: _sre, ...line } = row
return { ok: true, data: line as SalaryLineItemRow }
}
export async function createPayslipLine(
supabase: SupabaseClient,
args: {
companyId: string
salaryRunId: string
target: PayslipLineTarget
input: CreatePayslipLineInput
/** Validate + resolve only; return the would-be row (id null) without writing. */
dryRun?: boolean
},
): Promise<PayslipLineResult<SalaryLineItemRow | (Omit<SalaryLineItemRow, 'id' | 'created_at' | 'updated_at'> & { id: null })>> {
const gate = await assertRunDraft(supabase, args.companyId, args.salaryRunId)
if (!gate.ok) return gate
const sre = await resolveRunEmployee(supabase, args.companyId, args.salaryRunId, args.target)
if (!sre.ok) return sre
const input = args.input
const accountNumber =
input.account_number || getLineItemAccount(input.item_type as SalaryLineItemType)
const row = {
salary_run_employee_id: sre.data.id,
company_id: args.companyId,
item_type: input.item_type,
description: input.description,
quantity: input.quantity ?? null,
unit_price: input.unit_price ?? null,
amount: roundOre(input.amount),
is_taxable: input.is_taxable,
is_avgift_basis: input.is_avgift_basis,
is_vacation_basis: input.is_vacation_basis,
is_gross_deduction: input.is_gross_deduction,
is_net_deduction: input.is_net_deduction,
account_number: accountNumber,
sort_order: input.sort_order,
}
if (args.dryRun) {
return { ok: true, data: { ...row, id: null } }
}
const { data: created, error } = await supabase
.from('salary_line_items')
.insert(row)
.select(LINE_COLUMNS)
.single()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: created as unknown as SalaryLineItemRow }
}
export async function updatePayslipLine(
supabase: SupabaseClient,
args: {
companyId: string
salaryRunId: string
lineId: string
patch: UpdatePayslipLineInput
/** Validate + resolve only; return the merged row without writing. */
dryRun?: boolean
},
): Promise<PayslipLineResult<SalaryLineItemRow>> {
const gate = await assertRunDraft(supabase, args.companyId, args.salaryRunId)
if (!gate.ok) return gate
const existing = await loadLineInRun(supabase, args.companyId, args.salaryRunId, args.lineId)
if (!existing.ok) return existing
const updates: Record<string, unknown> = { ...args.patch }
if (typeof updates.amount === 'number') {
updates.amount = roundOre(updates.amount)
}
if (args.dryRun) {
return { ok: true, data: { ...existing.data, ...updates } as SalaryLineItemRow }
}
const { data: updated, error } = await supabase
.from('salary_line_items')
.update(updates)
.eq('id', args.lineId)
.eq('company_id', args.companyId)
.select(LINE_COLUMNS)
.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
if (!updated) {
return { ok: false, code: 'SALARY_LINE_NOT_FOUND' }
}
return { ok: true, data: updated as unknown as SalaryLineItemRow }
}
export async function deletePayslipLine(
supabase: SupabaseClient,
args: {
companyId: string
salaryRunId: string
lineId: string
/** Validate + resolve only; do not delete. */
dryRun?: boolean
},
): Promise<PayslipLineResult<{ deleted: true; salary_line_item_id: string }>> {
const gate = await assertRunDraft(supabase, args.companyId, args.salaryRunId)
if (!gate.ok) return gate
const existing = await loadLineInRun(supabase, args.companyId, args.salaryRunId, args.lineId)
if (!existing.ok) return existing
if (args.dryRun) {
return { ok: true, data: { deleted: true, salary_line_item_id: args.lineId } }
}
const { error } = await supabase
.from('salary_line_items')
.delete()
.eq('id', args.lineId)
.eq('company_id', args.companyId)
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: { deleted: true, salary_line_item_id: args.lineId } }
}
+10 -3
View File
@@ -159,11 +159,18 @@ export function calculateAge(personnummer: string, atDate: string): number {
}
/**
* Calculate age at the start of a given year.
* Used for avgifter age tier determination.
* Age tier for "vid årets ingång fyllt X" rules (avgifter age tiers).
*
* Skatteverket applies these rules as BIRTH-YEAR ranges (the 2026
* ungdomsrabatt covers born 2003-2007; the 66/67+ reduction for 2026 covers
* born 1958 or earlier), which equals the age attained by December 31 of
* the PRIOR year. Birthday-inclusive age at January 1 (calculateAge
* semantics) misclassifies employees born exactly on January 1 in both
* directions: born 2008-01-01 would get the 2026 youth rate (Skatteverket's
* AGI validation rejects it) and born 2003-01-01 would be denied it.
*/
export function calculateAgeAtYearStart(personnummer: string, year: number): number {
return calculateAge(personnummer, `${year}-01-01`)
return year - 1 - extractBirthDate(personnummer).year
}
/**
+94 -7
View File
@@ -32,6 +32,8 @@ import { fetchAllTaxTableRatesForRun, TaxTableUnavailableError } from './tax-tab
import { loadAndDeriveAbsence } from './derive-absence-line-items'
import { getLineItemAccount } from './account-mapping'
import { computePremiumLines } from './shift-premium-engine'
import { roundOre } from '@/lib/money'
import { dailyDivisor, hourlyDivisor } from './work-schedule'
import type { WorkedDayShift } from './shift-premium-engine'
import type { Logger } from '@/lib/logger'
import type { SalaryLineItemType, ShiftPremiumRule, ShiftPremiumItemType } from '@/types'
@@ -63,19 +65,20 @@ const DERIVED_PREMIUM_TYPES: ShiftPremiumItemType[] = [
/**
* Effective hourly rate used as the base for shift-premium computation.
* - Hourly employees: their stored hourly_rate.
* - Monthly employees: monthly_salary / 173 (common Swedish derivation for
* full-time monthly → hourly, matches the timlön conventions used in
* CBAs). Applied even to part-timers since the engine multiplies by
* actually-worked premium hours.
* - Monthly employees: monthly_salary / hourlyDivisor(hours_per_week):
* 173 at the 40h default (common Swedish derivation for full-time
* monthly → hourly, matches the timlön conventions used in CBAs), the
* exact 52w formula for other schedules (arbetsschema-lite).
*/
function effectiveHourlyRate(emp: {
salary_type: 'monthly' | 'hourly'
hourly_rate: number | null
monthly_salary: number | null
hours_per_week?: number | null
}): number {
if (emp.salary_type === 'hourly') return emp.hourly_rate || 0
const monthly = emp.monthly_salary || 0
return monthly > 0 ? Math.round((monthly / 173) * 100) / 100 : 0
return monthly > 0 ? Math.round((monthly / hourlyDivisor(emp.hours_per_week)) * 100) / 100 : 0
}
/** Benefit-type → line-item-type mapping for the derived benefit rows. */
@@ -247,13 +250,68 @@ export async function runSalaryCalculation(
.eq('salary_run.status', 'booked')
.lt('salary_run.period_month', run.period_month)
// 6b. Cutover opening balances (payroll gap-closure 2.2): a company that
// switched to Accounted mid-year has YTD state from its previous
// payroll system that no booked run here carries. Fetched BEFORE the
// prior-run aggregation because the cutover month also decides which
// booked runs count (see the exclusion in the loop below). YTD is
// payslip display + reporting only: per-month tax lookup and the
// per-month avgifter caps never read it.
const rosterEmployeeIds = runEmployees
.map((sre) => sre.employee?.id)
.filter((id): id is string => !!id)
const openingByEmployee = new Map<
string,
{ cutoverDate: string; karensPeriodsAdjustment: number }
>()
const openingRowsTyped: Array<{
employee_id: string
cutover_date: string
ytd_gross: number
ytd_tax: number
ytd_net: number
karens_periods_adjustment: number
}> = []
if (rosterEmployeeIds.length > 0) {
const { data: openingRows } = await supabase
.from('employee_opening_balances')
.select('employee_id, cutover_date, ytd_gross, ytd_tax, ytd_net, karens_periods_adjustment')
.eq('company_id', companyId)
.in('employee_id', rosterEmployeeIds)
for (const opening of (openingRows || []) as typeof openingRowsTyped) {
openingRowsTyped.push(opening)
openingByEmployee.set(opening.employee_id, {
cutoverDate: opening.cutover_date,
karensPeriodsAdjustment: opening.karens_periods_adjustment ?? 0,
})
}
}
const ytdByEmployee = new Map<string, { gross: number; tax: number; net: number }>()
for (const prior of (priorRuns || []) as Array<{
// Cast via unknown: supabase-js infers the to-one `salary_run` embed as an
// array, but PostgREST returns an object for a many-to-one relationship.
for (const prior of (priorRuns || []) as unknown as Array<{
employee_id: string
gross_salary: number
tax_withheld: number
net_salary: number
salary_run: { period_year: number; period_month: number }
}>) {
// The opening balance is authoritative for pre-cutover YTD: a booked run
// backdated before the cutover month covers a month the opening already
// carries, so counting both would double the YTD.
const opening = openingByEmployee.get(prior.employee_id)
if (opening) {
const cutoverYear = Number(opening.cutoverDate.slice(0, 4))
const cutoverMonth = Number(opening.cutoverDate.slice(5, 7))
if (
prior.salary_run.period_year === cutoverYear &&
prior.salary_run.period_month < cutoverMonth
) {
continue
}
}
const current = ytdByEmployee.get(prior.employee_id) || { gross: 0, tax: 0, net: 0 }
current.gross += prior.gross_salary
current.tax += prior.tax_withheld
@@ -261,6 +319,22 @@ export async function runSalaryCalculation(
ytdByEmployee.set(prior.employee_id, current)
}
// Merge the opening YTD when the run's period is in the cutover year, on
// or after the cutover month (the month gate prevents double-count if
// someone backdates an in-system run before cutover).
for (const opening of openingRowsTyped) {
const cutoverYear = Number(opening.cutover_date.slice(0, 4))
const cutoverMonth = Number(opening.cutover_date.slice(5, 7))
const runOnOrAfterCutover =
run.period_year === cutoverYear && run.period_month >= cutoverMonth
if (!runOnOrAfterCutover) continue
const current = ytdByEmployee.get(opening.employee_id) || { gross: 0, tax: 0, net: 0 }
current.gross = roundOre(current.gross + (opening.ytd_gross || 0))
current.tax = roundOre(current.tax + (opening.ytd_tax || 0))
current.net = roundOre(current.net + (opening.ytd_net || 0))
ytdByEmployee.set(opening.employee_id, current)
}
// 7. Pay period bounds: used to load per-day absence + worked-day records.
const periodYear = run.period_year as number
const periodMonth = run.period_month as number
@@ -299,7 +373,16 @@ export async function runSalaryCalculation(
const emp = sre.employee
if (!emp) continue
// 8a. Derive absence line items from per-day records.
// 8a. Derive absence line items from per-day records. The cutover karens
// adjustment applies only while the 12-month högriskskydd lookback
// still reaches into pre-cutover time; past that horizon the
// adjustment is stale and imported day rows carry the truth.
const opening = openingByEmployee.get(emp.id)
const lookbackStartMs = Date.parse(`${periodStart}T00:00:00Z`) - 365 * 86_400_000
const karensAdjustmentApplies =
opening !== undefined &&
opening.karensPeriodsAdjustment > 0 &&
lookbackStartMs < Date.parse(`${opening.cutoverDate}T00:00:00Z`)
const absenceResult = await loadAndDeriveAbsence({
supabase,
companyId,
@@ -308,6 +391,8 @@ export async function runSalaryCalculation(
payrollConfig: config,
periodStart,
periodEnd,
karensPeriodsAdjustment: karensAdjustmentApplies ? opening.karensPeriodsAdjustment : 0,
dailyDivisor: dailyDivisor(emp.workdays_per_week),
})
// 8b. For hourly employees, derive worked hours from the calendar.
@@ -511,6 +596,7 @@ export async function runSalaryCalculation(
salary_type: emp.salary_type,
hourly_rate: emp.hourly_rate,
monthly_salary: sre.monthly_salary,
hours_per_week: emp.hours_per_week,
})
const shifts: WorkedDayShift[] = workedDayRows.map((row) => ({
work_date: row.work_date,
@@ -620,6 +706,7 @@ export async function runSalaryCalculation(
vacationRule: emp.vacation_rule,
vacationDaysPerYear: emp.vacation_days_per_year,
semestertillaggRate: emp.semestertillagg_rate,
dailyDivisor: dailyDivisor(emp.workdays_per_week),
vaxaStodEligible: emp.vaxa_stod_eligible,
vaxaStodStart: emp.vaxa_stod_start,
vaxaStodEnd: emp.vaxa_stod_end,
+231
View File
@@ -0,0 +1,231 @@
/**
* Shared salary-run roster commands: attach/remove an employee on a DRAFT run.
*
* Single source of truth consumed by the internal dashboard routes
* (app/api/salary/runs/[id]/employees/**) and the v1 REST routes. Attaching
* snapshots the employee's pay config onto salary_run_employees (so later
* employee edits don't retroactively change an open run) and seeds the base
* salary line item.
*
* Result-object convention mirrors lib/salary/run-calculation.ts.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { getLineItemAccount } from '@/lib/salary/account-mapping'
import { roundOre } from '@/lib/money'
import type { SalaryLineItemType } from '@/types'
export type RunEmployeeResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown> }
export interface SalaryRunEmployeeRow {
id: string
salary_run_id: string
employee_id: string
company_id: string
employment_degree: number
monthly_salary: number
salary_type: string
hours_worked: number | null
tax_table_number: number | null
tax_column: number | null
created_at: string
updated_at: string
}
async function assertRunDraftForRoster(
supabase: SupabaseClient,
companyId: string,
salaryRunId: string,
): Promise<RunEmployeeResult<{ id: string; status: string }>> {
const { data: run, error } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', salaryRunId)
.eq('company_id', companyId)
.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
if (!run) {
return { ok: false, code: 'SALARY_RUN_NOT_FOUND' }
}
if ((run as { status: string }).status !== 'draft') {
return {
ok: false,
code: 'SALARY_RUN_EMPLOYEES_NOT_DRAFT',
details: { current_status: (run as { status: string }).status },
}
}
return { ok: true, data: run as { id: string; status: string } }
}
export async function addEmployeeToRun(
supabase: SupabaseClient,
args: {
companyId: string
salaryRunId: string
employeeId: string
hoursWorked?: number | null
/** Validate + resolve only; return the would-be snapshot without writing. */
dryRun?: boolean
},
): Promise<RunEmployeeResult<SalaryRunEmployeeRow | (Omit<SalaryRunEmployeeRow, 'id' | 'created_at' | 'updated_at'> & { id: null })>> {
const gate = await assertRunDraftForRoster(supabase, args.companyId, args.salaryRunId)
if (!gate.ok) return gate
const { data: employee, error: empError } = await supabase
.from('employees')
.select(
'id, employment_degree, monthly_salary, hourly_rate, salary_type, employment_type, tax_table_number, tax_column',
)
.eq('id', args.employeeId)
.eq('company_id', args.companyId)
.eq('is_active', true)
.maybeSingle()
if (empError) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: empError.message } }
}
if (!employee) {
return { ok: false, code: 'EMPLOYEE_NOT_FOUND' }
}
const { data: existing, error: dupError } = await supabase
.from('salary_run_employees')
.select('id')
.eq('salary_run_id', args.salaryRunId)
.eq('employee_id', args.employeeId)
.maybeSingle()
if (dupError) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: dupError.message } }
}
if (existing) {
return {
ok: false,
code: 'SALARY_RUN_EMPLOYEE_DUPLICATE',
details: { salary_run_employee_id: (existing as { id: string }).id },
}
}
const emp = employee as {
id: string
employment_degree: number
monthly_salary: number | null
hourly_rate: number | null
salary_type: string
employment_type: string
tax_table_number: number | null
tax_column: number | null
}
const snapshot = {
salary_run_id: args.salaryRunId,
employee_id: emp.id,
company_id: args.companyId,
employment_degree: emp.employment_degree,
monthly_salary: emp.monthly_salary || 0,
salary_type: emp.salary_type,
hours_worked: args.hoursWorked ?? null,
tax_table_number: emp.tax_table_number,
tax_column: emp.tax_column,
}
if (args.dryRun) {
return { ok: true, data: { ...snapshot, id: null } }
}
const { data: sre, error: sreError } = await supabase
.from('salary_run_employees')
.insert(snapshot)
.select()
.single()
if (sreError) {
// Race with a concurrent attach: the pre-flight passed but the insert
// tripped the unique constraint.
if ((sreError as { code?: string }).code === '23505') {
return { ok: false, code: 'SALARY_RUN_EMPLOYEE_DUPLICATE' }
}
return { ok: false, code: 'INTERNAL_ERROR', details: { message: sreError.message } }
}
// Seed the base salary line so the run displays a sensible gross before
// the first :calculate.
const baseSalaryType: SalaryLineItemType =
emp.salary_type === 'monthly' ? 'monthly_salary' : 'hourly_salary'
const baseAmount =
emp.salary_type === 'monthly'
? roundOre((emp.monthly_salary || 0) * (emp.employment_degree / 100))
: roundOre((emp.hourly_rate || 0) * (args.hoursWorked || 0))
const { error: lineError } = await supabase.from('salary_line_items').insert({
salary_run_employee_id: (sre as { id: string }).id,
company_id: args.companyId,
item_type: baseSalaryType,
description: emp.salary_type === 'monthly' ? 'Grundlön' : 'Timlön',
quantity: emp.salary_type === 'hourly' ? args.hoursWorked ?? null : null,
unit_price: emp.salary_type === 'hourly' ? emp.hourly_rate : null,
amount: baseAmount,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
account_number: getLineItemAccount(baseSalaryType, emp.employment_type as never),
sort_order: 0,
})
if (lineError) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: lineError.message } }
}
return { ok: true, data: sre as unknown as SalaryRunEmployeeRow }
}
export async function removeEmployeeFromRun(
supabase: SupabaseClient,
args: {
companyId: string
salaryRunId: string
employeeId: string
/** Validate + resolve only; do not delete. */
dryRun?: boolean
},
): Promise<RunEmployeeResult<{ deleted: true; employee_id: string }>> {
const gate = await assertRunDraftForRoster(supabase, args.companyId, args.salaryRunId)
if (!gate.ok) return gate
const { data: sre, error: sreError } = await supabase
.from('salary_run_employees')
.select('id')
.eq('salary_run_id', args.salaryRunId)
.eq('employee_id', args.employeeId)
.eq('company_id', args.companyId)
.maybeSingle()
if (sreError) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: sreError.message } }
}
if (!sre) {
return { ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' }
}
if (args.dryRun) {
return { ok: true, data: { deleted: true, employee_id: args.employeeId } }
}
// Cascades to salary_line_items via ON DELETE CASCADE.
const { error } = await supabase
.from('salary_run_employees')
.delete()
.eq('salary_run_id', args.salaryRunId)
.eq('employee_id', args.employeeId)
.eq('company_id', args.companyId)
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: { deleted: true, employee_id: args.employeeId } }
}
+564
View File
@@ -0,0 +1,564 @@
/**
* Semesterberedning + semesterårsavslut (payroll gap-closure 3.3).
*
* ONE workflow, two phases on the same year boundary:
*
* Beredning (days): per employee, roll the closing year's balances into
* the next vacation year. Only days above the 20-day must-take floor are
* saveable (Semesterlagen 18 §); saved days expire after 5 years and move
* to forced_payout_days (paid out as semesterersättning via a normal
* salary run, never booked here). Untaken days at or below the floor are
* FLAGGED for manual handling, not auto-saved.
*
* Årsavslut (SEK): reconcile the day-valued semesterlöneskuld against the
* BOOKED 2920/2940 balances (per-run accruals never relieve 2920 when
* vacation is taken, so drift accumulates by design) and post ONE
* adjustment verifikation via the bookkeeping engine when |drift| > 1 kr.
*
* Day valuation (BFNAR 2016:10 per-employee, simplified and shown in the
* review report before anything commits):
* sammalöneregeln (monthly): monthly/dailyDivisor + monthly x tillägg
* procentregeln (monthly) : monthly x 12 x rate / entitled days
* procentregeln (hourly) : hourly x hours_per_week x 52 x rate / entitled
* Avgifter on the liability use per-employee age tiers at the settlement
* year (born <= 1937 exempt, fyllt 67 vid årets ingång 10.21%, otherwise
* 31.42%), matching the rates the per-run accruals booked on 2940; a flat
* 31.42% target would "correct" a correct booked balance to a wrong one
* for companies with 67+ staff. The temporary youth discount is
* deliberately NOT provisioned: it is payment-month- and cap-dependent and
* expires Sep 2027, so the full rate is the prudent target (ÅRL
* försiktighetsprincipen); youth accruals therefore show a top-up drift.
*
* The frozen report is stored on vacation_year_closures (BFL 7 kap: it is
* the underlag for the adjustment entry). The UNIQUE
* (company_id, vacation_year_start) makes replays a clean conflict.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre, sumOre } from '@/lib/money'
import { dailyDivisor } from './work-schedule'
import { getVacationYearBounds, type VacationYearBasis } from './vacation-year'
import { getVacationYearBasis, syncVacationLedgerForEmployees, type VacationBalanceRow } from './vacation-ledger'
import { calculateAgeAtYearStart, decryptPersonnummer } from './personnummer'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
const STANDARD_AVGIFTER_RATE = 0.3142
/** Ålderspensionsavgift only, for fyllt 67 vid årets ingång (SAL 2 kap). */
const REDUCED_AVGIFTER_RATE = 0.1021
/** Threshold applies to payment years >= 2026; every close this module can
* run settles 2026 or later, so the pre-2026 66-year threshold never applies. */
const REDUCED_AVGIFT_AGE = 67
/** Book an adjustment only beyond this drift (öre noise is not a bokslut post). */
const DRIFT_TOLERANCE_SEK = 1
export type VacationCloseResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown> }
export interface VacationCloseEmployeeRow {
employee_id: string
employee_name: string
vacation_rule: string
entitled_days: number
taken_days: number
remaining_days: number
/** Days above the 20-day must-take floor: rolled into saved_days. */
saveable_days: number
/** Untaken days at/below the floor: flagged for manual handling. */
untaken_below_floor_days: number
/** Saved days whose origin year fell out of the 5-year window: forced payout. */
expiring_days: number
saved_days_before: Record<string, number>
saved_days_after: Record<string, number>
next_year_entitled: number
day_value_sek: number
computed_liability_sek: number
/** Age-tier avgifter rate applied to this employee's liability for the
* 2940 target (0 exempt, 0.1021 fyllt 67, 0.3142 standard). */
avgifter_rate: number
}
export interface VacationCloseReport {
vacation_year_start: string
vacation_year_end: string
next_year_start: string
basis: VacationYearBasis
rows: VacationCloseEmployeeRow[]
sek: {
computed_liability: number
computed_avgifter: number
booked_2920: number
booked_2940: number
drift_2920: number
drift_2940: number
adjustment_needed: boolean
}
adjustment_date: string
}
interface EmployeeRosterRow {
id: string
first_name: string
last_name: string
personnummer: string | null
vacation_rule: string
vacation_days_per_year: number
salary_type: string
monthly_salary: number | null
hourly_rate: number | null
hours_per_week: number | null
workdays_per_week: number | null
employment_start: string
employment_end: string | null
}
function lastDayBefore(dateIso: string): string {
const d = new Date(`${dateIso}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() - 1)
return d.toISOString().slice(0, 10)
}
function dayValueSek(emp: EmployeeRosterRow): number {
const rate = emp.vacation_days_per_year >= 30 ? 0.144 : 0.12
if (emp.salary_type === 'hourly') {
const annualBasis = (emp.hourly_rate || 0) * (emp.hours_per_week ?? 40) * 52
return roundOre((annualBasis * rate) / Math.max(emp.vacation_days_per_year, 1))
}
const monthly = emp.monthly_salary || 0
if (emp.vacation_rule === 'sammaloneregeln') {
// Dagslön + semestertillägg per day. The employee's tillägg rate lives on
// the master row but the statutory floor 0.43% is used when absent.
return roundOre(monthly / dailyDivisor(emp.workdays_per_week) + monthly * 0.0043)
}
return roundOre((monthly * 12 * rate) / Math.max(emp.vacation_days_per_year, 1))
}
/**
* Age-tier avgifter rate for the semesterlöneskuld provision.
*
* The liability settles when vacation is taken during the NEXT vacation
* year, so the tier is evaluated against that settlement year. This matches
* the per-run accruals, which credit 2940 at each employee's actual rate
* (calculation-engine.ts step 10). The temporary youth discount is not
* applied here (see the module header). An absent or undecryptable
* personnummer falls back to the standard rate, mirroring the run engine.
*/
function avgifterRateFor(emp: EmployeeRosterRow, settlementYear: number): number {
if (!emp.personnummer) return STANDARD_AVGIFTER_RATE
let pnr: string
try {
pnr = decryptPersonnummer(emp.personnummer)
} catch {
return STANDARD_AVGIFTER_RATE
}
const birthYear = parseInt(pnr.slice(0, 4))
if (!Number.isFinite(birthYear)) return STANDARD_AVGIFTER_RATE
if (birthYear <= 1937) return 0
if (calculateAgeAtYearStart(pnr, settlementYear) >= REDUCED_AVGIFT_AGE) {
return REDUCED_AVGIFTER_RATE
}
return STANDARD_AVGIFTER_RATE
}
/** Prorated entitlement for the new year (Semesterlagen 3a §: round UP). */
function nextYearEntitled(emp: EmployeeRosterRow, newYearStart: string, newYearEnd: string): number {
if (emp.employment_end && emp.employment_end < newYearStart) return 0
if (emp.employment_start >= newYearEnd) return 0
const yearStartMs = Date.parse(`${newYearStart}T00:00:00Z`)
const yearEndMs = Date.parse(`${newYearEnd}T00:00:00Z`)
const fromMs = Math.max(yearStartMs, Date.parse(`${emp.employment_start}T00:00:00Z`))
const toMs = emp.employment_end
? Math.min(yearEndMs, Date.parse(`${emp.employment_end}T00:00:00Z`) + 86_400_000)
: yearEndMs
const fraction = Math.max(0, toMs - fromMs) / (yearEndMs - yearStartMs)
return Math.min(emp.vacation_days_per_year, Math.ceil(fraction * emp.vacation_days_per_year))
}
async function loadRoster(
supabase: SupabaseClient,
companyId: string,
): Promise<VacationCloseResult<EmployeeRosterRow[]>> {
const { data, error } = await supabase
.from('employees')
.select(
'id, first_name, last_name, personnummer, vacation_rule, vacation_days_per_year, salary_type, monthly_salary, hourly_rate, hours_per_week, workdays_per_week, employment_start, employment_end',
)
.eq('company_id', companyId)
.eq('is_active', true)
.not('vacation_rule', 'in', '(none,semesterersattning)')
if (error) return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
return { ok: true, data: (data ?? []) as unknown as EmployeeRosterRow[] }
}
async function bookedBalance(
supabase: SupabaseClient,
companyId: string,
asOfDate: string,
): Promise<VacationCloseResult<{ booked2920: number; booked2940: number }>> {
const { data: period, error } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('company_id', companyId)
.lte('period_start', asOfDate)
.gte('period_end', asOfDate)
.maybeSingle()
if (error) return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
if (!period) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { message: `Ingen räkenskapsperiod täcker ${asOfDate}.` },
}
}
const tb = await generateTrialBalance(supabase, companyId, (period as { id: string }).id, {
toDate: asOfDate,
})
const balanceOf = (account: string): number => {
const row = tb.rows.find((r) => r.account_number === account)
if (!row) return 0
// 2920/2940 are credit-normal liabilities.
return roundOre(row.closing_credit - row.closing_debit)
}
return { ok: true, data: { booked2920: balanceOf('2920'), booked2940: balanceOf('2940') } }
}
export async function previewVacationYearClose(
supabase: SupabaseClient,
companyId: string,
closingYearStart: string,
): Promise<VacationCloseResult<VacationCloseReport>> {
const basis = await getVacationYearBasis(supabase, companyId)
const bounds = getVacationYearBounds(closingYearStart)
const yearEnd = lastDayBefore(bounds.end)
const newYearStart = bounds.end
const newYearBounds = getVacationYearBounds(newYearStart)
// A year can only be closed once it has ended.
const today = new Date().toISOString().slice(0, 10)
if (today < bounds.end) {
return {
ok: false,
code: 'VACATION_YEAR_NOT_ENDED',
details: { vacation_year_start: closingYearStart, vacation_year_end: yearEnd },
}
}
// Replay guard for previews too: a closed year has a frozen report already.
const { data: existingClosure } = await supabase
.from('vacation_year_closures')
.select('id')
.eq('company_id', companyId)
.eq('vacation_year_start', closingYearStart)
.maybeSingle()
if (existingClosure) {
return {
ok: false,
code: 'VACATION_YEAR_ALREADY_CLOSED',
details: { vacation_year_start: closingYearStart },
}
}
const roster = await loadRoster(supabase, companyId)
if (!roster.ok) return roster
// Make sure the closing year's ledger rows exist and are recomputed as of
// the year end (seeds from cutover/legacy data on first touch).
const sync = await syncVacationLedgerForEmployees(
supabase,
companyId,
roster.data.map((e) => e.id),
yearEnd,
)
if (!sync.ok) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: sync.message } }
}
const { data: ledgerRows, error: ledgerErr } = await supabase
.from('employee_vacation_balances')
.select('id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days, status')
.eq('company_id', companyId)
.eq('vacation_year_start', closingYearStart)
if (ledgerErr) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: ledgerErr.message } }
}
const ledgerByEmployee = new Map(
((ledgerRows ?? []) as unknown as VacationBalanceRow[]).map((r) => [r.employee_id, r]),
)
const closingYear = Number(closingYearStart.slice(0, 4))
// The rolled liability is paid out during the new vacation year: that is
// the year the avgifter age tier is evaluated against.
const settlementYear = Number(newYearStart.slice(0, 4))
const rows: VacationCloseEmployeeRow[] = []
for (const emp of roster.data) {
const ledger = ledgerByEmployee.get(emp.id)
const entitled = ledger?.entitled_days ?? emp.vacation_days_per_year
const taken = ledger?.taken_days ?? 0
const savedBefore = (ledger?.saved_days ?? {}) as Record<string, number>
const remaining = Math.max(0, roundOre(entitled - taken))
// Semesterlagen 18 §: only days exceeding the 20-day floor are saveable.
const saveable = Math.max(0, Math.min(remaining, entitled - 20))
const untakenBelowFloor = roundOre(remaining - saveable)
// 5-year expiry: a day saved in origin year X is takeable through X + 5.
// Rolling into the new year, origins <= closingYear - 5 fall out.
let expiring = 0
const savedAfter: Record<string, number> = {}
for (const [originYear, days] of Object.entries(savedBefore)) {
const numDays = Number(days) || 0
if (numDays <= 0) continue
if (Number(originYear) <= closingYear - 5) {
expiring = roundOre(expiring + numDays)
} else {
savedAfter[originYear] = numDays
}
}
if (saveable > 0) {
savedAfter[String(closingYear)] = roundOre((savedAfter[String(closingYear)] ?? 0) + saveable)
}
const dayValue = dayValueSek(emp)
// The liability covers everything still owed: this year's untaken days
// (incl. the below-floor flag) + all saved days + expiring days awaiting
// payout.
const liabilityDays = roundOre(
remaining + Object.values(savedBefore).reduce((s, d) => s + (Number(d) || 0), 0),
)
const liability = roundOre(liabilityDays * dayValue)
rows.push({
employee_id: emp.id,
employee_name: `${emp.first_name} ${emp.last_name}`,
vacation_rule: emp.vacation_rule,
entitled_days: entitled,
taken_days: taken,
remaining_days: remaining,
saveable_days: saveable,
untaken_below_floor_days: untakenBelowFloor,
expiring_days: expiring,
saved_days_before: savedBefore,
saved_days_after: savedAfter,
next_year_entitled: nextYearEntitled(emp, newYearStart, newYearBounds.end),
day_value_sek: dayValue,
computed_liability_sek: liability,
avgifter_rate: avgifterRateFor(emp, settlementYear),
})
}
const computedLiability = sumOre(rows.map((r) => r.computed_liability_sek))
const computedAvgifter = sumOre(
rows.map((r) => roundOre(r.computed_liability_sek * r.avgifter_rate)),
)
const booked = await bookedBalance(supabase, companyId, yearEnd)
if (!booked.ok) return booked
const drift2920 = roundOre(computedLiability - booked.data.booked2920)
const drift2940 = roundOre(computedAvgifter - booked.data.booked2940)
return {
ok: true,
data: {
vacation_year_start: closingYearStart,
vacation_year_end: yearEnd,
next_year_start: newYearStart,
basis,
rows,
sek: {
computed_liability: computedLiability,
computed_avgifter: computedAvgifter,
booked_2920: booked.data.booked2920,
booked_2940: booked.data.booked2940,
drift_2920: drift2920,
drift_2940: drift2940,
adjustment_needed:
Math.abs(drift2920) > DRIFT_TOLERANCE_SEK || Math.abs(drift2940) > DRIFT_TOLERANCE_SEK,
},
adjustment_date: yearEnd,
},
}
}
export async function commitVacationYearClose(
supabase: SupabaseClient,
companyId: string,
userId: string,
closingYearStart: string,
options: { bookAdjustment: boolean },
): Promise<VacationCloseResult<{ closure_id: string; adjustment_entry_id: string | null; report: VacationCloseReport }>> {
const preview = await previewVacationYearClose(supabase, companyId, closingYearStart)
if (!preview.ok) return preview
const report = preview.data
// Period-lock pre-check BEFORE any writes so a locked bokslut period fails
// the whole close cleanly (the DB trigger is the backstop).
if (options.bookAdjustment && report.sek.adjustment_needed) {
const { checkPeriodLock } = await import('@/lib/api/v1/check-period-lock')
const lockVerdict = await checkPeriodLock(supabase, companyId, report.adjustment_date)
if (lockVerdict.locked) {
return {
ok: false,
code: 'PERIOD_LOCKED',
details: {
reason: lockVerdict.reason,
fiscal_period_id: lockVerdict.fiscal_period_id,
adjustment_date: report.adjustment_date,
},
}
}
}
// 1. Closure row: the UNIQUE constraint is the replay anchor.
const { data: closure, error: closureErr } = await supabase
.from('vacation_year_closures')
.insert({
company_id: companyId,
vacation_year_start: closingYearStart,
closed_by: userId,
report: report as unknown as Record<string, unknown>,
})
.select('id')
.single()
if (closureErr) {
if (closureErr.code === '23505') {
return {
ok: false,
code: 'VACATION_YEAR_ALREADY_CLOSED',
details: { vacation_year_start: closingYearStart },
}
}
return { ok: false, code: 'INTERNAL_ERROR', details: { message: closureErr.message } }
}
const closureId = (closure as { id: string }).id
// 2. Close the year's ledger rows.
const { error: closeErr } = await supabase
.from('employee_vacation_balances')
.update({ status: 'closed' })
.eq('company_id', companyId)
.eq('vacation_year_start', closingYearStart)
if (closeErr) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: closeErr.message } }
}
// 3. Next-year rows: entitlement + rolled saved days + forced payouts.
// Upsert MERGES over any lazy-seeded row; taken_days is recomputed by
// the sync below, so seeding it 0 here is safe.
const nextRows = report.rows.map((row) => ({
company_id: companyId,
employee_id: row.employee_id,
vacation_year_start: report.next_year_start,
entitled_days: row.next_year_entitled,
accrued_days: 0,
taken_days: 0,
saved_days: row.saved_days_after,
forced_payout_days: row.expiring_days,
status: 'open',
}))
if (nextRows.length > 0) {
const { error: nextErr } = await supabase
.from('employee_vacation_balances')
.upsert(nextRows, { onConflict: 'company_id,employee_id,vacation_year_start' })
if (nextErr) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: nextErr.message } }
}
}
// Recompute taken_days on the fresh rows from any already-booked runs in
// the new year (non-fatal, same contract as the booking hook).
await syncVacationLedgerForEmployees(
supabase,
companyId,
report.rows.map((r) => r.employee_id),
)
// 4. Drift adjustment verifikation (7290/2920 + 7519/2940).
let adjustmentEntryId: string | null = null
if (options.bookAdjustment && report.sek.adjustment_needed) {
const { data: period } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.lte('period_start', report.adjustment_date)
.gte('period_end', report.adjustment_date)
.maybeSingle()
if (!period) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { message: `Ingen räkenskapsperiod täcker ${report.adjustment_date}.` },
}
}
const lines: Array<{ account_number: string; debit_amount: number; credit_amount: number; line_description?: string }> = []
const d2920 = report.sek.drift_2920
if (Math.abs(d2920) > DRIFT_TOLERANCE_SEK) {
if (d2920 > 0) {
lines.push({ account_number: '7290', debit_amount: d2920, credit_amount: 0, line_description: 'Justering semesterlöneskuld' })
lines.push({ account_number: '2920', debit_amount: 0, credit_amount: d2920, line_description: 'Justering semesterlöneskuld' })
} else {
lines.push({ account_number: '2920', debit_amount: -d2920, credit_amount: 0, line_description: 'Justering semesterlöneskuld' })
lines.push({ account_number: '7290', debit_amount: 0, credit_amount: -d2920, line_description: 'Justering semesterlöneskuld' })
}
}
const d2940 = report.sek.drift_2940
if (Math.abs(d2940) > DRIFT_TOLERANCE_SEK) {
if (d2940 > 0) {
lines.push({ account_number: '7519', debit_amount: d2940, credit_amount: 0, line_description: 'Justering upplupna avgifter semester' })
lines.push({ account_number: '2940', debit_amount: 0, credit_amount: d2940, line_description: 'Justering upplupna avgifter semester' })
} else {
lines.push({ account_number: '2940', debit_amount: -d2940, credit_amount: 0, line_description: 'Justering upplupna avgifter semester' })
lines.push({ account_number: '7519', debit_amount: 0, credit_amount: -d2940, line_description: 'Justering upplupna avgifter semester' })
}
}
if (lines.length > 0) {
try {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
const entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: (period as { id: string }).id,
entry_date: report.adjustment_date,
description: `Semesterårsavslut ${closingYearStart.slice(0, 4)}: justering semesterlöneskuld`,
source_type: 'salary_payment',
source_id: closureId,
lines,
})
adjustmentEntryId = entry.id
const { error: linkError } = await supabase
.from('vacation_year_closures')
.update({ adjustment_entry_id: entry.id })
.eq('id', closureId)
if (linkError) {
// The verifikat IS posted; only the closure link failed. Surface
// it with the entry id so nobody re-posts the adjustment manually.
return {
ok: false,
code: 'VACATION_CLOSE_ADJUSTMENT_FAILED',
details: {
closure_id: closureId,
adjustment_entry_id: entry.id,
message: `Justeringsverifikatet är bokfört men kunde inte länkas till semesterårsavslutet: ${linkError.message}`,
},
}
}
} catch (err) {
// The days roll committed; the adjustment did not. Surface loudly:
// the closure row (sans adjustment_entry_id) shows exactly what is
// missing, and the entry can be posted manually from the report.
return {
ok: false,
code: 'VACATION_CLOSE_ADJUSTMENT_FAILED',
details: {
closure_id: closureId,
message: err instanceof Error ? err.message : 'unknown',
},
}
}
}
}
return { ok: true, data: { closure_id: closureId, adjustment_entry_id: adjustmentEntryId, report } }
}
+243
View File
@@ -0,0 +1,243 @@
/**
* Vacation balance ledger sync (payroll gap-closure 3.2).
*
* Keeps employee_vacation_balances (per employee, per vacation year) in step
* with reality after every salary-run booking or correction.
*
* RECOMPUTE, never increment: taken_days is re-derived from the currently
* BOOKED runs inside each open year's bounds on every call. Idempotent and
* self-healing; a corrected run simply drops out of the sum with no special
* casing.
*
* Days only: the SEK side of the liability stays derived (2920/2940 are
* booked per run plus the cutover opening term); see the ledger migration
* header for the rationale.
*
* NON-FATAL CONTRACT: callers (book/correct routes) wrap this in try/catch
* and log a warning on failure. A ledger bug must never block a legally
* required booking; the next successful sync heals any gap.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import {
getVacationYearBounds,
getVacationYearStart,
type VacationYearBasis,
} from './vacation-year'
export interface VacationBalanceRow {
id: string
employee_id: string
vacation_year_start: string
entitled_days: number
accrued_days: number
taken_days: number
saved_days: Record<string, number>
forced_payout_days: number
status: 'open' | 'closed'
}
export async function getVacationYearBasis(
supabase: SupabaseClient,
companyId: string,
): Promise<VacationYearBasis> {
const { data } = await supabase
.from('company_settings')
.select('salary_vacation_year_basis')
.eq('company_id', companyId)
.maybeSingle()
return ((data as { salary_vacation_year_basis?: string } | null)?.salary_vacation_year_basis ===
'statutory_apr_mar'
? 'statutory_apr_mar'
: 'calendar') as VacationYearBasis
}
/**
* Recompute the OPEN ledger rows for the given employees and lazy-seed the
* current vacation year's row where none exists.
*
* `asOf` exists for determinism in tests; production callers omit it.
*/
export async function syncVacationLedgerForEmployees(
supabase: SupabaseClient,
companyId: string,
employeeIds: string[],
asOf?: string,
): Promise<{ ok: true } | { ok: false; message: string }> {
if (employeeIds.length === 0) return { ok: true }
const asOfDate = asOf ?? new Date().toISOString().slice(0, 10)
try {
const basis = await getVacationYearBasis(supabase, companyId)
const currentYearStart = getVacationYearStart(asOfDate, basis)
const { data: employees, error: empErr } = await supabase
.from('employees')
.select('id, vacation_days_per_year, vacation_days_saved, vacation_rule')
.eq('company_id', companyId)
.in('id', employeeIds)
if (empErr) return { ok: false, message: empErr.message }
const employeeById = new Map(
((employees ?? []) as Array<{
id: string
vacation_days_per_year: number
vacation_days_saved: number
vacation_rule: string
}>).map((e) => [e.id, e]),
)
const { data: openings, error: openErr } = await supabase
.from('employee_opening_balances')
.select('employee_id, cutover_date, vacation_paid_days_remaining, vacation_saved_days_by_year')
.eq('company_id', companyId)
.in('employee_id', employeeIds)
if (openErr) return { ok: false, message: openErr.message }
const openingByEmployee = new Map(
((openings ?? []) as Array<{
employee_id: string
cutover_date: string
vacation_paid_days_remaining: number
vacation_saved_days_by_year: Record<string, number> | null
}>).map((o) => [o.employee_id, o]),
)
const { data: ledgerRows, error: ledgerErr } = await supabase
.from('employee_vacation_balances')
.select('id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days, status')
.eq('company_id', companyId)
.eq('status', 'open')
.in('employee_id', employeeIds)
if (ledgerErr) return { ok: false, message: ledgerErr.message }
const openRows = (ledgerRows ?? []) as unknown as VacationBalanceRow[]
// Booked vacation days per employee, bucketed later per year bounds.
const { data: bookedRows, error: bookedErr } = await supabase
.from('salary_run_employees')
.select('employee_id, vacation_days_taken, salary_run:salary_runs!inner(period_year, period_month, status)')
.eq('company_id', companyId)
.eq('salary_run.status', 'booked')
.in('employee_id', employeeIds)
if (bookedErr) return { ok: false, message: bookedErr.message }
const booked = ((bookedRows ?? []) as unknown as Array<{
employee_id: string
vacation_days_taken: number
salary_run: { period_year: number; period_month: number; status: string } | null
}>).filter((r) => r.salary_run?.status === 'booked')
const takenInYear = (employeeId: string, yearStart: string): number => {
const bounds = getVacationYearBounds(yearStart)
let sum = 0
for (const row of booked) {
if (row.employee_id !== employeeId) continue
const run = row.salary_run!
const periodDate = `${run.period_year}-${String(run.period_month).padStart(2, '0')}-01`
if (periodDate >= bounds.start && periodDate < bounds.end) {
sum += row.vacation_days_taken || 0
}
}
return sum
}
const upserts: Array<Record<string, unknown>> = []
for (const employeeId of employeeIds) {
const employee = employeeById.get(employeeId)
if (!employee) continue
const rowsForEmployee = openRows.filter((r) => r.employee_id === employeeId)
const hasCurrentYearRow = rowsForEmployee.some(
(r) => r.vacation_year_start === currentYearStart,
)
// Recompute every open year the employee has.
for (const row of rowsForEmployee) {
upserts.push({
company_id: companyId,
employee_id: employeeId,
vacation_year_start: row.vacation_year_start,
entitled_days: row.entitled_days,
accrued_days: computeAccruedDays(basis, row.vacation_year_start, asOfDate, employee.vacation_days_per_year),
taken_days: takenInYear(employeeId, row.vacation_year_start),
saved_days: row.saved_days ?? {},
forced_payout_days: row.forced_payout_days ?? 0,
status: 'open',
})
}
// Lazy-seed the current year on first touch.
if (!hasCurrentYearRow) {
const opening = openingByEmployee.get(employeeId)
const cutoverInThisYear =
!!opening &&
opening.cutover_date >= currentYearStart &&
opening.cutover_date < getVacationYearBounds(currentYearStart).end
let savedDays: Record<string, number>
if (cutoverInThisYear && opening) {
savedDays = opening.vacation_saved_days_by_year ?? {}
} else if ((employee.vacation_days_saved || 0) > 0) {
// Legacy master field has no origin-year data: attribute the whole
// balance to the year before this one (the most conservative choice
// for the 5-year expiry: it expires EARLIER, never later).
const previousYear = String(Number(currentYearStart.slice(0, 4)) - 1)
savedDays = { [previousYear]: employee.vacation_days_saved }
} else {
savedDays = {}
}
upserts.push({
company_id: companyId,
employee_id: employeeId,
vacation_year_start: currentYearStart,
entitled_days:
cutoverInThisYear && opening
? opening.vacation_paid_days_remaining
: employee.vacation_days_per_year,
accrued_days: computeAccruedDays(basis, currentYearStart, asOfDate, employee.vacation_days_per_year),
taken_days: takenInYear(employeeId, currentYearStart),
saved_days: savedDays,
forced_payout_days: 0,
status: 'open',
})
}
}
if (upserts.length === 0) return { ok: true }
const { error: upsertErr } = await supabase
.from('employee_vacation_balances')
.upsert(upserts, { onConflict: 'company_id,employee_id,vacation_year_start' })
if (upsertErr) return { ok: false, message: upsertErr.message }
return { ok: true }
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : 'ledger sync failed' }
}
}
/**
* Intjänade dagar toward NEXT year: only meaningful on the statutory
* Apr-Mar basis, where intjänandeår (this year) and semesterår (next year)
* are split. Sammanfallande calendar years earn and take in the same year,
* so the live number is entitled - taken and accrued stays 0.
*/
function computeAccruedDays(
basis: VacationYearBasis,
yearStart: string,
asOfDate: string,
vacationDaysPerYear: number,
): number {
if (basis !== 'statutory_apr_mar') return 0
const bounds = getVacationYearBounds(yearStart)
if (asOfDate < bounds.start) return 0
if (asOfDate >= bounds.end) return vacationDaysPerYear
const startYear = Number(yearStart.slice(0, 4))
const startMonth = Number(yearStart.slice(5, 7))
const asOfYear = Number(asOfDate.slice(0, 4))
const asOfMonth = Number(asOfDate.slice(5, 7))
const elapsedMonths = (asOfYear - startYear) * 12 + (asOfMonth - startMonth)
// Whole elapsed months / 12, rounded to half days (Semesterlagen 3a §
// rounds UP to whole days at payout; the running accrual view keeps halves
// for transparency).
return Math.round(((elapsedMonths / 12) * vacationDaysPerYear) * 2) / 2
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Vacation year (semesterår) boundary helpers.
*
* Two bases exist (company_settings.salary_vacation_year_basis):
* 'calendar' : sammanfallande intjänande- och semesterår, Jan 1 to
* Dec 31. The small-company norm and our default.
* 'statutory_apr_mar' : the Semesterlagen 3 § default, Apr 1 to Mar 31.
*/
export type VacationYearBasis = 'calendar' | 'statutory_apr_mar'
/** The vacation-year start date (YYYY-MM-DD) containing `dateIso`. */
export function getVacationYearStart(dateIso: string, basis: VacationYearBasis): string {
const year = Number(dateIso.slice(0, 4))
const month = Number(dateIso.slice(5, 7))
if (basis === 'calendar') {
return `${year}-01-01`
}
// Statutory Apr-Mar: Jan-Mar belongs to the year that started the PREVIOUS
// April.
return month >= 4 ? `${year}-04-01` : `${year - 1}-04-01`
}
/** Inclusive start + exclusive end of the vacation year starting at `yearStartIso`. */
export function getVacationYearBounds(yearStartIso: string): { start: string; end: string } {
const year = Number(yearStartIso.slice(0, 4))
const month = yearStartIso.slice(5, 7)
return {
start: yearStartIso,
end: `${year + 1}-${month}-01`,
}
}
/** The vacation year that has ENDED most recently as of `asOfIso`: the one a
* year-close would target. Returns null while the first-ever year is still
* running (nothing closable). */
export function getClosableYearStart(asOfIso: string, basis: VacationYearBasis): string {
const currentStart = getVacationYearStart(asOfIso, basis)
const year = Number(currentStart.slice(0, 4))
const month = currentStart.slice(5, 7)
return `${year - 1}-${month}-01`
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Work-schedule divisors (arbetsschema-lite).
*
* Converts an employee's weekly schedule into the two divisors the salary
* engine uses:
*
* hourly divisor : monthly salary -> effective hourly rate
* daily divisor : monthly salary -> daily rate (sick/VAB/parental
* deductions, sammalöneregeln day valuation)
*
* BACKWARD-COMPAT CONTRACT (deliberate discontinuity): at the DEFAULT
* schedule (40h / 5d) these return the legacy constants 173 and 21, not the
* exact formulas (which give 173.33 and 21.67). Switching the defaults to
* exact formulas would change every running company's monthly-to-hourly
* derivation by ~0.2% and every sick/VAB daily deduction by ~3% mid-year
* with zero schedule change, which is indefensible in a payroll product.
* Non-default schedules use the exact formula: they have no legacy results
* to preserve. Migrating the defaults to exact formulas is deferred to a
* fiscal-year boundary with release notes.
*/
import { roundOre } from '@/lib/money'
/** Legacy CBA convention: 52 weeks x 40 hours / 12 months, truncated. */
export const LEGACY_HOURLY_DIVISOR = 173
/** Legacy convention: 52 weeks x 5 workdays / 12 months, rounded down. */
export const LEGACY_DAILY_DIVISOR = 21
export const DEFAULT_HOURS_PER_WEEK = 40
export const DEFAULT_WORKDAYS_PER_WEEK = 5
/** Monthly-salary -> hourly-rate divisor for a weekly hours schedule. */
export function hourlyDivisor(hoursPerWeek: number | null | undefined): number {
const hours = hoursPerWeek ?? DEFAULT_HOURS_PER_WEEK
if (hours === DEFAULT_HOURS_PER_WEEK) return LEGACY_HOURLY_DIVISOR
return roundOre((hours * 52) / 12)
}
/** Monthly-salary -> daily-rate divisor for a weekly workdays schedule. */
export function dailyDivisor(workdaysPerWeek: number | null | undefined): number {
const days = workdaysPerWeek ?? DEFAULT_WORKDAYS_PER_WEEK
if (days === DEFAULT_WORKDAYS_PER_WEEK) return LEGACY_DAILY_DIVISOR
return roundOre((days * 52) / 12)
}