32d9978f1b
* feat: add option to exclude year-end closing entries in SIE export and related reports * delete docs * fix: allow Chrome's PDF viewer in verifikat document preview The /api/documents/:id/inline route shipped with `object-src 'none'` in its CSP, which blocked Chrome's built-in PDF viewer (it renders inline PDFs via an internal <embed>). Users on Chrome saw "Det här innehållet har blockerats" when expanding a PDF attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own viewer) were unaffected, and JPGs worked because <img> isn't subject to object-src. Drops the CSP for this route to the minimum needed for embeddability: `frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the fixed Content-Type from the handler already block MIME confusion; X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(auth): add webmail deep link to email confirmation screens Mirrors Stripe's signup UX: after asking the user to verify their email, detect their webmail provider from the domain and show a button that opens the inbox in a new tab. Gmail gets a from:<sender> search pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly. Unknown / custom domains fall back to the existing copy. Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM (default noreply@gnubok.se) so white-label installs can match their Supabase Auth SMTP config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): unblock first-time password set for BankID users with MFA Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session is required" whenever a TOTP factor is enrolled. BankID magic-link logins produce AAL1, and middleware skips MFA enforcement for bankid_linked users, so they had no path to AAL2 — leaving them unable to set a backup password or disable MFA without going through the email-recovery escape hatch. - /api/account/password: branch on app_metadata.has_password. First-time set writes via service.auth.admin.updateUserById (no existing credential to protect, AAL2 guard does not apply). Change-password keeps the user-session updateUser so AAL2 still fires for credential rotation. - /mfa/verify: accept a safeReturnTo query param and route there after successful verify, so step-up flows can land back where they came from. - SecuritySettings: detect the AAL2 error from both change-password and mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account instead of toasting a dead-end error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add tests and rounding utility for öre precision in bokslut calculations - Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations. - Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries. - Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency. - Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies. - Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios. * fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility * fix: enhance security by rejecting data URIs in safeReturnTo function tests * fix: improve rounding logic in roundOre function and add customer_type migration * fix: add customer_type column to customers and enforce CHECK constraint --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
/**
|
||
* Shift-premium engine — pure functions that turn worked-day shifts plus
|
||
* configured premium rules into salary line items.
|
||
*
|
||
* Boundary rules:
|
||
* - Worked-day rows with explicit start_time/end_time use the exact window
|
||
* to intersect rule windows.
|
||
* - Worked-day rows missing one or both times fall back to a default day
|
||
* shift of 08:00-17:00 (`DEFAULT_SHIFT_*`). This is the legacy hours-only
|
||
* row shape: those days were always assumed to be plain weekday office
|
||
* hours, so pure-night/pure-weekend rules will not match.
|
||
* - Windows where end_time <= start_time are treated as wrapping past
|
||
* midnight (e.g. 22:00-06:00 covers 22:00-24:00 the same day and
|
||
* 00:00-06:00 the next day). Both halves are intersected separately.
|
||
*
|
||
* Overlap resolution:
|
||
* - Multiple active rules may apply to the same minute of a shift. The
|
||
* engine prefers the rule with the higher `priority`; ties are broken by
|
||
* higher `premium_percent`. Each minute is awarded to exactly one rule,
|
||
* so totals never double-count.
|
||
*
|
||
* The engine is intentionally side-effect-free. The orchestrator
|
||
* (run-calculation.ts) fetches the rules, runs `computePremiumLines`, and
|
||
* persists the result as salary_line_items.
|
||
*/
|
||
|
||
import type { ShiftPremiumRule, ShiftPremiumItemType } from '@/types'
|
||
import { isSwedishHolidayISO } from '@/lib/tax/swedish-holidays'
|
||
|
||
// ============================================================
|
||
// Types
|
||
// ============================================================
|
||
|
||
export interface WorkedDayShift {
|
||
/** ISO date (YYYY-MM-DD). */
|
||
work_date: string
|
||
/** Total worked hours (used as a sanity cap). */
|
||
hours: number
|
||
/** Optional explicit shift start ('HH:MM' or 'HH:MM:SS'). */
|
||
start_time?: string | null
|
||
/** Optional explicit shift end ('HH:MM' or 'HH:MM:SS'). */
|
||
end_time?: string | null
|
||
}
|
||
|
||
export interface ShiftPremiumLineItem {
|
||
itemType: ShiftPremiumItemType
|
||
/** Description rendered on the payslip and verifikat. */
|
||
description: string
|
||
/** Date the premium applies to (the worked day). */
|
||
workDate: string
|
||
/** Hours actually covered by the winning rule. */
|
||
hours: number
|
||
/** Premium amount = baseHourlyRate × hours × premium_percent / 100. */
|
||
amount: number
|
||
/** Rule id used (for traceability/debugging). */
|
||
sourceRuleId: string
|
||
}
|
||
|
||
// ============================================================
|
||
// Constants
|
||
// ============================================================
|
||
|
||
/** Total minutes in a 24h day. */
|
||
const MINUTES_PER_DAY = 24 * 60
|
||
|
||
/** Fallback shift for worked-day rows that don't carry explicit times. */
|
||
const DEFAULT_SHIFT_START_MIN = 8 * 60 // 08:00
|
||
const DEFAULT_SHIFT_END_MIN = 17 * 60 // 17:00
|
||
|
||
// ============================================================
|
||
// Helpers
|
||
// ============================================================
|
||
|
||
/** Round to 2 decimals (CLAUDE.md monetary precision rule). */
|
||
function r(x: number): number {
|
||
return Math.round(x * 100) / 100
|
||
}
|
||
|
||
/** Parse 'HH:MM' or 'HH:MM:SS' (or 'HH:MM:SS.ffffff') to minutes-since-midnight. */
|
||
function parseTimeToMinutes(time: string): number {
|
||
const parts = time.split(':')
|
||
if (parts.length < 2) {
|
||
throw new Error(`Invalid time format: ${time}`)
|
||
}
|
||
const h = parseInt(parts[0], 10)
|
||
const m = parseInt(parts[1], 10)
|
||
if (Number.isNaN(h) || Number.isNaN(m)) {
|
||
throw new Error(`Invalid time format: ${time}`)
|
||
}
|
||
return h * 60 + m
|
||
}
|
||
|
||
/** ISO weekday (1 = Mon … 7 = Sun) from a YYYY-MM-DD string. */
|
||
function isoWeekdayFromDate(workDate: string): number {
|
||
// Parse as UTC to avoid the user's local timezone shifting the day.
|
||
const [y, m, d] = workDate.split('-').map((x) => parseInt(x, 10))
|
||
const day = new Date(Date.UTC(y, m - 1, d)).getUTCDay() // Sun=0…Sat=6
|
||
return day === 0 ? 7 : day
|
||
}
|
||
|
||
/** Shift a weekday by N days, staying in ISO 1..7. */
|
||
function shiftWeekday(weekday: number, offset: number): number {
|
||
const next = ((weekday - 1 + offset) % 7 + 7) % 7
|
||
return next + 1
|
||
}
|
||
|
||
/**
|
||
* Resolve a shift's occupied minutes into segments keyed by ISO weekday.
|
||
* Returns one segment per occupied weekday. Wrap-past-midnight shifts return
|
||
* two entries.
|
||
*/
|
||
interface DaySegment {
|
||
weekday: number
|
||
startMin: number
|
||
endMin: number
|
||
}
|
||
|
||
function resolveShiftSegments(shift: WorkedDayShift): DaySegment[] {
|
||
const shiftWeekdayBase = isoWeekdayFromDate(shift.work_date)
|
||
const hasStart = !!shift.start_time
|
||
const hasEnd = !!shift.end_time
|
||
|
||
if (!hasStart || !hasEnd) {
|
||
return [
|
||
{
|
||
weekday: shiftWeekdayBase,
|
||
startMin: DEFAULT_SHIFT_START_MIN,
|
||
endMin: DEFAULT_SHIFT_END_MIN,
|
||
},
|
||
]
|
||
}
|
||
|
||
const startMin = parseTimeToMinutes(shift.start_time!)
|
||
const endMin = parseTimeToMinutes(shift.end_time!)
|
||
|
||
if (endMin > startMin) {
|
||
return [{ weekday: shiftWeekdayBase, startMin, endMin }]
|
||
}
|
||
|
||
// Wraps past midnight (e.g. 22:00 -> 06:00). Split at 24:00.
|
||
return [
|
||
{ weekday: shiftWeekdayBase, startMin, endMin: MINUTES_PER_DAY },
|
||
{ weekday: shiftWeekday(shiftWeekdayBase, 1), startMin: 0, endMin },
|
||
]
|
||
}
|
||
|
||
/**
|
||
* Resolve a rule's coverage windows into per-weekday segments.
|
||
* Returns one entry per (primary weekday in rule.day_of_week × time half).
|
||
* A rule whose end_time <= start_time wraps midnight; the second half then
|
||
* lands on the weekday AFTER each listed primary day.
|
||
*/
|
||
function resolveRuleSegments(rule: ShiftPremiumRule): DaySegment[] {
|
||
const startMin = parseTimeToMinutes(rule.start_time)
|
||
const endMin = parseTimeToMinutes(rule.end_time)
|
||
|
||
const segments: DaySegment[] = []
|
||
|
||
for (const primaryDay of rule.day_of_week) {
|
||
if (endMin > startMin) {
|
||
segments.push({ weekday: primaryDay, startMin, endMin })
|
||
} else if (startMin === endMin) {
|
||
// 00:00-00:00 special case: full 24h coverage on the primary day only.
|
||
segments.push({ weekday: primaryDay, startMin: 0, endMin: MINUTES_PER_DAY })
|
||
} else {
|
||
// Wrap past midnight.
|
||
segments.push({ weekday: primaryDay, startMin, endMin: MINUTES_PER_DAY })
|
||
segments.push({ weekday: shiftWeekday(primaryDay, 1), startMin: 0, endMin })
|
||
}
|
||
}
|
||
|
||
return segments
|
||
}
|
||
|
||
function ruleAppliesToEmployee(rule: ShiftPremiumRule, employeeId: string): boolean {
|
||
if (rule.applies_to_all_employees) return true
|
||
return rule.applies_to_employee_ids.includes(employeeId)
|
||
}
|
||
|
||
/** Total dominance score (priority outweighs premium_percent). */
|
||
function ruleScore(rule: ShiftPremiumRule): number {
|
||
return rule.priority * 1_000_000 + rule.premium_percent
|
||
}
|
||
|
||
// ============================================================
|
||
// Public API
|
||
// ============================================================
|
||
|
||
export interface ComputePremiumLinesArgs {
|
||
employeeId: string
|
||
baseHourlyRate: number
|
||
workedDays: WorkedDayShift[]
|
||
rules: ShiftPremiumRule[]
|
||
}
|
||
|
||
const DEFAULT_DESCRIPTIONS: Record<ShiftPremiumItemType, string> = {
|
||
overtime_50: 'Övertid 50 %',
|
||
overtime_100: 'Övertid 100 %',
|
||
ob_weekday_evening: 'OB vardag kväll',
|
||
ob_weekend: 'OB helg',
|
||
ob_night: 'OB natt',
|
||
ob_holiday: 'OB helgdag',
|
||
}
|
||
|
||
/**
|
||
* Compute premium line items for a single employee over a set of worked days.
|
||
*
|
||
* Algorithm:
|
||
* 1. Resolve each shift into per-weekday segments (one or two if wraps).
|
||
* 2. For each segment, gather every rule-segment whose weekday matches.
|
||
* 3. Split the segment at every rule boundary, then award each sub-interval
|
||
* to the single highest-scoring rule that fully covers it.
|
||
* 4. Aggregate (work_date × rule_id) → minutes; convert to hours + amount.
|
||
*/
|
||
export function computePremiumLines(args: ComputePremiumLinesArgs): ShiftPremiumLineItem[] {
|
||
const { employeeId, baseHourlyRate, workedDays, rules } = args
|
||
if (baseHourlyRate <= 0 || workedDays.length === 0 || rules.length === 0) {
|
||
return []
|
||
}
|
||
|
||
const applicable = rules.filter((rule) => rule.is_active && ruleAppliesToEmployee(rule, employeeId))
|
||
if (applicable.length === 0) return []
|
||
|
||
// Aggregate (workDate × ruleId) → minutes.
|
||
const aggregates = new Map<
|
||
string,
|
||
{ rule: ShiftPremiumRule; workDate: string; minutes: number }
|
||
>()
|
||
|
||
for (const shift of workedDays) {
|
||
if (shift.hours <= 0) continue
|
||
const shiftSegments = resolveShiftSegments(shift)
|
||
// Holiday status drives ob_holiday gating. A rule with item_type === 'ob_holiday'
|
||
// only fires when the worked day is a Swedish public holiday — day_of_week alone
|
||
// is not enough (a regular Sunday is not a helgdag, Midsommarafton on a Tuesday is).
|
||
const isHoliday = isSwedishHolidayISO(shift.work_date)
|
||
|
||
for (const seg of shiftSegments) {
|
||
// Collect rule-segments intersecting this weekday.
|
||
const candidates: Array<{
|
||
rule: ShiftPremiumRule
|
||
startMin: number
|
||
endMin: number
|
||
}> = []
|
||
for (const rule of applicable) {
|
||
if (rule.item_type === 'ob_holiday' && !isHoliday) continue
|
||
for (const ruleSeg of resolveRuleSegments(rule)) {
|
||
if (ruleSeg.weekday !== seg.weekday) continue
|
||
const startMin = Math.max(seg.startMin, ruleSeg.startMin)
|
||
const endMin = Math.min(seg.endMin, ruleSeg.endMin)
|
||
if (endMin <= startMin) continue
|
||
candidates.push({ rule, startMin, endMin })
|
||
}
|
||
}
|
||
if (candidates.length === 0) continue
|
||
|
||
// Build boundary set inside the shift segment.
|
||
const boundaries = new Set<number>([seg.startMin, seg.endMin])
|
||
for (const cand of candidates) {
|
||
boundaries.add(cand.startMin)
|
||
boundaries.add(cand.endMin)
|
||
}
|
||
const sorted = [...boundaries].sort((a, b) => a - b)
|
||
|
||
for (let i = 0; i < sorted.length - 1; i++) {
|
||
const subStart = sorted[i]
|
||
const subEnd = sorted[i + 1]
|
||
if (subEnd <= subStart) continue
|
||
if (subStart < seg.startMin || subEnd > seg.endMin) continue
|
||
|
||
// Award this sub-interval to the highest-scoring candidate covering it.
|
||
let winner: ShiftPremiumRule | null = null
|
||
for (const cand of candidates) {
|
||
if (cand.startMin > subStart || cand.endMin < subEnd) continue
|
||
if (!winner || ruleScore(cand.rule) > ruleScore(winner)) {
|
||
winner = cand.rule
|
||
}
|
||
}
|
||
if (!winner) continue
|
||
|
||
const minutes = subEnd - subStart
|
||
const key = `${shift.work_date}::${winner.id}`
|
||
const existing = aggregates.get(key)
|
||
if (existing) {
|
||
existing.minutes += minutes
|
||
} else {
|
||
aggregates.set(key, { rule: winner, workDate: shift.work_date, minutes })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const lineItems: ShiftPremiumLineItem[] = []
|
||
for (const agg of aggregates.values()) {
|
||
const hours = r(agg.minutes / 60)
|
||
if (hours <= 0) continue
|
||
const amount = r(baseHourlyRate * hours * (agg.rule.premium_percent / 100))
|
||
if (amount <= 0) continue
|
||
const fallback = DEFAULT_DESCRIPTIONS[agg.rule.item_type]
|
||
const desc = agg.rule.name
|
||
? `${agg.rule.name} (${agg.workDate}, ${hours} h)`
|
||
: `${fallback} (${agg.workDate}, ${hours} h)`
|
||
lineItems.push({
|
||
itemType: agg.rule.item_type,
|
||
description: desc,
|
||
workDate: agg.workDate,
|
||
hours,
|
||
amount,
|
||
sourceRuleId: agg.rule.id,
|
||
})
|
||
}
|
||
|
||
lineItems.sort((a, b) => {
|
||
if (a.workDate !== b.workDate) return a.workDate < b.workDate ? -1 : 1
|
||
return a.itemType < b.itemType ? -1 : 1
|
||
})
|
||
|
||
return lineItems
|
||
}
|