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:
co-authored by
Claude Fable 5
parent
e7e3c35f9e
commit
b6332e9ff4
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)})`,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user