Fix/invoice numbers (#365)
* 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.
This commit is contained in:
@@ -4,6 +4,7 @@ import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -58,19 +59,16 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
// Generate real invoice number
|
||||
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
|
||||
p_company_id: companyId,
|
||||
})
|
||||
|
||||
// Create the real invoice
|
||||
// Create the real invoice with invoice_number=null; assign atomically below.
|
||||
// generate_invoice_number now requires the target row to exist so it can lock
|
||||
// it (FOR UPDATE) and persist the number in the same transaction.
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
customer_id: proforma.customer_id,
|
||||
invoice_number: invoiceNumber,
|
||||
invoice_number: null,
|
||||
invoice_date: new Date().toISOString().split('T')[0],
|
||||
due_date: proforma.due_date,
|
||||
currency: proforma.currency,
|
||||
@@ -99,6 +97,20 @@ export async function POST(
|
||||
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Now that the row exists, allocate the F-series number. Mutates invoice
|
||||
// in place so the response includes the assigned number.
|
||||
try {
|
||||
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
||||
} catch (err) {
|
||||
// Roll back the partially-created invoice so the company counter is the
|
||||
// only side effect to clean up (manually in worst case).
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to assign invoice number' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Copy invoice items
|
||||
const items = (proforma.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }) => ({
|
||||
invoice_id: invoice.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
@@ -52,6 +53,17 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
// Assign invoice number now if this draft doesn't have one yet
|
||||
try {
|
||||
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
||||
} catch (err) {
|
||||
console.error('Failed to assign invoice number on mark-sent:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte tilldela fakturanummer. Försök igen.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update status to sent
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
|
||||
@@ -81,9 +81,10 @@ export async function GET(
|
||||
|
||||
// Return PDF as response
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const filenameNumber = invoice.invoice_number ?? `utkast-${String(invoice.id).slice(0, 8)}`
|
||||
const filename = isCreditNote
|
||||
? `kreditfaktura-${invoice.invoice_number}.pdf`
|
||||
: `faktura-${invoice.invoice_number}.pdf`
|
||||
? `kreditfaktura-${filenameNumber}.pdf`
|
||||
: `faktura-${filenameNumber}.pdf`
|
||||
|
||||
return new NextResponse(uint8Array, {
|
||||
status: 200,
|
||||
|
||||
@@ -240,6 +240,69 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
expect(body.success).toBe(true)
|
||||
})
|
||||
|
||||
it('assigns an invoice number when sending a draft with no number', async () => {
|
||||
const draftWithoutNumber = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'draft',
|
||||
invoice_number: null,
|
||||
customer,
|
||||
items: invoice.items,
|
||||
})
|
||||
|
||||
// Fetch invoice (no number)
|
||||
enqueue({ data: draftWithoutNumber, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: company, error: null })
|
||||
// ensureInvoiceNumber: rpc generate_invoice_number (RPC now persists internally)
|
||||
enqueue({ data: 'F-2026010', error: null })
|
||||
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-99' })
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
// Update status to 'sent'
|
||||
enqueue({ data: null, error: null })
|
||||
// Update with journal_entry_id
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(mockSupabase.rpc).toHaveBeenCalledWith('generate_invoice_number', {
|
||||
p_company_id: 'company-1',
|
||||
p_invoice_id: 'inv-1',
|
||||
p_document_type: 'invoice',
|
||||
})
|
||||
// The journal entry should see the freshly-assigned number
|
||||
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ invoice_number: 'F-2026010' }),
|
||||
'enskild_firma'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not re-assign number when draft already has one (idempotency)', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-100' })
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-2' })
|
||||
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
|
||||
})
|
||||
|
||||
it('returns 500 when email sending fails', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/lib/email/invoice-templates'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||
@@ -84,6 +85,19 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
// Assign invoice number now if this is a draft being sent for the first time.
|
||||
// Mutates `invoice.invoice_number` so the rest of this flow (PDF render,
|
||||
// email subject, journal entry description) sees the new value.
|
||||
try {
|
||||
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
||||
} catch (err) {
|
||||
console.error('Failed to assign invoice number on send:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte tilldela fakturanummer. Försök igen.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Sort items by sort_order
|
||||
const items = (invoice.items as InvoiceItem[]).sort(
|
||||
(a, b) => a.sort_order - b.sort_order
|
||||
|
||||
@@ -187,9 +187,7 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
|
||||
// Fetch customer
|
||||
enqueue({ data: customer, error: null })
|
||||
// RPC generate_invoice_number
|
||||
enqueue({ data: 'F-2024001' })
|
||||
// Insert invoice
|
||||
// Insert invoice (no number generated for drafts — assigned at send time)
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Insert items
|
||||
enqueue({ data: null, error: null })
|
||||
@@ -237,7 +235,6 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
])
|
||||
|
||||
enqueue({ data: customer, error: null })
|
||||
enqueue({ data: 'F-2024001' })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Items insertion fails
|
||||
enqueue({ data: null, error: { message: 'Items insert failed' } })
|
||||
|
||||
@@ -166,20 +166,15 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate document number from the appropriate sequence
|
||||
let invoiceNumber: string
|
||||
// Generate document number — eagerly for delivery notes (separate sequence,
|
||||
// separate UX), lazily for invoices and proformas (assigned at first send so
|
||||
// discarded drafts never consume a number).
|
||||
let invoiceNumber: string | null = null
|
||||
if (documentType === 'delivery_note') {
|
||||
const { data: dnNumber } = await supabase.rpc('generate_delivery_note_number', {
|
||||
p_company_id: companyId,
|
||||
})
|
||||
invoiceNumber = dnNumber
|
||||
} else {
|
||||
const { data: baseNumber } = await supabase.rpc('generate_invoice_number', {
|
||||
p_company_id: companyId,
|
||||
})
|
||||
invoiceNumber = documentType === 'proforma'
|
||||
? `PF-${baseNumber}`
|
||||
: baseNumber
|
||||
}
|
||||
|
||||
// Create invoice
|
||||
|
||||
Reference in New Issue
Block a user