* 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.
203 lines
7.2 KiB
TypeScript
203 lines
7.2 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
||
import { NextResponse } from 'next/server'
|
||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||
import { validateBody } from '@/lib/api/validate'
|
||
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
|
||
import { requireCompanyId } from '@/lib/company/context'
|
||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||
|
||
export async function GET() {
|
||
const supabase = await createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
}
|
||
|
||
const companyId = await requireCompanyId(supabase, user.id)
|
||
|
||
const { data, error } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('*')
|
||
.eq('company_id', companyId)
|
||
.order('period_start', { ascending: false })
|
||
|
||
if (error) {
|
||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||
}
|
||
|
||
return NextResponse.json({ data })
|
||
}
|
||
|
||
export async function POST(request: Request) {
|
||
const supabase = await createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
}
|
||
|
||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||
if (!writeCheck.ok) return writeCheck.response
|
||
|
||
const companyId = await requireCompanyId(supabase, user.id)
|
||
|
||
const validation = await validateBody(request, CreateFiscalPeriodSchema)
|
||
if (!validation.success) return validation.response
|
||
const body = validation.data
|
||
|
||
// Fetch all existing periods to determine direction
|
||
const { data: allPeriods } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('id, period_start, period_end, is_closed')
|
||
.eq('company_id', companyId)
|
||
.order('period_start', { ascending: true })
|
||
|
||
const isFirstPeriod = !allPeriods || allPeriods.length === 0
|
||
|
||
// Validate period duration (max 18 months per BFL 3 kap.)
|
||
const durationError = validatePeriodDuration(body.period_start, body.period_end, { isFirstPeriod })
|
||
if (durationError) {
|
||
return NextResponse.json({ error: durationError }, { status: 400 })
|
||
}
|
||
|
||
if (allPeriods && allPeriods.length > 0) {
|
||
const earliest = allPeriods[0]
|
||
const latest = allPeriods[allPeriods.length - 1]
|
||
|
||
const isBackward = body.period_end < earliest.period_start
|
||
const isForward = body.period_start > latest.period_end
|
||
|
||
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 }
|
||
)
|
||
}
|
||
|
||
if (isBackward) {
|
||
// Backward chaining: new period_end must be day before earliest period_start
|
||
const expectedEnd = new Date(earliest.period_start + 'T12:00:00Z')
|
||
expectedEnd.setUTCDate(expectedEnd.getUTCDate() - 1)
|
||
const expectedEndStr = expectedEnd.toISOString().split('T')[0]
|
||
if (body.period_end !== expectedEndStr) {
|
||
return NextResponse.json(
|
||
{ error: `Period must end on ${expectedEndStr} (day before earliest period starts)` },
|
||
{ 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 }
|
||
)
|
||
}
|
||
|
||
// 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
|
||
// 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('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 lockThrough = settings?.bookkeeping_locked_through ?? null
|
||
const trulyOpen = (openPeriods ?? []).filter(
|
||
(p) => !(lockThrough && p.period_end <= lockThrough)
|
||
)
|
||
|
||
if (trulyOpen.length > 0) {
|
||
const names = trulyOpen
|
||
.map((p) => `${p.name} (${p.period_start} – ${p.period_end})`)
|
||
.join(', ')
|
||
return NextResponse.json(
|
||
{
|
||
error: `Cannot create a new period while an unlocked period exists. Lock the following first: ${names}`,
|
||
},
|
||
{ status: 409 }
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Defense-in-depth: check for overlapping periods
|
||
const { data: overlapping } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('id, name')
|
||
.eq('company_id', companyId)
|
||
.lte('period_start', body.period_end)
|
||
.gte('period_end', body.period_start)
|
||
.limit(1)
|
||
|
||
if (overlapping && overlapping.length > 0) {
|
||
return NextResponse.json(
|
||
{ error: `Overlaps with existing period: ${overlapping[0].name}` },
|
||
{ status: 409 }
|
||
)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
|
||
const { data, error } = await supabase
|
||
.from('fiscal_periods')
|
||
.insert({
|
||
user_id: user.id,
|
||
company_id: companyId,
|
||
name: body.name,
|
||
period_start: body.period_start,
|
||
period_end: body.period_end,
|
||
previous_period_id: previousPeriodId,
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (error) {
|
||
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) {
|
||
await supabase
|
||
.from('fiscal_periods')
|
||
.update({ previous_period_id: data.id })
|
||
.eq('id', earliest.id)
|
||
.eq('company_id', companyId)
|
||
}
|
||
}
|
||
|
||
return NextResponse.json({ data })
|
||
}
|