Files
accounted/lib/salary/shift-premium-engine.ts
T
Mattsson 32d9978f1b Fix/chrome pdf preview csp (#572)
* 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>
2026-05-26 22:29:41 +02:00

320 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
}