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