Bug/year end numbers (#744)

* fix(bookkeeping): allow creating a fiscal year that fills an interior gap

Fiscal-period creation only allowed chaining a new räkenskapsår before the
earliest or after the latest existing period, so a company with a gap between
years (e.g. 2024 + 2026 from an SIE import, missing 2025) could not create the
missing year — it failed with "New period must chain before the earliest or
after the latest existing period".

Generalise forward chaining onto the new period's immediate predecessor, which
covers both appending a new latest year and filling an interior gap. The
"prior year must be locked" guard now applies only to true appends, not gap
fills (a backfill, like backward chaining). previous_period_id is set to the
predecessor and the successor is relinked so the BFNAR 2013:2 continuity chain
stays intact. The create dialog suggests the missing year (capped so it never
overlaps the next period), the settings page seeds the dialog at the earliest
gap, and the default suggested name is now "Räkenskapsår <year>".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): omföra föregående års resultat (2099 → 2098) at year-end

Year-end closing posts the result to 2099 "Årets resultat" and the opening
balance carried it forward on 2099 every year, so 2099 accumulated across
years and the prior result never moved off "Årets resultat".

executeYearEndClosing now posts a separate "Omföring av föregående års
resultat" verifikat (Dr 2099 / Cr 2098 for a profit, reversed for a loss)
into the new period after the continuity check passes, so 2099 starts each
year at zero. Kept as a standalone entry rather than folded into the opening
balance so the IB stays a faithful mirror of the prior UB and IB/UB
continuity still holds. Aktiebolag only; idempotent; no-op when 2099 is flat.
The 2098 → 2091/2898 disposition (bolagsstämma decision) is intentionally
left to a separate step.

- new source_type 'result_appropriation' (migration + type + Zod enum)
- generateResultAppropriation helper (planner + poster) wired as step 11
- ResultStep surfaces the omföring voucher
- unit tests + pg-real invariant
- scripts/repair-result-appropriation.ts: retroactive catch-up (dry-run default)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(transactions): shadow-detect date-drift duplicate bank transactions

The content-dedup bridge buckets on exact (date, ore), so the same
transaction re-imported with a booking date that drifted a day lands in
a different bucket and slips past every dedup layer. Add a measure-only
("shadow") detector that flags would-be +/-1-day duplicates and counts
them, without changing what is inserted - so the gap can be validated on
real data before any enforcement, mirroring the scope-drift shadow.

- shiftIsoDate(): pure, deterministic adjacent-date helper
- ingest: DEDUP_DATE_DRIFT_MODE flag (default on), pre-loop bucket
  snapshot, per-row gate with desc-bridge + cross-channel-symmetry
  signals; logs shadow_date_drift_candidates, never alters inserts
- fail-safe date guard so the measurement can never abort an import
- regression tests for both signals, account/window/distinct guards,
  no-double-count, and the malformed-date fail-safe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(bookkeeping): anonymize a customer reference in fiscal-period tests

Remove a real customer name ("AXMD AB") from regression-test comments;
no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(workflows): enhance Docker image scanning and caching mechanisms

* fix(bookkeeping): enhance year-end result appropriation handling and error reporting

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-16 18:22:09 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 5c40fa9aeb
commit 2a8bf9b42e
27 changed files with 1860 additions and 174 deletions
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest'
import { computeSuggestedPeriod, suggestSeedDate } from '../suggest-fiscal-period'
type Range = { period_start: string; period_end: string }
const FY2024: Range = { period_start: '2024-01-01', period_end: '2024-12-31' }
const FY2026: Range = { period_start: '2026-01-01', period_end: '2026-12-31' }
describe('computeSuggestedPeriod', () => {
it('suggests a calendar year around the entry date when there are no periods', () => {
expect(computeSuggestedPeriod('2025-06-15', [])).toEqual({
name: 'Räkenskapsår 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
})
it('suggests a backfill year before the earliest period', () => {
expect(computeSuggestedPeriod('2025-06-15', [FY2026])).toEqual({
name: 'Räkenskapsår 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
})
it('suggests the missing year when the entry date is in an interior gap', () => {
// FY 2024 + FY 2026 exist, 2025 is the hole (interior-gap scenario).
expect(computeSuggestedPeriod('2025-06-15', [FY2024, FY2026])).toEqual({
name: 'Räkenskapsår 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
})
it('caps the gap suggestion so it never overlaps the right neighbour', () => {
// A six-month hole (Jan–Jun 2025) before a short FY 2025 H2 period.
const fy2025h2: Range = { period_start: '2025-07-01', period_end: '2025-12-31' }
expect(computeSuggestedPeriod('2025-03-15', [FY2024, fy2025h2])).toEqual({
name: 'Räkenskapsår 2025',
period_start: '2025-01-01',
period_end: '2025-06-30',
})
})
it('suggests the next forward year after the latest period', () => {
expect(computeSuggestedPeriod('2025-06-15', [FY2024])).toEqual({
name: 'Räkenskapsår 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
})
})
describe('suggestSeedDate', () => {
const today = '2026-06-16'
it('returns today when there are no periods', () => {
expect(suggestSeedDate([], today)).toBe('2026-06-16')
})
it('returns the start of the earliest gap when one exists', () => {
expect(suggestSeedDate([FY2024, FY2026], today)).toBe('2025-01-01')
})
it('returns the day after the latest period when there is no gap', () => {
const fy2025: Range = { period_start: '2025-01-01', period_end: '2025-12-31' }
expect(suggestSeedDate([FY2024, fy2025], today)).toBe('2026-01-01')
})
it('returns the day after the only period for a single period', () => {
expect(suggestSeedDate([FY2024], today)).toBe('2025-01-01')
})
})
+123
View File
@@ -0,0 +1,123 @@
/**
* 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'
/** Minimal shape needed for the date math — `FiscalPeriod` satisfies it. */
type PeriodRange = Pick<FiscalPeriod, 'period_start' | 'period_end'>
export interface SuggestedPeriod {
name: string
period_start: string
period_end: string
}
/** Add `days` to a YYYY-MM-DD date string, returning a YYYY-MM-DD string. */
function addDays(date: string, days: number): string {
const d = new Date(date + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().split('T')[0]
}
/**
* 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)
}