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
+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) {