Files
accounted/lib/bookkeeping/suggest-fiscal-period.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

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

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

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-02 11:51:16 +02:00

154 lines
6.6 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.
*/
function periodName(start: string, end: string): string {
const startYear = Number(start.slice(0, 4))
const endYear = Number(end.slice(0, 4))
return startYear === endYear ? `Räkenskapsår ${startYear}` : `Räkenskapsår ${startYear}/${endYear}`
}
/**
* 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
}