* fix(year-end): stop revaluing FX items that were not on the balance sheet The year-end close ran currency revaluation as an unconditional step before the irreversible close, and the revaluation queried LIVE open invoices with no date scoping. An invoice issued after balansdagen, settled before it, or never booked at all was therefore revalued into the year being closed, writing down a 1510/2440 that stood at zero. Because the entry lands inside the same run that closes the period, the only remedy left was a rattelse in the following year. The population is now measured as of balansdagen, reusing the reconstruction the reskontra reports already use (fetchPaymentsAsOf / outstandingAsOf): the invoice_date ceiling is unconditional (post-dated invoices make the bug reachable for a current period too) and the widening to 'paid' applies only to a historical date, where a since-settled invoice was still open then. Rows that carry no balance-sheet exposure are skipped per row rather than per company: an unbooked registration is not on 1510/2440. Deliberately NOT keyed on accounting_method, since BFL 5 kap 2 § 3 st requires kontantmetoden companies to book their outstanding fordringar/skulder at balansdagen, and those converted rows are genuine exposure that ARL 4 kap. 13 § must value. The readiness warning stays ungated on purpose: an unbooked FX row is exactly what deserves a warning, because /book still posts it into the year about to close and lockPeriod/closePeriod then removes that remedy for good. The wizard preview now lists the per-invoice revaluation rows it will post instead of three aggregate numbers, so the user approves line-level content before the close. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bookkeeping): reach accounts outside BAS 2026 from a verifikat rattelse A user could not move a verifikat line to konto 8022: the picker reported no such account and offered no way forward. 8022 was dropped from BAS 2026 (it is in BAS 2018), so it is a legitimate company-specific underkonto rather than a catalog gap. Verified against the official bas.se kontoplan that our BAS reference already matches BAS 2026, so 8022 is deliberately NOT added to it: seeding a retired account would push it onto every company. StrikeLinesDialog and CorrectionEntryDialog were the only account pickers in the app that never passed onCreateAccount, so their combobox rendered a dead empty state. Both now open AddAccountDialog prefilled, then refetch the chart and select the new account on the initiating line, leaving the half-finished rattelse intact. AccountCombobox closed its dropdown on the fourth digit of any committed number, which hid the empty state before it was ever painted and made the create affordance unreachable for exactly the numbers that need it. It now closes only when the number matches something, so focus still advances to the belopp field for real accounts. No change to posting rules: correct_entry_lines_inline validates chart membership, not BAS membership, and account creation already required the same write role. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(vacation): adjust vacation accrual calculations for mid-year hires and update related logic --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
/**
|
|
* Vacation balance ledger sync (payroll gap-closure 3.2).
|
|
*
|
|
* Keeps employee_vacation_balances (per employee, per vacation year) in step
|
|
* with reality after every salary-run booking or correction.
|
|
*
|
|
* RECOMPUTE, never increment: taken_days is re-derived from the currently
|
|
* BOOKED runs inside each open year's bounds on every call. Idempotent and
|
|
* self-healing; a corrected run simply drops out of the sum with no special
|
|
* casing.
|
|
*
|
|
* Days only: the SEK side of the liability stays derived (2920/2940 are
|
|
* booked per run plus the cutover opening term); see the ledger migration
|
|
* header for the rationale.
|
|
*
|
|
* NON-FATAL CONTRACT: callers (book/correct routes) wrap this in try/catch
|
|
* and log a warning on failure. A ledger bug must never block a legally
|
|
* required booking; the next successful sync heals any gap.
|
|
*/
|
|
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
getVacationYearBounds,
|
|
getVacationYearStart,
|
|
type VacationYearBasis,
|
|
} from './vacation-year'
|
|
|
|
export interface VacationBalanceRow {
|
|
id: string
|
|
employee_id: string
|
|
vacation_year_start: string
|
|
entitled_days: number
|
|
accrued_days: number
|
|
taken_days: number
|
|
saved_days: Record<string, number>
|
|
forced_payout_days: number
|
|
status: 'open' | 'closed'
|
|
}
|
|
|
|
export async function getVacationYearBasis(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
): Promise<VacationYearBasis> {
|
|
const { data } = await supabase
|
|
.from('company_settings')
|
|
.select('salary_vacation_year_basis')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
return ((data as { salary_vacation_year_basis?: string } | null)?.salary_vacation_year_basis ===
|
|
'statutory_apr_mar'
|
|
? 'statutory_apr_mar'
|
|
: 'calendar') as VacationYearBasis
|
|
}
|
|
|
|
/**
|
|
* Recompute the OPEN ledger rows for the given employees and lazy-seed the
|
|
* current vacation year's row where none exists.
|
|
*
|
|
* `asOf` exists for determinism in tests; production callers omit it.
|
|
*/
|
|
export async function syncVacationLedgerForEmployees(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
employeeIds: string[],
|
|
asOf?: string,
|
|
): Promise<{ ok: true } | { ok: false; message: string }> {
|
|
if (employeeIds.length === 0) return { ok: true }
|
|
const asOfDate = asOf ?? new Date().toISOString().slice(0, 10)
|
|
|
|
try {
|
|
const basis = await getVacationYearBasis(supabase, companyId)
|
|
const currentYearStart = getVacationYearStart(asOfDate, basis)
|
|
|
|
const { data: employees, error: empErr } = await supabase
|
|
.from('employees')
|
|
.select('id, vacation_days_per_year, vacation_days_saved, vacation_rule, employment_start')
|
|
.eq('company_id', companyId)
|
|
.in('id', employeeIds)
|
|
if (empErr) return { ok: false, message: empErr.message }
|
|
const employeeById = new Map(
|
|
((employees ?? []) as Array<{
|
|
id: string
|
|
vacation_days_per_year: number
|
|
vacation_days_saved: number
|
|
vacation_rule: string
|
|
employment_start: string
|
|
}>).map((e) => [e.id, e]),
|
|
)
|
|
|
|
const { data: openings, error: openErr } = await supabase
|
|
.from('employee_opening_balances')
|
|
.select('employee_id, cutover_date, vacation_paid_days_remaining, vacation_saved_days_by_year')
|
|
.eq('company_id', companyId)
|
|
.in('employee_id', employeeIds)
|
|
if (openErr) return { ok: false, message: openErr.message }
|
|
const openingByEmployee = new Map(
|
|
((openings ?? []) as Array<{
|
|
employee_id: string
|
|
cutover_date: string
|
|
vacation_paid_days_remaining: number
|
|
vacation_saved_days_by_year: Record<string, number> | null
|
|
}>).map((o) => [o.employee_id, o]),
|
|
)
|
|
|
|
const { data: ledgerRows, error: ledgerErr } = await supabase
|
|
.from('employee_vacation_balances')
|
|
.select('id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days, status')
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'open')
|
|
.in('employee_id', employeeIds)
|
|
if (ledgerErr) return { ok: false, message: ledgerErr.message }
|
|
const openRows = (ledgerRows ?? []) as unknown as VacationBalanceRow[]
|
|
|
|
// Booked vacation days per employee, bucketed later per year bounds.
|
|
const { data: bookedRows, error: bookedErr } = await supabase
|
|
.from('salary_run_employees')
|
|
.select('employee_id, vacation_days_taken, salary_run:salary_runs!inner(period_year, period_month, status)')
|
|
.eq('company_id', companyId)
|
|
.eq('salary_run.status', 'booked')
|
|
.in('employee_id', employeeIds)
|
|
if (bookedErr) return { ok: false, message: bookedErr.message }
|
|
const booked = ((bookedRows ?? []) as unknown as Array<{
|
|
employee_id: string
|
|
vacation_days_taken: number
|
|
salary_run: { period_year: number; period_month: number; status: string } | null
|
|
}>).filter((r) => r.salary_run?.status === 'booked')
|
|
|
|
const takenInYear = (employeeId: string, yearStart: string): number => {
|
|
const bounds = getVacationYearBounds(yearStart)
|
|
let sum = 0
|
|
for (const row of booked) {
|
|
if (row.employee_id !== employeeId) continue
|
|
const run = row.salary_run!
|
|
const periodDate = `${run.period_year}-${String(run.period_month).padStart(2, '0')}-01`
|
|
if (periodDate >= bounds.start && periodDate < bounds.end) {
|
|
sum += row.vacation_days_taken || 0
|
|
}
|
|
}
|
|
return sum
|
|
}
|
|
|
|
const upserts: Array<Record<string, unknown>> = []
|
|
|
|
for (const employeeId of employeeIds) {
|
|
const employee = employeeById.get(employeeId)
|
|
if (!employee) continue
|
|
|
|
const rowsForEmployee = openRows.filter((r) => r.employee_id === employeeId)
|
|
const hasCurrentYearRow = rowsForEmployee.some(
|
|
(r) => r.vacation_year_start === currentYearStart,
|
|
)
|
|
|
|
// Recompute every open year the employee has.
|
|
for (const row of rowsForEmployee) {
|
|
upserts.push({
|
|
company_id: companyId,
|
|
employee_id: employeeId,
|
|
vacation_year_start: row.vacation_year_start,
|
|
entitled_days: row.entitled_days,
|
|
accrued_days: computeAccruedDays(basis, row.vacation_year_start, asOfDate, employee.vacation_days_per_year, employee.employment_start),
|
|
taken_days: takenInYear(employeeId, row.vacation_year_start),
|
|
saved_days: row.saved_days ?? {},
|
|
forced_payout_days: row.forced_payout_days ?? 0,
|
|
status: 'open',
|
|
})
|
|
}
|
|
|
|
// Lazy-seed the current year on first touch.
|
|
if (!hasCurrentYearRow) {
|
|
const opening = openingByEmployee.get(employeeId)
|
|
const cutoverInThisYear =
|
|
!!opening &&
|
|
opening.cutover_date >= currentYearStart &&
|
|
opening.cutover_date < getVacationYearBounds(currentYearStart).end
|
|
|
|
let savedDays: Record<string, number>
|
|
if (cutoverInThisYear && opening) {
|
|
savedDays = opening.vacation_saved_days_by_year ?? {}
|
|
} else if ((employee.vacation_days_saved || 0) > 0) {
|
|
// Legacy master field has no origin-year data: attribute the whole
|
|
// balance to the year before this one (the most conservative choice
|
|
// for the 5-year expiry: it expires EARLIER, never later).
|
|
const previousYear = String(Number(currentYearStart.slice(0, 4)) - 1)
|
|
savedDays = { [previousYear]: employee.vacation_days_saved }
|
|
} else {
|
|
savedDays = {}
|
|
}
|
|
|
|
upserts.push({
|
|
company_id: companyId,
|
|
employee_id: employeeId,
|
|
vacation_year_start: currentYearStart,
|
|
// A cutover opening balance is the migrated truth from the previous
|
|
// system and outranks any recomputation.
|
|
entitled_days:
|
|
cutoverInThisYear && opening
|
|
? opening.vacation_paid_days_remaining
|
|
: computeEntitledDays(
|
|
basis,
|
|
currentYearStart,
|
|
employee.vacation_days_per_year,
|
|
employee.employment_start,
|
|
),
|
|
accrued_days: computeAccruedDays(basis, currentYearStart, asOfDate, employee.vacation_days_per_year, employee.employment_start),
|
|
taken_days: takenInYear(employeeId, currentYearStart),
|
|
saved_days: savedDays,
|
|
forced_payout_days: 0,
|
|
status: 'open',
|
|
})
|
|
}
|
|
}
|
|
|
|
if (upserts.length === 0) return { ok: true }
|
|
|
|
const { error: upsertErr } = await supabase
|
|
.from('employee_vacation_balances')
|
|
.upsert(upserts, { onConflict: 'company_id,employee_id,vacation_year_start' })
|
|
if (upsertErr) return { ok: false, message: upsertErr.message }
|
|
|
|
return { ok: true }
|
|
} catch (err) {
|
|
return { ok: false, message: err instanceof Error ? err.message : 'ledger sync failed' }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Intjänade dagar toward NEXT year: only meaningful on the statutory
|
|
* Apr-Mar basis, where intjänandeår (this year) and semesterår (next year)
|
|
* are split. Sammanfallande calendar years earn and take in the same year,
|
|
* so the live number is entitled - taken and accrued stays 0.
|
|
*
|
|
* Earning starts on the employment date, not on the year boundary: a mid-year
|
|
* hire has not earned the months before their first day, and showing them the
|
|
* full year's accrual overstates what they may take.
|
|
*/
|
|
function computeAccruedDays(
|
|
basis: VacationYearBasis,
|
|
yearStart: string,
|
|
asOfDate: string,
|
|
vacationDaysPerYear: number,
|
|
employmentStart: string,
|
|
): number {
|
|
if (basis !== 'statutory_apr_mar') return 0
|
|
const bounds = getVacationYearBounds(yearStart)
|
|
if (asOfDate < bounds.start) return 0
|
|
// Employed only after this earning year closed: nothing earned in it.
|
|
if (employmentStart >= bounds.end) return 0
|
|
const earningStart = employmentStart > bounds.start ? employmentStart : bounds.start
|
|
const effectiveAsOf = asOfDate >= bounds.end ? bounds.end : asOfDate
|
|
const elapsedMonths = wholeMonthsBetween(earningStart, effectiveAsOf)
|
|
// Whole elapsed months / 12, rounded to half days (Semesterlagen 3a §
|
|
// rounds UP to whole days at payout; the running accrual view keeps halves
|
|
// for transparency).
|
|
return Math.round(((elapsedMonths / 12) * vacationDaysPerYear) * 2) / 2
|
|
}
|
|
|
|
/** Whole calendar months from `from` to `to`, day-of-month ignored (the
|
|
* pre-existing convention of this view). */
|
|
function wholeMonthsBetween(from: string, to: string): number {
|
|
const months =
|
|
(Number(to.slice(0, 4)) - Number(from.slice(0, 4))) * 12 +
|
|
(Number(to.slice(5, 7)) - Number(from.slice(5, 7)))
|
|
return months > 0 ? months : 0
|
|
}
|
|
|
|
/**
|
|
* Betalda semesterdagar for a semesterår, per Semesterlagen 7 §:
|
|
*
|
|
* anställningsdagar under intjänandeåret / dagar under intjänandeåret
|
|
* x semesterdagar, and "om ett brutet tal då uppstår, avrundas detta till
|
|
* närmast högre hela tal" (round UP, always).
|
|
*
|
|
* The intjänandeår is the twelve months immediately preceding the semesterår
|
|
* (3 §), so someone hired part-way through it earns proportionally fewer PAID
|
|
* days while keeping the right to take unpaid ones.
|
|
*
|
|
* Two deliberate omissions, both of which can only overstate entitlement and
|
|
* never understate it, so neither can silently deny an employee a paid day:
|
|
*
|
|
* - 7 § also subtracts days of unpaid full-day absence. The ledger has no
|
|
* unpaid-absence day source, so that term is not modelled.
|
|
* - 4 § second sentence caps semesterLEDIGHET at five days when employment
|
|
* starts after 31 August of the semesterår. That is a cap on days off,
|
|
* paid or unpaid, which is a different quantity from the paid days 7 §
|
|
* computes, so it does not belong in this number.
|
|
*
|
|
* Only applied on the statutory basis. Under sammanfallande semesterår the
|
|
* employee earns and takes in the same year, commonly with förskottssemester,
|
|
* and how a mid-year hire is treated is a collective-agreement question rather
|
|
* than a statutory one. Those companies keep the flat entitlement until that
|
|
* is decided.
|
|
*/
|
|
export function computeEntitledDays(
|
|
basis: VacationYearBasis,
|
|
yearStart: string,
|
|
vacationDaysPerYear: number,
|
|
employmentStart: string,
|
|
): number {
|
|
if (basis !== 'statutory_apr_mar') return vacationDaysPerYear
|
|
// The intjänandeår is the year immediately BEFORE this semesterår.
|
|
const semesterBounds = getVacationYearBounds(yearStart)
|
|
const earningStart = shiftYear(semesterBounds.start, -1)
|
|
const earningEnd = semesterBounds.start
|
|
if (employmentStart >= earningEnd) return 0
|
|
const totalDays = daysBetween(earningStart, earningEnd)
|
|
if (totalDays <= 0) return vacationDaysPerYear
|
|
const employedFrom = employmentStart > earningStart ? employmentStart : earningStart
|
|
const employedDays = daysBetween(employedFrom, earningEnd)
|
|
const quota = (employedDays / totalDays) * vacationDaysPerYear
|
|
return Math.min(vacationDaysPerYear, Math.ceil(quota))
|
|
}
|
|
|
|
function shiftYear(iso: string, delta: number): string {
|
|
return `${Number(iso.slice(0, 4)) + delta}${iso.slice(4)}`
|
|
}
|
|
|
|
function daysBetween(from: string, to: string): number {
|
|
const ms = Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)
|
|
return Math.round(ms / 86_400_000)
|
|
}
|