Fix/m sprint fixes (#613)

* fix(dashboard): exclude ignored and already-triaged transactions from stale count

The "Gamla transaktioner" widget counted transactions that had been ignored
or already marked as is_business=true but not yet booked, so users saw a
nag for a row they had already dealt with — and the /transactions inbox
correctly hid it. Align the count with the inbox criterion (is_business
IS NULL, is_ignored = false) so the widget clears when the row leaves
the inbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(transactions): read entity_type from settings response wrapper

The transactions page read entityRes.entity_type directly, but
/api/settings returns { data: { entity_type, ... } }. The expression
was always undefined, so setEntityType never fired and entityType
stayed at its initial 'enskild_firma'. The template picker's
entity_type filter then dropped every aktiebolag-tagged user template
for AB customers — only entity_type='all' templates made it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* stale templates
bank sync
journal entry from transaction

* fixed pr comments

* fixed pr comment

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-30 01:28:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent fc7a46c3f2
commit ea1bf01f1e
76 changed files with 5474 additions and 532 deletions
+175 -1
View File
@@ -1,5 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { calculateSalary, calculateKarensavdrag, calculateSjuklon, calculateAvgifterRate, calculateVacationAccrual } from '../calculation-engine'
import {
calculateSalary,
calculateKarensavdrag,
calculateSjuklon,
calculateAvgifterRate,
calculateVacationAccrual,
prorateBaseSalaryForPeriod,
} from '../calculation-engine'
import type { PayrollConfig } from '../payroll-config'
import type { TaxTableRate } from '../tax-tables'
@@ -384,6 +391,173 @@ describe('calculateSalary', () => {
})
})
// ============================================================
// Partial-month employment proration
// ============================================================
//
// May 2026 calendar (Mon-Fri only):
// May 1 (Fri), May 4-8 (5), May 11-15 (5), May 18-22 (5), May 25-29 (5)
// → 21 workdays total in May.
// An employee hired May 15 (Fri) works May 15, 18-22, 25-29 → 11 workdays.
// 11 / 21 ≈ 0.5238 → 40 000 SEK × 0.5238 ≈ 20 952,38 SEK.
describe('partial-month employment proration', () => {
it('prorates base salary for an employee hired mid-month', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-05-15',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
// 40 000 × 11 / 21 = 20 952,38 (rounded via engine's r())
expect(result.grossSalary).toBeCloseTo(20952.38, 2)
})
it('prorates base salary for an employee terminated mid-month', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2020-01-01',
employmentEnd: '2026-05-15',
}),
config2026,
emptyTaxRates,
)
// May 1, 4-8, 11-15 = 11 workdays → 40 000 × 11 / 21
expect(result.grossSalary).toBeCloseTo(20952.38, 2)
})
it('does not prorate when the employee covers the full period', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2024-01-01',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBe(40000)
})
it('returns 0 gross when employment does not overlap the period', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-06-01',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBe(0)
})
it('combines employment proration with employment_degree', () => {
const result = calculateSalary(
makeBasicInput({
employmentDegree: 50,
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-05-15',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
// 40 000 × 50% × 11 / 21 = 20 000 × 11 / 21 ≈ 10 476,19
expect(result.grossSalary).toBeCloseTo(10476.19, 2)
})
it('skips proration when period bounds are missing (backward compat)', () => {
const result = calculateSalary(
makeBasicInput({
employmentStart: '2026-05-15',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBe(40000)
})
it('subtracts unpaid_leave once on top of proration (no double-counting)', () => {
// 40 000 × 11/21 (mid-month hire) − 2 × 40 000/21 (two unpaid days)
// = 20 952,38 − 3 809,52
// = 17 142,86
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-05-15',
employmentEnd: null,
lineItems: [
{
itemType: 'unpaid_leave',
amount: -3809.52, // = 2 × Math.round((40000/21) * 100) / 100
isTaxable: true,
isAvgiftBasis: true,
isVacationBasis: false,
isGrossDeduction: false,
isNetDeduction: false,
},
],
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBeCloseTo(17142.86, 2)
})
})
describe('prorateBaseSalaryForPeriod', () => {
it('returns 1 when all dates are missing', () => {
expect(prorateBaseSalaryForPeriod(undefined, undefined, undefined, undefined)).toBe(1)
})
it('returns 1 when employment fully covers the period', () => {
expect(
prorateBaseSalaryForPeriod('2020-01-01', null, '2026-05-01', '2026-05-31'),
).toBe(1)
})
it('returns 11/21 for May 2026 mid-month hire on the 15th', () => {
const ratio = prorateBaseSalaryForPeriod(
'2026-05-15',
null,
'2026-05-01',
'2026-05-31',
)
expect(ratio).toBeCloseTo(11 / 21, 6)
})
it('returns 0 when employment ends before the period starts', () => {
expect(
prorateBaseSalaryForPeriod('2020-01-01', '2026-04-30', '2026-05-01', '2026-05-31'),
).toBe(0)
})
it('returns 0 when employment starts after the period ends', () => {
expect(
prorateBaseSalaryForPeriod('2026-06-01', null, '2026-05-01', '2026-05-31'),
).toBe(0)
})
})
// ============================================================
// Hardening — realistic API flow & cross-rule invariants
// ============================================================
@@ -237,11 +237,34 @@ describe('deriveAbsenceLineItems — parental', () => {
})
})
describe('deriveAbsenceLineItems — unpaid_leave', () => {
it('emits unpaid_leave line item with a per-day daily-rate deduction', () => {
const result = deriveAbsenceLineItems(
baseInput({
monthlySalary: 42000, // dailyRate = 42 000 / 21 = 2 000
periodDays: days([
['2026-04-10', 'unpaid_leave'],
['2026-04-13', 'unpaid_leave'],
]),
}),
)
const unpaid = result.lineItems.find(li => li.item_type === 'unpaid_leave')
expect(unpaid).toBeDefined()
expect(unpaid!.quantity).toBe(2)
expect(unpaid!.amount).toBe(-4000)
// false — engine's Step 3 absence sum already subtracts unpaid_leave;
// setting the flag would double-count in Step 4 totalGrossDeductions.
expect(unpaid!.is_gross_deduction).toBe(false)
expect(unpaid!.is_vacation_basis).toBe(false)
expect(result.aggregated.unpaidLeaveDays).toBe(2)
})
})
describe('deriveAbsenceLineItems — empty', () => {
it('returns empty result for no absence', () => {
const result = deriveAbsenceLineItems(baseInput())
expect(result.lineItems).toEqual([])
expect(result.aggregated).toEqual({ sickDays: 0, vabDays: 0, parentalDays: 0 })
expect(result.aggregated).toEqual({ sickDays: 0, vabDays: 0, parentalDays: 0, unpaidLeaveDays: 0 })
expect(result.flagFkReporting).toBe(false)
expect(result.flagLakarintyg).toBe(false)
})
+1
View File
@@ -37,6 +37,7 @@ const LINE_ITEM_ACCOUNTS: Record<SalaryLineItemType, string> = {
sick_day15_plus: '7281',
vab: '7210',
parental_leave: '7210',
unpaid_leave: '7210',
vacation: '7285',
semesterersattning: '7285',
// Travel
+123 -8
View File
@@ -42,6 +42,17 @@ export interface SalaryCalculationInput {
/** Line items */
lineItems: CalculationLineItem[]
/**
* Pay period bounds (YYYY-MM-DD). Together with employmentStart/employmentEnd
* they drive partial-month proration: an employee hired mid-period or
* terminated mid-period receives only the workday-fraction of base salary.
* When omitted, proration is skipped (ratio = 1).
*/
periodStart?: string
periodEnd?: string
employmentStart?: string
employmentEnd?: string | null
}
export interface CalculationLineItem {
@@ -134,6 +145,80 @@ function fmtKr(amount: number): string {
return `${Math.round(amount).toLocaleString('sv-SE')} kr`
}
// ============================================================
// Partial-month proration
// ============================================================
const DAY_MS = 24 * 60 * 60 * 1000
function parseIsoDateUtc(s: string): Date {
return new Date(`${s}T00:00:00Z`)
}
function maxDate(a: string, b: string): string {
return a >= b ? a : b
}
function minDate(a: string, b: string): string {
return a <= b ? a : b
}
/**
* Count Mon–Fri days inclusive between start and end (YYYY-MM-DD). Returns 0
* when start > end. Swedish bank holidays are NOT excluded — the engine uses
* the same 21-workday convention used elsewhere (monthlySalary / 21), so a
* variable workday count that excluded holidays would diverge from the
* baseline daily rate convention.
*/
function countWorkdaysInclusive(start: string, end: string): number {
if (start > end) return 0
const startMs = parseIsoDateUtc(start).getTime()
const endMs = parseIsoDateUtc(end).getTime()
const totalDays = Math.round((endMs - startMs) / DAY_MS) + 1
let workdays = 0
for (let i = 0; i < totalDays; i++) {
const d = new Date(startMs + i * DAY_MS)
const dow = d.getUTCDay() // 0 = Sun, 6 = Sat
if (dow >= 1 && dow <= 5) workdays += 1
}
return workdays
}
/**
* Fraction of the pay period the employee was actually employed, measured in
* Mon–Fri workdays. Returns 1 when the employee was employed for the full
* period (or when employment dates / period bounds are missing). Returns 0
* when the employee was not employed at all during the period.
*
* This is the standard Swedish payroll convention for partial-month proration:
* an employee hired 2026-05-15 gets workdays-in-(May 15–31) / workdays-in-May.
* Hourly employees are not prorated here — they are paid for actually-worked
* hours, so the calling code passes salaryType='monthly' to gate this.
*/
export function prorateBaseSalaryForPeriod(
employmentStart: string | undefined,
employmentEnd: string | null | undefined,
periodStart: string | undefined,
periodEnd: string | undefined,
): number {
if (!periodStart || !periodEnd) return 1
if (!employmentStart) return 1
const effectiveStart = maxDate(employmentStart, periodStart)
const effectiveEnd = employmentEnd ? minDate(employmentEnd, periodEnd) : periodEnd
if (effectiveStart > effectiveEnd) return 0
// Fast path: employment fully covers the period.
if (employmentStart <= periodStart && (!employmentEnd || employmentEnd >= periodEnd)) {
return 1
}
const overlap = countWorkdaysInclusive(effectiveStart, effectiveEnd)
const total = countWorkdaysInclusive(periodStart, periodEnd)
if (total === 0) return 1
const ratio = overlap / total
if (ratio < 0) return 0
if (ratio > 1) return 1
return ratio
}
// ============================================================
// Main calculation
// ============================================================
@@ -162,13 +247,43 @@ export function calculateSalary(
// ─── Step 1: Base salary ───
let baseSalary: number
if (input.salaryType === 'monthly') {
baseSalary = r(input.monthlySalary * (input.employmentDegree / 100))
steps.push({
label: 'Grundlön',
formula: 'månadslön × (sysselsättningsgrad / 100)',
input: { monthly_salary: input.monthlySalary, employment_degree: input.employmentDegree },
output: baseSalary,
})
const degreeAdjusted = r(input.monthlySalary * (input.employmentDegree / 100))
const prorationRatio = prorateBaseSalaryForPeriod(
input.employmentStart,
input.employmentEnd,
input.periodStart,
input.periodEnd,
)
if (prorationRatio < 1 && input.periodStart && input.periodEnd) {
baseSalary = r(degreeAdjusted * prorationRatio)
const overlapStart = input.employmentStart && input.employmentStart > input.periodStart
? input.employmentStart
: input.periodStart
const overlapEnd = input.employmentEnd && input.employmentEnd < input.periodEnd
? input.employmentEnd
: input.periodEnd
steps.push({
label: 'Grundlön (proportionerad anställningsperiod)',
formula: 'månadslön × (sysselsättningsgrad / 100) × (arbetsdagar i anställning / arbetsdagar i period)',
input: {
monthly_salary: input.monthlySalary,
employment_degree: input.employmentDegree,
degree_adjusted: degreeAdjusted,
overlap_start: overlapStart,
overlap_end: overlapEnd,
proration_ratio: Math.round(prorationRatio * 10000) / 10000,
},
output: baseSalary,
})
} else {
baseSalary = degreeAdjusted
steps.push({
label: 'Grundlön',
formula: 'månadslön × (sysselsättningsgrad / 100)',
input: { monthly_salary: input.monthlySalary, employment_degree: input.employmentDegree },
output: baseSalary,
})
}
} else {
const hours = input.hoursWorked || 0
const rate = input.hourlyRate || 0
@@ -205,7 +320,7 @@ export function calculateSalary(
// ─── Step 3: Subtract absence deductions ───
const absenceItems = input.lineItems.filter(
li => ['sick_karens', 'sick_day2_14', 'sick_day15_plus', 'vab', 'parental_leave', 'vacation'].includes(li.itemType)
li => ['sick_karens', 'sick_day2_14', 'sick_day15_plus', 'vab', 'parental_leave', 'unpaid_leave', 'vacation'].includes(li.itemType)
)
const totalAbsence = r(absenceItems.reduce((sum, li) => sum + li.amount, 0))
if (totalAbsence !== 0) {
+16 -2
View File
@@ -49,7 +49,21 @@ export async function createSalaryRunWithEmployees(
.eq('company_id', companyId)
.eq('is_active', true)
for (const emp of employees || []) {
// Pay period bounds (inclusive) — used to skip employees whose employment
// does not overlap the run. employment_start is NOT NULL on employees;
// employment_end is nullable for ongoing employments.
const periodStart = `${params.periodYear}-${String(params.periodMonth).padStart(2, '0')}-01`
const periodEnd = new Date(Date.UTC(params.periodYear, params.periodMonth, 0))
.toISOString()
.slice(0, 10)
const eligibleEmployees = (employees || []).filter((emp) => {
if (emp.employment_start && emp.employment_start > periodEnd) return false
if (emp.employment_end && emp.employment_end < periodStart) return false
return true
})
for (const emp of eligibleEmployees) {
const baseAmount =
emp.salary_type === 'monthly'
? Math.round((emp.monthly_salary || 0) * (emp.employment_degree / 100) * 100) / 100
@@ -91,7 +105,7 @@ export async function createSalaryRunWithEmployees(
}
}
return { run: run as Record<string, unknown>, employeeCount: (employees || []).length }
return { run: run as Record<string, unknown>, employeeCount: eligibleEmployees.length }
} catch (err) {
// Compensating delete — never leave a half-populated run. Cascade removes
// any salary_run_employees / salary_line_items already inserted.
+29 -1
View File
@@ -35,6 +35,7 @@ export type AbsenceType =
| 'pregnancy'
| 'care_relative'
| 'study'
| 'unpaid_leave'
| 'other_leave'
export interface AbsenceDay {
@@ -44,7 +45,7 @@ export interface AbsenceDay {
}
export interface DerivedLineItem {
item_type: 'sick_karens' | 'sick_day2_14' | 'sick_day15_plus' | 'vab' | 'parental_leave'
item_type: 'sick_karens' | 'sick_day2_14' | 'sick_day15_plus' | 'vab' | 'parental_leave' | 'unpaid_leave'
description: string
quantity: number
amount: number
@@ -58,6 +59,7 @@ export interface AggregatedCounts {
sickDays: number
vabDays: number
parentalDays: number
unpaidLeaveDays: number
}
export interface DeriveResult {
@@ -173,6 +175,7 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
.map(d => d.absence_date)
const vabDays = periodDays.filter(d => d.absence_type === 'vab')
const parentalDays = periodDays.filter(d => d.absence_type === 'parental')
const unpaidLeaveDays = periodDays.filter(d => d.absence_type === 'unpaid_leave')
let flagFkReporting = false
let flagLakarintyg = false
@@ -334,12 +337,37 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
})
}
// ── Unpaid leave (tjänstledighet utan lön) ─────────────────────────────
// Each day reduces gross pay by one daily rate (monthlySalary / 21 — same
// convention used elsewhere in the engine). Not semestergrundande per SemL
// 17 § (only paid leave types accrue vacation).
//
// is_gross_deduction is deliberately false: the engine's Step 3 absence
// sum already subtracts items whose item_type is 'unpaid_leave', so setting
// 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 deduction = r(dailyRate * unpaidLeaveCount)
lineItems.push({
item_type: 'unpaid_leave',
description: `Tjänstledighet utan lön (${unpaidLeaveCount} dagar)`,
quantity: unpaidLeaveCount,
amount: -deduction,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: false,
is_gross_deduction: false,
})
}
return {
lineItems,
aggregated: {
sickDays: periodSickDates.length,
vabDays: vabCount,
parentalDays: parentalCount,
unpaidLeaveDays: unpaidLeaveCount,
},
flagFkReporting,
flagLakarintyg,
+5
View File
@@ -43,6 +43,7 @@ const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [
'sick_day15_plus',
'vab',
'parental_leave',
'unpaid_leave',
]
/**
@@ -598,6 +599,10 @@ export async function runSalaryCalculation(
vaxaStodStart: emp.vaxa_stod_start,
vaxaStodEnd: emp.vaxa_stod_end,
lineItems,
periodStart,
periodEnd,
employmentStart: emp.employment_start,
employmentEnd: emp.employment_end,
},
config,
taxRates.map((r) => ({