* 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>
720 lines
29 KiB
TypeScript
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')
|
|
})
|
|
})
|