Files
accounted/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts
T
Mattsson 24107338fa Fix/balance inconsitency (#306)
* feat: implement fiscal period date fields component and validation logic

* feat: update fiscal period validation and naming logic

* feat: implement RPC for computing prior opening balances

- Added `compute_prior_opening_balances` RPC to aggregate opening balances for balance-sheet accounts when no opening balance entry is set.
- Updated tests across various reports to utilize the new RPC for fetching prior balances.
- Refactored `getOpeningBalances` to call the RPC when necessary, improving performance and reliability.
- Introduced a script to repair fiscal period chains for companies with broken periods, ensuring proper linking and continuity.
- Enhanced error handling and validation in the repair script to ensure data integrity during the process.

* feat: implement duplicate opening-balance repair for multi-year SIE imports

* feat: enhance SIE entry listing and deduplication logic for opening balances

* fix: refine companyHasPriorActivity logic to exclude storno entries and improve balance counting
2026-04-21 21:39:57 +02:00

348 lines
12 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'),
}))
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[]
openCount?: number
overlapping?: Array<{ id: string; name: string }>
insertResult?: { data: unknown; error: unknown }
}) {
const {
user = { id: 'user-1' },
allPeriods = [],
openCount = 0,
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(() => {
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 openCount query (call 2): .select('id', { count: ... }).eq(...).eq(...)
// 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, opts?: { count?: string }) => {
if (opts?.count === 'exact') {
// openCount query
return {
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({ count: openCount }),
}),
}
}
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()
})
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/)
})
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/)
})
it('rejects forward period when unclosed period exists', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }],
openCount: 1,
})
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(409)
const body = await res.json()
expect(body.error).toMatch(/unclosed period/)
})
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 }],
openCount: 1,
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 period that is neither forward nor backward', 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' })
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/)
})
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(() => {
fpCallIndex++
const callNum = fpCallIndex
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
select: vi.fn().mockImplementation((_sel: string, opts?: any) => {
if (opts?.count === 'exact') {
return { eq: vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ count: 0 }) }) }
}
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(() => {
fpCallIndex++
const callNum = fpCallIndex
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
select: vi.fn().mockImplementation((_sel: string, opts?: any) => {
if (opts?.count === 'exact') {
return { eq: vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ count: 0 }) }) }
}
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()
})
})