Files
accounted/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts
T
a48508e5b0 feat(dimensions): show the value's name after picking, and let an unused custom dimension be deleted (#2219) (#2255)
* feat(dimensions): show the value's name after picking, and let an unused custom dimension be deleted (#2219)

Two things from the same Discord report, both in bookkeeping from the
transaction view:

1. After picking a kostnadsställe the field showed only the code ("1").
   DimensionCombobox now writes the value's full name under the field once
   a code is committed, exactly as AccountCombobox does for the account
   name (looked up in the full registry so an archived code stays
   readable). The input text itself stays the code: the blur/revert logic
   keys on it.

2. A self-created dimension could not be removed at all: the DB already
   allowed it (enforce_dimension_registry_guards lets a non-system
   dimension go when no posted/reversed line carries its number, and the
   value retention trigger fires on the cascade), but no route or UI
   asked. New DELETE /api/dimensions/[id]: 400 DIMENSION_SYSTEM_DELETE for
   kostnadsställe/projekt, the guard's own Swedish P0001 verbatim as 409
   DIMENSION_REFERENCED, 404, and a happy path; the register gets a quiet
   "Ta bort dimension" link for the active custom dimension behind a
   DestructiveConfirmDialog. Keys added to sv and en.

Closes #2219

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

* test: satisfy the TypeScript ratchet for two test files main inherited from #2247 and #2242

accounts-route.test.ts built SyncResult literals without the
requestedFromDate / historyNarrowed fields #2247 added (vitest does not
typecheck, so it passed locally); fiscal-periods route.test.ts got two
more one-argument POST(req) calls from #2242 in a file already at its
ratchet baseline. Both files now typecheck; the ratchet runs clean.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:20:09 +02:00

