Files
accounted/lib/bookkeeping/suggest-fiscal-period.ts
T
b2d3a3e273 feat(settings): rename or re-date an existing räkenskapsår from the fiscal-year list (#2287) (#2337)
* feat(settings): rename or re-date an existing räkenskapsår from the fiscal-year list (#2287)

Inställningar > Bokföring > Räkenskapsår offered Lås, Nollställ and Skapa
nytt räkenskapsår but no way to change the name or dates of a year that
already exists, although PATCH /api/bookkeeping/fiscal-periods/[id] has
supported both (name always on an open year, dates only while the year has
no posted vouchers). The only surface over that route was the first-year
date editor under Företag, so a later or backfilled year saved with the
wrong name or dates (Aisen & Adison AB, #2286) could only be repaired in
the database.

Every open year row now gets a quiet Ändra action opening one dialog
(Namn, Startdatum, Slutdatum) that posts only the changed fields to the
existing route. Dates are read-only with the route's own reason when the
year has posted vouchers (count from the entry-count endpoint); the name
is always editable. Locked and closed years get no Ändra, matching the
route's refusal and the row's chip. Route refusals are shown inline so the
user can correct and retry.

The name follows the dates while it still has the shape fiscalYearName()
produces (new isDerivedFiscalYearName, the inverse predicate next to the
one naming helper): the customer's "Räkenskapsår 2027" corrects itself to
"Räkenskapsår 2022/2023" as the dates are fixed, and a hand-written name is
never overwritten. Saving invalidates ref:fiscal-periods so every picker
updates; the reset dialog's typed confirmation reads the name live from
the reset snapshot, so a rename does not break it.

No route change. New strings in both messages/sv.json and messages/en.json.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

* fix(settings): fiscal-year edit dialog is not dirty on open when the stored name has stray whitespace

"Changed" now compares the raw input with the stored name and sends the
trimmed value only once the user has edited it; before, a stored name with
leading or trailing whitespace enabled Spara on open with a trimmed name
in the payload (CodeRabbit on #2337).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:48:27 +02:00

171 lines
7.4 KiB
TypeScript

/**
* Pure helpers for proposing a new fiscal period (räkenskapsår) in the
* "Skapa räkenskapsår" dialog. Kept framework-free so they can be unit-tested
* in isolation (component code itself is not under test per the project's
* lib/ + app/api/ test scope).
*
* All date math runs in UTC. Local-time `Date` arithmetic combined with
* `toISOString()` shifts dates by the timezone offset (e.g. CET turns
* 2024-12-31 into 2025-12-30), which silently corrupts period boundaries.
*/
import type { FiscalPeriod } from '@/types'
import { addDaysIso as addDays } from '@/lib/dates/iso'
/** Minimal shape needed for the date math: `FiscalPeriod` satisfies it. */
type PeriodRange = Pick<FiscalPeriod, 'period_start' | 'period_end'>
/** A period plus its id, for resolving which räkenskapsår a view scopes to. */
type IdentifiablePeriod = Pick<FiscalPeriod, 'id' | 'period_start' | 'period_end'>
export interface SuggestedPeriod {
name: string
period_start: string
period_end: string
}
/**
* A fiscal-year name: `Räkenskapsår 2025`, or `Räkenskapsår 2024/2025` when it
* straddles two calendar years. Swedish by default to match the app's existing
* fiscal-year naming (Swedish-first); the field stays editable in the dialog.
* Exported so the create dialog can keep the name in step with the dates the
* user actually types: a suggested "Räkenskapsår 2027" must not survive the
* user re-dating the year to 2022-07-01..2023-12-31. Callers pass full
* YYYY-MM-DD strings.
*/
export function fiscalYearName(start: string, end: string): string {
const startYear = start.slice(0, 4)
const endYear = end.slice(0, 4)
return startYear === endYear ? `Räkenskapsår ${startYear}` : `Räkenskapsår ${startYear}/${endYear}`
}
const periodName = fiscalYearName
/**
* True when `name` has the shape fiscalYearName() produces ("Räkenskapsår
* 2025", "Räkenskapsår 2024/2025"), whatever the years say. The edit dialog
* lets such a name follow the dates the user types, so a seed name that never
* matched its dates (the "Räkenskapsår 2027" on a 2022/2023 year of #2286) is
* corrected together with them; a hand-written name is left alone.
*/
export function isDerivedFiscalYearName(name: string): boolean {
return /^Räkenskapsår \d{4}(\/\d{4})?$/.test(name.trim())
}
/**
* Suggest a fiscal period for the create dialog, given the date the user is
* trying to book and the company's existing periods. Three cases:
* - No periods yet → a calendar year around the entry date.
* - Entry date before the earliest period → a year ending the day before it (backfill).
* - Entry date inside an interior gap → a year filling the hole, starting the day
* after the left neighbour and capped so it never overlaps the right neighbour
* (a clean one-year hole yields exactly that year, e.g. 2025 between 2024 and 2026).
* - Otherwise → the next year chaining forward off the latest period.
*/
export function computeSuggestedPeriod(
entryDate: string,
periods: PeriodRange[],
): SuggestedPeriod {
if (periods.length === 0) {
// No periods at all: suggest a calendar year period around the entry date.
const year = entryDate.split('-')[0]
return { name: `Räkenskapsår ${year}`, period_start: `${year}-01-01`, period_end: `${year}-12-31` }
}
const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
const earliest = sorted[0]
const latest = sorted[sorted.length - 1]
if (entryDate < earliest.period_start) {
// Backward: end = day before earliest start, start = 12 months back, 1st of month.
const endStr = addDays(earliest.period_start, -1)
const start = new Date(endStr + 'T00:00:00Z')
start.setUTCMonth(start.getUTCMonth() - 11)
start.setUTCDate(1)
const startStr = start.toISOString().split('T')[0]
return { name: periodName(startStr, endStr), period_start: startStr, period_end: endStr }
}
// Interior gap: the entry date sits between the earliest and latest period but is
// not contained by any existing period.
const containing = sorted.find((p) => p.period_start <= entryDate && entryDate <= p.period_end)
if (!containing && entryDate <= latest.period_end) {
const leftNeighbour = [...sorted].reverse().find((p) => p.period_end < entryDate)!
const rightNeighbour = sorted.find((p) => p.period_start > entryDate)!
const startStr = addDays(leftNeighbour.period_end, 1)
// Tentative end: 12 months after start, last day of the prior month.
const end = new Date(startStr + 'T00:00:00Z')
end.setUTCMonth(end.getUTCMonth() + 12)
end.setUTCDate(0)
let endStr = end.toISOString().split('T')[0]
// Cap at the day before the right neighbour starts so we never overlap it.
const gapEnd = addDays(rightNeighbour.period_start, -1)
if (gapEnd < endStr) endStr = gapEnd
return { name: periodName(startStr, endStr), period_start: startStr, period_end: endStr }
}
// Forward: start = day after latest end, end = 12 months later (last day of month).
const startStr = addDays(latest.period_end, 1)
const end = new Date(startStr + 'T00:00:00Z')
end.setUTCMonth(end.getUTCMonth() + 12)
end.setUTCDate(0)
const endStr = end.toISOString().split('T')[0]
return { name: periodName(startStr, endStr), period_start: startStr, period_end: endStr }
}
/**
* Seed date for the settings "Skapa räkenskapsår" dialog. Prefers the start of the
* earliest gap between consecutive periods so the dialog proposes a missing year
* (e.g. 2025 between 2024 and 2026) instead of jumping to the next forward year.
* Falls back to the day after the latest period ends, or today when there are none.
*/
export function suggestSeedDate(periods: PeriodRange[], today: string): string {
if (periods.length === 0) return today
const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
for (let i = 0; i < sorted.length - 1; i++) {
const gapStart = addDays(sorted[i].period_end, 1)
if (gapStart < sorted[i + 1].period_start) return gapStart
}
return addDays(sorted[sorted.length - 1].period_end, 1)
}
/**
* Resolve which fiscal period a period-scoped view (e.g. the verifikat list)
* should default to: the räkenskapsår the user is currently in.
*
* Verifikationsnummer run as an unbroken series *per räkenskapsår* (BFL 5 kap
* 7§), so the same number (e.g. A42) legitimately recurs once per year. Showing
* every year at once makes those look like duplicates and makes a bare "A42"
* reference ambiguous: a period-oriented view should land on a single year.
*
* Resolution, given `today` (YYYY-MM-DD):
* 1. The period that contains today.
* 2. Else the most recent period that has already started (period_start ≤ today):
* covers a gap after the last year before the next one is created.
* 3. Else the earliest period (a company whose only/first year is still upcoming).
* 4. Else null (no periods at all → caller falls back to "all years").
*/
export function resolveCurrentPeriodId(
periods: IdentifiablePeriod[],
today: string,
): string | null {
if (periods.length === 0) return null
const containing = periods.find((p) => p.period_start <= today && today <= p.period_end)
if (containing) return containing.id
const started = periods
.filter((p) => p.period_start <= today)
.sort((a, b) => b.period_start.localeCompare(a.period_start))
if (started.length > 0) return started[0].id
return [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))[0].id
}