* feat: make invoice_number nullable and assign on send - Updated the invoices table to allow invoice_number to be nullable. - Modified the logic to assign invoice numbers only when the invoice status transitions to 'sent'. - Refactored related code to handle nullable invoice numbers, including UI components and API routes. - Added tests to ensure correct behavior when handling invoices with null invoice numbers. - Introduced a utility function to display invoice numbers, defaulting to '(Utkast)' for drafts. * fix: update fiscal period handling to return names of open periods in error messages * fix: enhance period creation logic to account for company-wide bookkeeping lock-through * fix: remove unnecessary customer_type field from customer insertion query * fix: scope invoice number count query to specific companies to avoid test interference * feat: Implement atomic invoice number generation and ensure compliance with invoice numbering rules - Introduced `ensureInvoiceNumber` function to assign invoice numbers atomically, handling concurrency and ensuring compliance with document types. - Updated invoice-related components to utilize the new `invoiceNumberDisplay` utility for consistent invoice number formatting. - Added checks to ensure that invoices in non-draft statuses have valid invoice numbers, preventing violations of legal requirements. - Created tests for the new invoice number generation logic, ensuring correct behavior under various scenarios, including concurrent requests. - Added a draft banner to PDF templates for invoices without assigned numbers, clarifying their status to users. - Updated database migrations to support the new atomic invoice number generation logic and enforce constraints on invoice statuses.
457 lines
17 KiB
TypeScript
457 lines
17 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[]
|
||
openPeriods?: Array<{ 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') && !sel.includes('id')) {
|
||
// 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()
|
||
})
|
||
|
||
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 an unlocked open period exists and lists its name', async () => {
|
||
buildMockSupabase({
|
||
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }],
|
||
openPeriods: [{ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
|
||
})
|
||
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(/unlocked period/)
|
||
expect(body.error).toMatch(/FY 2025 \(2025-01-01 – 2025-12-31\)/)
|
||
})
|
||
|
||
// 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()
|
||
})
|
||
|
||
// Partial coverage: lock-through covers only part of the period — must still block.
|
||
it('rejects forward period creation 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: [{ 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(409)
|
||
const body = await res.json()
|
||
expect(body.error).toMatch(/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)
|
||
})
|
||
|
||
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((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') && !sel.includes('id')) {
|
||
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') && !sel.includes('id')) {
|
||
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()
|
||
})
|
||
})
|