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:
co-authored by
Claude Opus 4.8
parent
5c40fa9aeb
commit
2a8bf9b42e
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user