720 lines
29 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock dependencies
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { createClient } from '@/lib/supabase/server'
import { POST } from '../route'
function createMockRequest(body: unknown): Request {
return new Request('http://localhost/api/bookkeeping/fiscal-periods', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
type Period = { id: string; period_start: string; period_end: string; is_closed: boolean; name?: string }
/**
* Build a mock supabase that tracks sequential from('fiscal_periods') calls
* and returns the correct data based on the select() arguments.
*/
function buildMockSupabase(options: {
user?: { id: string } | null
allPeriods?: Period[]
openPeriods?: Array<{ id?: string; name: string; period_start: string; period_end: string }>
bookkeepingLockedThrough?: string | null
overlapping?: Array<{ id: string; name: string }>
insertResult?: { data: unknown; error: unknown }
}) {
const {
user = { id: 'user-1' },
allPeriods = [],
openPeriods = [],
bookkeepingLockedThrough = null,
overlapping = [],
insertResult = { data: { id: 'new-period', name: 'FY 2025' }, error: null },
} = options
// Track from() calls to fiscal_periods
let fpCallIndex = 0
const supabase = {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user } }),
},
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: bookkeepingLockedThrough },
error: null,
}),
}),
}),
}
}
fpCallIndex++
const callNum = fpCallIndex
// Build a chainable object that resolves differently based on the call chain
const chainable: Record<string, unknown> = {}
// For the allPeriods query (call 1): .select('id, period_start, ...').eq(...).order(...)
// For openPeriods query (call 2): .select('name, period_start, period_end').eq(...).eq(...).is(...).order(...)
// For overlap query (call 3): .select('id, name').eq(...).lte(...).gte(...).limit(...)
// For insert (call 4): .insert(...).select().single()
// For update (call 5): .update(...).eq(...).eq(...)
chainable.select = vi.fn().mockImplementation((sel: string) => {
if (sel.includes('name') && sel.includes('period_start')) {
// openPeriods query: .eq(company_id).eq(is_closed=false).is(locked_at, null).order(...)
return {
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
is: vi.fn().mockReturnValue({
order: vi.fn().mockResolvedValue({ data: openPeriods, error: null }),
}),
}),
}),
}
}
if (callNum === 1) {
// allPeriods query
return {
eq: vi.fn().mockReturnValue({
order: vi.fn().mockResolvedValue({ data: allPeriods, error: null }),
}),
}
}
// overlap query or any other select
return {
eq: vi.fn().mockReturnValue({
lte: vi.fn().mockReturnValue({
gte: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue({ data: overlapping, error: null }),
}),
}),
order: vi.fn().mockResolvedValue({ data: allPeriods, error: null }),
}),
}
})
chainable.insert = vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue(insertResult),
}),
})
chainable.update = vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({ error: null }),
}),
})
return chainable
}),
}
;(createClient as ReturnType<typeof vi.fn>).mockResolvedValue(supabase)
return supabase
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('POST /api/bookkeeping/fiscal-periods', () => {
it('returns 401 when not authenticated', async () => {
buildMockSupabase({ user: null })
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(401)
})
it('creates first period successfully', async () => {
buildMockSupabase({ allPeriods: [] })
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()
// Nothing precedes the first period, so there is nothing to advise about.
expect(body.warnings).toBeUndefined()
})
it('rejects overlapping periods', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: true }],
overlapping: [{ id: 'p1', name: 'FY 2024' }],
})
// Forward chain from 2024, start = 2025-01-01
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(409)
const body = await res.json()
expect(body.error).toMatch(/Overlaps/)
})
// Dropping the "prior year must be locked" gate must not open an overlap
// hole: a period colliding with a still-open prior year is still a 409.
it('rejects an overlapping period even when the prior year is still open', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }],
openPeriods: [{ id: 'p1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
overlapping: [{ id: 'p1', name: 'FY 2025' }],
})
// Starts inside FY 2025.
const req = createMockRequest({ name: 'FY 2025 dup', period_start: '2025-07-01', period_end: '2026-06-30' })
const res = await POST(req)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error).toMatch(/Overlaps/)
})
it('rejects forward period with wrong start date', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: true }],
})
const req = createMockRequest({ name: 'FY 2026', period_start: '2026-02-01', period_end: '2026-12-31' })
const res = await POST(req)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/must start on 2026-01-01/)
})
// Regression (BFL 5 kap 2 §): this used to be a 409. One unbooked December
// bank transaction makes FY 2025 unlockable (lockPeriod refuses, correctly),
// and the old guard then refused to create FY 2026, so ALL bookkeeping in the
// new year stopped, while BFL 5 kap 2 § requires the new year's
// affärshändelser to be booked "så snart det kan ske" (senast månaden efter)
// and BFL 6 kap gives the bokslut 6 months. Both bind at once: the new
// räkenskapsår must be creatable with the prior one still fully open.
it('creates the new räkenskapsår while the prior year is still fully open', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }],
openPeriods: [{ id: 'p1', name: 'Räkenskapsår 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
overlapping: [],
})
const req = createMockRequest({
name: 'Räkenskapsår 2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
})
const res = await POST(req)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toBeDefined()
// The open prior year survives only as a non-blocking advisory.
expect(body.warnings).toHaveLength(1)
expect(body.warnings[0].code).toBe('PRIOR_FISCAL_YEAR_STILL_OPEN')
expect(body.warnings[0].message).toContain('Räkenskapsår 2025')
})
// The advisory must be actionable and must not dead-end: it states that the
// new year is bookable now and that the IB arrives with the bokslut, rather
// than demanding a lock the user may be unable to perform.
it('advisory tells the user booking may continue and that IB follows the bokslut', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }],
openPeriods: [{ id: 'p1', name: 'Räkenskapsår 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
overlapping: [],
})
const req = createMockRequest({
name: 'Räkenskapsår 2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
})
const res = await POST(req)
const body = await res.json()
expect(body.warnings[0].message).toMatch(/bokföra i det direkt/)
expect(body.warnings[0].message).toMatch(/Ingående balanser bokförs automatiskt/)
// No wording that orders the user to lock the prior year first.
expect(body.warnings[0].message).not.toMatch(/måste låsa/)
})
// Every still-open prior year is named, not just the immediate predecessor:
// the user needs to know the full set whose UB is still provisional.
it('names every still-open prior räkenskapsår in the single advisory', async () => {
buildMockSupabase({
allPeriods: [
{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false },
{ id: 'p2', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false },
],
openPeriods: [
{ id: 'p1', name: 'Räkenskapsår 2024', period_start: '2024-01-01', period_end: '2024-12-31' },
{ id: 'p2', name: 'Räkenskapsår 2025', period_start: '2025-01-01', period_end: '2025-12-31' },
],
overlapping: [],
})
const req = createMockRequest({
name: 'Räkenskapsår 2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
})
const res = await POST(req)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.warnings).toHaveLength(1)
expect(body.warnings[0].message).toContain('Räkenskapsår 2024')
expect(body.warnings[0].message).toContain('Räkenskapsår 2025')
})
// Regression: BFL 6 kap allows löpande bokföring of the new year in parallel
// with bokslut work on the prior year (6-month deadline for årsbokslut, 7
// months for AB årsredovisning). A locked-but-not-yet-closed prior period is
// the normal state during that window and must not block creation of the
// next räkenskapsår. The .is('locked_at', null) filter excludes locked
// periods from openPeriods, so the mock returns [] here.
it('allows forward period creation when prior period is locked-but-not-closed', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }],
openPeriods: [],
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()
})
// Regression: a real user (Egon Johansson, 2026-04-27) set the company-wide
// bookkeeping_locked_through to 2024-12-31 but never set locked_at on the
// FY 2024 period. From their perspective and from the enforce_company_lock_date
// trigger's perspective, the period is locked. The creation check must agree.
it('allows forward period creation when prior period is covered by company-wide lock-through', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }],
openPeriods: [{ name: 'Räkenskapsår 2024', period_start: '2024-01-01', period_end: '2024-12-31' }],
bookkeepingLockedThrough: '2024-12-31',
overlapping: [],
})
const req = createMockRequest({ name: 'Räkenskapsår 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()
// Effectively locked: nothing to advise about.
expect(body.warnings).toBeUndefined()
})
// Partial coverage: lock-through covers only part of the period, so the prior
// year still counts as open. Creation proceeds either way; only the advisory
// distinguishes the two states.
it('advises but does not block when company-wide lock only partially covers prior period', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }],
openPeriods: [{ id: 'p1', name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' }],
bookkeepingLockedThrough: '2024-06-30',
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.warnings).toHaveLength(1)
expect(body.warnings[0].code).toBe('PRIOR_FISCAL_YEAR_STILL_OPEN')
expect(body.warnings[0].message).toContain('FY 2024')
})
it('allows backward period creation', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', 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)
})
it('rejects backward period with wrong end date', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', 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-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/)
})
it('backward chaining skips unclosed period constraint', async () => {
// There's an unclosed period (2026), but backward creation should still work
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false }],
openPeriods: [{ 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)
// The advisory is about the NEXT year's ingående balanser, so it belongs to
// appends only. Backfilling an earlier year says nothing about IB.
const body = await res.json()
expect(body.warnings).toBeUndefined()
})
// Regression (issue #2237): a company that imported 2024+ from Fortnox and
// then backfilled its FIRST räkenskapsår by hand was refused because the
// first year started mid-month (2022-07-22, the registration date). The
// 1st-of-month rule (BFL 3 kap. 1 §) binds subsequent years only; "first"
// means no existing period starts earlier, exactly as the DB trigger
// enforce_first_of_month_for_subsequent_periods defines it.
it('allows a mid-month start when the new period becomes the earliest (first räkenskapsår backfilled after an import)', async () => {
buildMockSupabase({
allPeriods: [
{ id: 'p2024', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false },
{ id: 'p2025', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false },
],
overlapping: [],
})
const req = createMockRequest({ name: '2022/2023', period_start: '2022-07-22', period_end: '2023-12-31' })
const res = await POST(req, { params: Promise.resolve({}) })
expect(res.status).toBe(200)
})
it('still rejects a mid-month start for a period that is not the earliest', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p2024', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }],
})
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-15', period_end: '2025-12-31' })
const res = await POST(req, { params: Promise.resolve({}) })
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/1st of a month/)
// The refusal explains the rule instead of only saying no.
expect(body.error).toMatch(/first fiscal year may start mid-month/)
})
// 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 },
],
})
// 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 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)
// Gap fill is a backfill too: no IB advisory.
const body = await res.json()
expect(body.warnings).toBeUndefined()
})
it('rejects invalid period duration (> 18 months)', async () => {
buildMockSupabase({ allPeriods: [] })
const req = createMockRequest({ name: 'Long period', period_start: '2025-01-01', period_end: '2026-12-31' })
const res = await POST(req)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/18 months/)
})
it('rejects invalid body', async () => {
buildMockSupabase({})
const req = createMockRequest({ name: '', period_start: 'bad', period_end: '2025-12-31' })
const res = await POST(req)
expect(res.status).toBe(400)
})
it('sets previous_period_id when chaining forward', async () => {
// Build a mock that captures the insert payload.
const insertSpy = vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { id: 'new-period', name: 'FY 2026' },
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((sel: string) => {
if (sel.includes('name') && sel.includes('period_start')) {
return {
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
is: vi.fn().mockReturnValue({
order: vi.fn().mockResolvedValue({ data: [], error: null }),
}),
}),
}),
}
}
if (callNum === 1) {
return {
eq: vi.fn().mockReturnValue({
order: vi.fn().mockResolvedValue({
data: [{ id: 'prior-period-id', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: true, closing_entry_id: null }],
error: null,
}),
}),
}
}
return {
eq: vi.fn().mockReturnValue({
lte: vi.fn().mockReturnValue({
gte: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue({ data: [], error: null }) }),
}),
}),
}
}),
insert: insertSpy,
update: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ error: null }) }) }),
}
}),
}
;(createClient as ReturnType<typeof vi.fn>).mockResolvedValue(supabase)
const req = createMockRequest({ name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' })
const res = await POST(req)
expect(res.status).toBe(200)
expect(insertSpy).toHaveBeenCalledTimes(1)
const insertArg = insertSpy.mock.calls[0][0]
expect(insertArg.previous_period_id).toBe('prior-period-id')
expect(insertArg.period_start).toBe('2026-01-01')
})
it('does not set previous_period_id for the first period', async () => {
const insertSpy = vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { id: 'new-period', name: 'FY 2025' },
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((sel: string) => {
if (sel.includes('name') && sel.includes('period_start')) {
return {
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
is: vi.fn().mockReturnValue({
order: vi.fn().mockResolvedValue({ data: [], error: null }),
}),
}),
}),
}
}
if (callNum === 1) {
return {
eq: vi.fn().mockReturnValue({
order: vi.fn().mockResolvedValue({ data: [], error: null }),
}),
}
}
return {
eq: vi.fn().mockReturnValue({
lte: vi.fn().mockReturnValue({
gte: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue({ data: [], error: null }) }),
}),
}),
}
}),
insert: insertSpy,
update: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ error: null }) }) }),
}
}),
}
;(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)
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')
})
})