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
@@ -284,18 +284,77 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('rejects period that is neither forward nor backward', async () => {
|
||||
// Regression (2026-06-16): a company with FY 2024 + FY 2026 but no
|
||||
// FY 2025 could not create the missing year — the old code only allowed
|
||||
// chaining before the earliest or after the latest period. A period that
|
||||
// exactly fills an interior gap must be allowed.
|
||||
it('allows filling a gap between two existing periods', async () => {
|
||||
buildMockSupabase({
|
||||
allPeriods: [
|
||||
{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: true },
|
||||
{ id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false },
|
||||
],
|
||||
overlapping: [],
|
||||
})
|
||||
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data).toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects a gap-fill period that is not adjacent to the preceding period', async () => {
|
||||
buildMockSupabase({
|
||||
allPeriods: [
|
||||
{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: true },
|
||||
{ id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false },
|
||||
],
|
||||
})
|
||||
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
|
||||
// Starts 2025-02-01 instead of 2025-01-01 — would leave a hole after FY 2024.
|
||||
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-02-01', period_end: '2025-12-31' })
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error).toMatch(/must chain before the earliest or after the latest/)
|
||||
expect(body.error).toMatch(/must start on 2025-01-01/)
|
||||
})
|
||||
|
||||
// Regression: the start check only constrains the predecessor side. A gap-fill
|
||||
// period that starts correctly (day after FY 2024) but ends BEFORE the day
|
||||
// before FY 2026 would leave a fresh sub-gap while still relinking FY 2026's
|
||||
// previous_period_id onto it — a broken continuity chain. The end-adjacency
|
||||
// guard must reject it.
|
||||
it('rejects a gap-fill period that does not end the day before the successor', async () => {
|
||||
buildMockSupabase({
|
||||
allPeriods: [
|
||||
{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: true },
|
||||
{ id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false },
|
||||
],
|
||||
})
|
||||
// Correct start (2025-01-01) but ends 2025-11-30 — leaves a hole before FY 2026.
|
||||
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-11-30' })
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error).toMatch(/must end on 2025-12-31/)
|
||||
})
|
||||
|
||||
// A gap fill is a backfill (like backward chaining), so the "prior year must be
|
||||
// locked" guard must NOT apply — otherwise the two open neighbours would block it.
|
||||
it('allows a gap fill even when both neighbouring years are open', async () => {
|
||||
buildMockSupabase({
|
||||
allPeriods: [
|
||||
{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false },
|
||||
{ id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false },
|
||||
],
|
||||
openPeriods: [
|
||||
{ id: 'p1', name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' },
|
||||
{ id: 'p2', name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' },
|
||||
],
|
||||
overlapping: [],
|
||||
})
|
||||
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('rejects invalid period duration (> 18 months)', async () => {
|
||||
@@ -461,4 +520,80 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
|
||||
expect(insertSpy).toHaveBeenCalledTimes(1)
|
||||
expect(insertSpy.mock.calls[0][0].previous_period_id).toBeNull()
|
||||
})
|
||||
|
||||
it('gap fill chains to the predecessor and relinks the successor', async () => {
|
||||
const insertSpy = vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
single: vi.fn().mockResolvedValue({ data: { id: 'new-2025', name: 'FY 2025' }, error: null }),
|
||||
}),
|
||||
})
|
||||
const updatedIds: string[] = []
|
||||
const updatePayloads: unknown[] = []
|
||||
const updateSpy = vi.fn().mockImplementation((payload: unknown) => {
|
||||
updatePayloads.push(payload)
|
||||
return {
|
||||
eq: vi.fn().mockImplementation((col: string, val: string) => {
|
||||
if (col === 'id') updatedIds.push(val)
|
||||
return { eq: vi.fn().mockResolvedValue({ error: null }) }
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
let fpCallIndex = 0
|
||||
const supabase = {
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }) },
|
||||
from: vi.fn().mockImplementation((table: string) => {
|
||||
if (table === 'company_settings') {
|
||||
return {
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: { bookkeeping_locked_through: null }, error: null }),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
fpCallIndex++
|
||||
const callNum = fpCallIndex
|
||||
return {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
if (callNum === 1) {
|
||||
// allPeriods: FY 2024 + FY 2026 with a hole at 2025
|
||||
return {
|
||||
eq: vi.fn().mockReturnValue({
|
||||
order: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'p-2024', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false },
|
||||
{ id: 'p-2026', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false },
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
// overlap query
|
||||
return {
|
||||
eq: vi.fn().mockReturnValue({
|
||||
lte: vi.fn().mockReturnValue({
|
||||
gte: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue({ data: [], error: null }) }),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}),
|
||||
insert: insertSpy,
|
||||
update: updateSpy,
|
||||
}
|
||||
}),
|
||||
}
|
||||
;(createClient as ReturnType<typeof vi.fn>).mockResolvedValue(supabase)
|
||||
|
||||
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
|
||||
const res = await POST(req)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
// New period chains onto FY 2024 (the predecessor).
|
||||
expect(insertSpy.mock.calls[0][0].previous_period_id).toBe('p-2024')
|
||||
// FY 2026 (the successor) is relinked to follow the new period.
|
||||
expect(updatePayloads[0]).toEqual({ previous_period_id: 'new-2025' })
|
||||
expect(updatedIds[0]).toBe('p-2026')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,23 +65,26 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: durationError }, { status: 400 })
|
||||
}
|
||||
|
||||
if (allPeriods && allPeriods.length > 0) {
|
||||
const earliest = allPeriods[0]
|
||||
const latest = allPeriods[allPeriods.length - 1]
|
||||
// Identify the new period's immediate neighbours. Fiscal periods never overlap
|
||||
// (the no_overlapping_fiscal_periods DB exclusion constraint), so ordering by
|
||||
// period_start is also ordering by period_end.
|
||||
// predecessor = closest existing period ending before the new one starts
|
||||
// successor = closest existing period starting after the new one ends
|
||||
const sortedPeriods = allPeriods ?? []
|
||||
const predecessor = [...sortedPeriods].reverse().find((p) => p.period_end < body.period_start) ?? null
|
||||
const successor = sortedPeriods.find((p) => p.period_start > body.period_end) ?? null
|
||||
|
||||
const isBackward = body.period_end < earliest.period_start
|
||||
const isForward = body.period_start > latest.period_end
|
||||
if (sortedPeriods.length > 0) {
|
||||
const earliest = sortedPeriods[0]
|
||||
const latest = sortedPeriods[sortedPeriods.length - 1]
|
||||
|
||||
if (!isBackward && !isForward) {
|
||||
// Neither backward nor forward — must overlap or be in the middle
|
||||
return NextResponse.json(
|
||||
{ error: 'New period must chain before the earliest or after the latest existing period' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const isPrepend = body.period_end < earliest.period_start
|
||||
const isAppend = body.period_start > latest.period_end
|
||||
|
||||
if (isBackward) {
|
||||
// Backward chaining: new period_end must be day before earliest period_start
|
||||
if (isPrepend) {
|
||||
// Prepend before the earliest period: new period_end must be the day before
|
||||
// the earliest period starts. Skip the "no open prior period" constraint —
|
||||
// backfilling an earlier year needs that year to stay open.
|
||||
const expectedEnd = new Date(earliest.period_start + 'T12:00:00Z')
|
||||
expectedEnd.setUTCDate(expectedEnd.getUTCDate() - 1)
|
||||
const expectedEndStr = expectedEnd.toISOString().split('T')[0]
|
||||
@@ -91,58 +94,91 @@ export async function POST(request: Request) {
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
// Skip "no unclosed period" constraint for backward chaining (backfill needs the period open)
|
||||
} else {
|
||||
// Forward chaining: keep existing constraints (contiguity + no unclosed periods)
|
||||
const prev = new Date(latest.period_end + 'T12:00:00Z')
|
||||
prev.setUTCDate(prev.getUTCDate() + 1)
|
||||
const expectedStart = prev.toISOString().split('T')[0]
|
||||
if (body.period_start !== expectedStart) {
|
||||
return NextResponse.json(
|
||||
{ error: `Period must start on ${expectedStart} (day after latest period ends)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
// Forward-like: either append a new latest year OR fill an interior gap
|
||||
// between two existing years. Both must chain onto their immediate
|
||||
// predecessor — i.e. start the day after it ends. When appending, the
|
||||
// predecessor IS the latest period (original forward-chaining behaviour);
|
||||
// when filling a gap, it's the year just before the hole.
|
||||
if (predecessor) {
|
||||
const next = new Date(predecessor.period_end + 'T12:00:00Z')
|
||||
next.setUTCDate(next.getUTCDate() + 1)
|
||||
const expectedStart = next.toISOString().split('T')[0]
|
||||
if (body.period_start !== expectedStart) {
|
||||
return NextResponse.json(
|
||||
{ error: `Period must start on ${expectedStart} (day after the preceding period ends)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
// No predecessor here means the new period reaches back over the earliest
|
||||
// existing period (an overlap) — the overlap check below returns 409.
|
||||
|
||||
// Gap fill: the new period must also butt up against its SUCCESSOR — end
|
||||
// exactly the day before the successor starts — so it fills the hole
|
||||
// completely. The predecessor check above only constrains the start side.
|
||||
// Without this end check a too-short period would leave a fresh sub-gap
|
||||
// yet still get the successor's previous_period_id relinked onto it
|
||||
// (below), silently breaking the BFNAR 2013:2 continuity chain; a too-long
|
||||
// period that bleeds past the successor is separately caught as an overlap
|
||||
// (409). Appends have no successor, so this is skipped.
|
||||
if (successor) {
|
||||
const prevDay = new Date(successor.period_start + 'T12:00:00Z')
|
||||
prevDay.setUTCDate(prevDay.getUTCDate() - 1)
|
||||
const expectedEnd = prevDay.toISOString().split('T')[0]
|
||||
if (body.period_end !== expectedEnd) {
|
||||
return NextResponse.json(
|
||||
{ error: `Period must end on ${expectedEnd} (day before the following period starts)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce: max one editable prior period (no skipping ahead) — forward only.
|
||||
// A period is "effectively locked" if EITHER its own locked_at is set, OR
|
||||
// The "prior year must be locked" guard applies only when appending a new
|
||||
// latest räkenskapsår, not when backfilling a gap between existing years.
|
||||
// A gap fill is a backfill (like prepend) and must not be blocked by an
|
||||
// open neighbouring year.
|
||||
//
|
||||
// A period counts as "effectively locked" if its own locked_at is set, OR
|
||||
// company_settings.bookkeeping_locked_through covers its end date (the
|
||||
// enforce_company_lock_date trigger blocks any entry on/before that date).
|
||||
// BFL 6 kap allows löpande bokföring of the new year in parallel with
|
||||
// bokslut work on the prior year, so locked-but-not-closed prior periods
|
||||
// must not block creating the next räkenskapsår.
|
||||
const { data: openPeriods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_closed', false)
|
||||
.is('locked_at', null)
|
||||
.order('period_start', { ascending: true })
|
||||
if (isAppend) {
|
||||
const { data: openPeriods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_closed', false)
|
||||
.is('locked_at', null)
|
||||
.order('period_start', { ascending: true })
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bookkeeping_locked_through')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bookkeeping_locked_through')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
const lockThrough = settings?.bookkeeping_locked_through ?? null
|
||||
const trulyOpen = (openPeriods ?? []).filter(
|
||||
(p) => !(lockThrough && p.period_end <= lockThrough)
|
||||
)
|
||||
const lockThrough = settings?.bookkeeping_locked_through ?? null
|
||||
const trulyOpen = (openPeriods ?? []).filter(
|
||||
(p) => !(lockThrough && p.period_end <= lockThrough)
|
||||
)
|
||||
|
||||
if (trulyOpen.length > 0) {
|
||||
// Hand the blocking periods (id + name + dates) to the client so the
|
||||
// "Skapa räkenskapsår" dialog can offer to lock them inline and retry,
|
||||
// instead of dead-ending the user on a message they can't act on.
|
||||
const blockingPeriods = trulyOpen.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
period_start: p.period_start,
|
||||
period_end: p.period_end,
|
||||
}))
|
||||
return errorResponseFromCode('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS', log, {
|
||||
details: { blockingPeriods },
|
||||
})
|
||||
if (trulyOpen.length > 0) {
|
||||
// Hand the blocking periods (id + name + dates) to the client so the
|
||||
// "Skapa räkenskapsår" dialog can offer to lock them inline and retry,
|
||||
// instead of dead-ending the user on a message they can't act on.
|
||||
const blockingPeriods = trulyOpen.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
period_start: p.period_start,
|
||||
period_end: p.period_end,
|
||||
}))
|
||||
return errorResponseFromCode('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS', log, {
|
||||
details: { blockingPeriods },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,18 +199,11 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve previous_period_id for forward chaining so the new period is
|
||||
// linked to the period it follows. Without this, balance-sheet/trial-balance
|
||||
// reports fall back to scanning every prior journal line (BFNAR 2013:2
|
||||
// continuity chain is broken). Backward chaining sets previous_period_id
|
||||
// on the old earliest period instead (see below), not on the new one.
|
||||
let previousPeriodId: string | null = null
|
||||
if (allPeriods && allPeriods.length > 0) {
|
||||
const latest = allPeriods[allPeriods.length - 1]
|
||||
if (body.period_start > latest.period_end) {
|
||||
previousPeriodId = latest.id
|
||||
}
|
||||
}
|
||||
// Chain the new period onto its predecessor (append or gap fill) so reports
|
||||
// can walk the BFNAR 2013:2 continuity chain instead of scanning every prior
|
||||
// journal line. Prepend leaves this null and instead relinks the old earliest
|
||||
// period to follow the new one (below).
|
||||
const previousPeriodId = predecessor ? predecessor.id : null
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
@@ -193,14 +222,19 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// For backward chaining: update the old earliest period's previous_period_id
|
||||
if (allPeriods && allPeriods.length > 0) {
|
||||
const earliest = allPeriods[0]
|
||||
if (body.period_end < earliest.period_start) {
|
||||
// Keep the continuity chain intact for the period that now follows the new one:
|
||||
// - Prepend: the old earliest period follows the new (earlier) period.
|
||||
// - Gap fill: the successor period follows the new period.
|
||||
// (Append has no successor, so nothing to relink.)
|
||||
if (sortedPeriods.length > 0) {
|
||||
const earliest = sortedPeriods[0]
|
||||
const isPrepend = body.period_end < earliest.period_start
|
||||
const periodToRelink = isPrepend ? earliest : successor
|
||||
if (periodToRelink) {
|
||||
await supabase
|
||||
.from('fiscal_periods')
|
||||
.update({ previous_period_id: data.id })
|
||||
.eq('id', earliest.id)
|
||||
.eq('id', periodToRelink.id)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user