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:
Mattsson
2026-04-27 16:29:58 +02:00
committed by GitHub
parent ec5fd78e3e
commit fd1db89603
33 changed files with 931 additions and 131 deletions
+4 -2
View File
@@ -26,6 +26,8 @@ import {
Lock,
} from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { cn } from '@/lib/utils'
import { invoiceNumberDisplay } from '@/lib/invoices/display'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
const customerTypeLabels: Record<CustomerType, string> = {
@@ -44,7 +46,7 @@ const customerTypeIcons: Record<CustomerType, React.ElementType> = {
interface RelatedInvoice {
id: string
invoice_number: string
invoice_number: string | null
invoice_date: string
due_date: string
status: string
@@ -349,7 +351,7 @@ export default function CustomerDetailPage({
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div>
<p className="font-medium">{invoice.invoice_number}</p>
<p className={cn('font-medium', !invoice.invoice_number && 'italic text-muted-foreground')}>{invoiceNumberDisplay(invoice.invoice_number)}</p>
<p className="text-sm text-muted-foreground">{invoice.invoice_date}</p>
</div>
<div className="flex items-center gap-3">
@@ -311,7 +311,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={invoice.invoice_number}
placeholder={invoice.invoice_number ?? ''}
disabled={!invoice.invoice_number}
className={cn(
confirmText && confirmText !== invoice.invoice_number && 'border-destructive'
)}
@@ -327,7 +328,12 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
<Button
variant="destructive"
onClick={handleSubmit}
disabled={isSubmitting || confirmText !== invoice.invoice_number || !canWrite}
disabled={
isSubmitting ||
!invoice.invoice_number ||
confirmText !== invoice.invoice_number ||
!canWrite
}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{isSubmitting ? (
+12 -7
View File
@@ -9,8 +9,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
import { invoiceNumberDisplay } from '@/lib/invoices/display'
import {
Loader2,
ArrowLeft,
@@ -278,7 +279,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `faktura-${invoice.invoice_number}.pdf`
a.download = `faktura-${invoice.invoice_number ?? `utkast-${invoice.id.slice(0, 8)}`}.pdf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
@@ -286,7 +287,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
toast({
title: 'PDF nedladdad',
description: `Faktura ${invoice.invoice_number} har laddats ner`,
description: invoice.invoice_number
? `Faktura ${invoice.invoice_number} har laddats ner`
: 'Utkastet har laddats ner',
})
} catch (error) {
toast({
@@ -316,7 +319,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
toast({
title: 'Faktura borttagen',
description: `Utkast ${invoice.invoice_number} har tagits bort`,
description: invoice.invoice_number
? `Utkast ${invoice.invoice_number} har tagits bort`
: 'Utkastet har tagits bort',
})
router.push('/invoices')
@@ -361,7 +366,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Button>
<div>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<h1 className="font-display text-2xl sm:text-3xl font-medium tracking-tight">{invoice.invoice_number}</h1>
<h1 className={cn('font-display text-2xl sm:text-3xl font-medium tracking-tight', !invoice.invoice_number && 'italic text-muted-foreground')}>{invoiceNumberDisplay(invoice.invoice_number)}</h1>
{isProforma && (
<Badge variant="secondary" className="bg-primary/10 text-primary">Proforma</Badge>
)}
@@ -618,7 +623,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<CardContent className="space-y-4">
<div className="flex justify-between">
<span className="text-muted-foreground">Fakturanummer</span>
<span className="font-medium">{invoice.invoice_number}</span>
<span className={cn('font-medium', !invoice.invoice_number && 'italic text-muted-foreground')}>{invoiceNumberDisplay(invoice.invoice_number)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Fakturadatum</span>
@@ -948,7 +953,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<DialogHeader>
<DialogTitle>Ta bort fakturautkast</DialogTitle>
<DialogDescription>
Är du säker att du vill ta bort utkast {invoice.invoice_number}? Detta kan inte ångras.
Är du säker att du vill ta bort {invoice.invoice_number ? `utkast ${invoice.invoice_number}` : 'utkastet'}? Detta kan inte ångras.
</DialogDescription>
</DialogHeader>
<DialogFooter>
+3 -2
View File
@@ -13,6 +13,7 @@ import { PageHeader } from '@/components/ui/page-header'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { invoiceNumberDisplay } from '@/lib/invoices/display'
import { Plus, Search, Receipt, Lock } from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state'
import { useCompany } from '@/contexts/CompanyContext'
@@ -87,7 +88,7 @@ export default function InvoicesPage() {
const filteredInvoices = invoices.filter((invoice) => {
const matchesSearch =
invoice.invoice_number.toLowerCase().includes(searchTerm.toLowerCase()) ||
(invoice.invoice_number ?? '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(invoice.customer as { name: string })?.name?.toLowerCase().includes(searchTerm.toLowerCase())
const isCreditNote = !!invoice.credited_invoice_id
@@ -282,7 +283,7 @@ export default function InvoicesPage() {
<CardContent className="py-4">
<div className="min-w-0">
<div className="flex items-start sm:items-center justify-between gap-2">
<p className="font-medium truncate">{invoice.invoice_number}</p>
<p className={cn('font-medium truncate', !invoice.invoice_number && 'italic text-muted-foreground')}>{invoiceNumberDisplay(invoice.invoice_number)}</p>
<p className={`font-medium tabular-nums shrink-0 ${isCreditNote ? 'text-destructive' : ''}`}>
{formatCurrency(Number(invoice.total), invoice.currency)}
</p>
@@ -31,14 +31,16 @@ type Period = { id: string; period_start: string; period_end: string; is_closed:
function buildMockSupabase(options: {
user?: { id: string } | null
allPeriods?: Period[]
openCount?: number
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 = [],
openCount = 0,
openPeriods = [],
bookkeepingLockedThrough = null,
overlapping = [],
insertResult = { data: { id: 'new-period', name: 'FY 2025' }, error: null },
} = options
@@ -50,7 +52,19 @@ function buildMockSupabase(options: {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user } }),
},
from: vi.fn().mockImplementation(() => {
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
@@ -58,18 +72,20 @@ function buildMockSupabase(options: {
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 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, opts?: { count?: string }) => {
if (opts?.count === 'exact') {
// openCount query: .eq(company_id).eq(is_closed=false).is(locked_at, null)
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().mockResolvedValue({ count: openCount }),
is: vi.fn().mockReturnValue({
order: vi.fn().mockResolvedValue({ data: openPeriods, error: null }),
}),
}),
}),
}
@@ -162,16 +178,17 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
expect(body.error).toMatch(/must start on 2026-01-01/)
})
it('rejects forward period when an unlocked open period exists', async () => {
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 }],
openCount: 1,
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
@@ -179,11 +196,11 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
// 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 the openCount, so the mock returns 0 here.
// 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 }],
openCount: 0,
openPeriods: [],
overlapping: [],
})
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
@@ -193,6 +210,39 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
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 }],
@@ -218,7 +268,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
// 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,
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' })
@@ -270,17 +320,30 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
let fpCallIndex = 0
const supabase = {
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }) },
from: vi.fn().mockImplementation(() => {
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 {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
select: vi.fn().mockImplementation((_sel: string, opts?: any) => {
if (opts?.count === 'exact') {
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().mockResolvedValue({ count: 0 }),
is: vi.fn().mockReturnValue({
order: vi.fn().mockResolvedValue({ data: [], error: null }),
}),
}),
}),
}
@@ -333,17 +396,30 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
let fpCallIndex = 0
const supabase = {
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }) },
from: vi.fn().mockImplementation(() => {
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 {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
select: vi.fn().mockImplementation((_sel: string, opts?: any) => {
if (opts?.count === 'exact') {
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().mockResolvedValue({ count: 0 }),
is: vi.fn().mockReturnValue({
order: vi.fn().mockResolvedValue({ data: [], error: null }),
}),
}),
}),
}
+27 -7
View File
@@ -101,19 +101,39 @@ export async function POST(request: Request) {
}
// Enforce: max one editable prior period (no skipping ahead) — forward only.
// Locked periods are write-blocked by enforce_period_lock and so don't
// represent skipping ahead; they're the normal state during bokslut work,
// which BFL 6 kap allows in parallel with löpande bokföring of the new year.
const { count: openCount } = await supabase
// 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('id', { count: 'exact', head: true })
.select('name, period_start, period_end')
.eq('company_id', companyId)
.eq('is_closed', false)
.is('locked_at', null)
.order('period_start', { ascending: true })
if (openCount && openCount > 0) {
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' },
{
error: `Cannot create a new period while an unlocked period exists. Lock the following first: ${names}`,
},
{ status: 409 }
)
}
+19 -7
View File
@@ -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,
+12
View File
@@ -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')
+3 -2
View File
@@ -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 })
+14
View File
@@ -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
+1 -4
View File
@@ -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' } })
+4 -9
View File
@@ -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
@@ -217,20 +217,20 @@ describe('POST /api/pending-operations/:id/commit', () => {
enqueueMany([
{ data: pendingOp }, // fetch pending op
{ data: customer }, // fetch customer
{ data: '20260001' }, // generate invoice number (rpc)
{ data: { id: 'inv-1' } }, // insert invoice
{ data: { id: 'inv-1', invoice_number: null } }, // insert invoice (no number — assigned at send)
{ data: null, error: null }, // insert items
{ data: { id: 'inv-1', customer: customer, items: [] } }, // fetch complete invoice
{ data: { id: 'inv-1', invoice_number: null, customer: customer, items: [] } }, // fetch complete invoice
{ data: null, error: null }, // update pending op status
])
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
const response = await POST(request, routeParams)
const { status, body } = await parseJsonResponse<{ data: { invoice_id: string; invoice_number: string } }>(response)
const { status, body } = await parseJsonResponse<{ data: { invoice_id: string; invoice_number: string | null } }>(response)
expect(status).toBe(200)
expect(body.data.invoice_id).toBe('inv-1')
expect(body.data.invoice_number).toBe('20260001')
// Drafts no longer reserve a number — assigned at send time instead
expect(body.data.invoice_number).toBeNull()
})
it('returns 404 when customer not found', async () => {
@@ -30,6 +30,7 @@ import {
import { uploadDocument } from '@/lib/core/documents/document-service'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { createLogger } from '@/lib/logger'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import type {
@@ -391,10 +392,8 @@ async function commitCreateInvoice(
const uniqueRates = new Set(items.map((item) => item.vat_rate ?? vatRules.rate))
const isMixedRate = uniqueRates.size > 1
// Generate invoice number
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
p_company_id: companyId,
})
// Invoice number is assigned later when the draft is sent — leave null here
// so a discarded draft never consumes a number.
// Create invoice
const { data: invoice, error: invoiceError } = await supabase
@@ -403,7 +402,7 @@ async function commitCreateInvoice(
user_id: userId,
company_id: companyId,
customer_id: customerId,
invoice_number: invoiceNumber,
invoice_number: null,
invoice_date: (params.invoice_date as string) || new Date().toISOString().split('T')[0],
due_date: (params.due_date as string) || null,
currency,
@@ -472,7 +471,7 @@ async function commitCreateInvoice(
})
}
return { data: { invoice_id: invoice.id, invoice_number: invoiceNumber } }
return { data: { invoice_id: invoice.id, invoice_number: invoice.invoice_number } }
}
async function commitMarkInvoicePaid(
@@ -570,6 +569,14 @@ async function commitSendInvoice(
if (companyError || !company) return { error: 'Company settings missing', status: 500 }
// Assign invoice number now if this draft doesn't have one yet —
// mutates `invoice.invoice_number` so PDF, email, JE all see it.
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
return { error: `Failed to assign invoice number: ${err instanceof Error ? err.message : 'unknown'}`, status: 500 }
}
const items = (invoice.items as InvoiceItem[]).sort(
(a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order
)
@@ -682,6 +689,12 @@ async function commitMarkInvoiceSent(
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
if (invoice.status !== 'draft') return { error: 'Only draft invoices can be marked as sent', status: 409 }
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
return { error: `Failed to assign invoice number: ${err instanceof Error ? err.message : 'unknown'}`, status: 500 }
}
const { error: updateError } = await supabase
.from('invoices')
.update({ status: 'sent' })
+14 -12
View File
@@ -41,34 +41,36 @@ function computeSuggestedPeriod(entryDate: string, periods: FiscalPeriod[]) {
if (entryDate < earliest.period_start) {
// Backward: end = day before earliest start, start = 12 months back, 1st of month
const end = new Date(earliest.period_start + 'T00:00:00')
end.setDate(end.getDate() - 1)
// Use UTC throughout — local-time Date math + toISOString() shifts dates by
// the timezone offset (e.g. CET produces 2024-12-31 → 2025-12-30).
const end = new Date(earliest.period_start + 'T00:00:00Z')
end.setUTCDate(end.getUTCDate() - 1)
const start = new Date(end)
start.setMonth(start.getMonth() - 11)
start.setDate(1)
start.setUTCMonth(start.getUTCMonth() - 11)
start.setUTCDate(1)
const startStr = start.toISOString().split('T')[0]
const endStr = end.toISOString().split('T')[0]
const startYear = start.getFullYear()
const endYear = end.getFullYear()
const startYear = start.getUTCFullYear()
const endYear = end.getUTCFullYear()
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
return { name, period_start: startStr, period_end: endStr }
}
// Forward: start = day after latest end, end = 12 months later (last day of month)
const start = new Date(latest.period_end + 'T00:00:00')
start.setDate(start.getDate() + 1)
const start = new Date(latest.period_end + 'T00:00:00Z')
start.setUTCDate(start.getUTCDate() + 1)
const end = new Date(start)
end.setMonth(end.getMonth() + 12)
end.setDate(0) // Last day of previous month
end.setUTCMonth(end.getUTCMonth() + 12)
end.setUTCDate(0) // Last day of previous month
const startStr = start.toISOString().split('T')[0]
const endStr = end.toISOString().split('T')[0]
const startYear = start.getFullYear()
const endYear = end.getFullYear()
const startYear = start.getUTCFullYear()
const endYear = end.getUTCFullYear()
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
return { name, period_start: startStr, period_end: endStr }
+1 -1
View File
@@ -212,7 +212,7 @@ export default function PaymentBookingDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[680px]">
<DialogHeader>
<DialogTitle>Bokför betalning {invoice.invoice_number}</DialogTitle>
<DialogTitle>Bokför betalning{invoice.invoice_number ? `${invoice.invoice_number}` : ''}</DialogTitle>
<DialogDescription>
{formatCurrency(invoice.total, invoice.currency)}
{invoice.currency !== 'SEK' && invoice.total_sek && (
+2 -2
View File
@@ -188,7 +188,7 @@ export default function SendInvoiceDialog({
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{mode === 'email' ? 'Skicka faktura' : 'Markera som skickad'} {invoice.invoice_number}
{mode === 'email' ? 'Skicka faktura' : 'Markera som skickad'}{invoice.invoice_number ? `${invoice.invoice_number}` : ''}
</DialogTitle>
<DialogDescription>
{formatCurrency(invoice.total, invoice.currency)}
@@ -215,7 +215,7 @@ export default function SendInvoiceDialog({
<JournalEntryReviewContent
periodName={periodName}
entryDate={invoice.invoice_date}
description={`Försäljning faktura ${invoice.invoice_number}${invoice.customer.name ? `, ${invoice.customer.name}` : ''}`}
description={`Försäljning faktura${invoice.invoice_number ? ` ${invoice.invoice_number}` : ''}${invoice.customer.name ? `, ${invoice.customer.name}` : ''}`}
lines={proposedLines}
totalDebit={totalDebit}
totalCredit={totalCredit}
@@ -20,6 +20,7 @@ import {
createReceiptExtractedPayload,
createReceiptMatchedPayload,
} from './payload-builders'
import { invoiceNumberDisplay } from '@/lib/invoices/display'
// ============================================================
// Settings
@@ -166,7 +167,7 @@ async function handleInvoiceSent(
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const notificationPayload = createInvoiceSentPayload(
invoice.invoice_number,
invoiceNumberDisplay(invoice.invoice_number),
invoice.id
)
+1 -1
View File
@@ -133,7 +133,7 @@ export async function previewCurrencyRevaluation(
items.push({
type: 'receivable',
source_id: inv.id,
reference: inv.invoice_number,
reference: inv.invoice_number ?? '',
currency: inv.currency,
amount_in_currency: amountInCurrency,
original_rate: inv.exchange_rate,
+45 -25
View File
@@ -16,16 +16,32 @@ import type {
const log = createLogger('invoice-entries')
/**
* Build the invoice identifier used in line_description. Prefers the assigned
* invoice number; falls back to a draft tag with the first 8 chars of the
* invoice UUID so the verifikation still identifies *vad affärshändelsen avser*
* per BFL 5 kap 6§ p.3 even if a journal entry is somehow created against an
* unnumbered invoice. The send path always assigns a number first, so this
* fallback is defensive — but it leaves no ambiguity if a future caller skips
* ensureInvoiceNumber.
*/
function invoiceTag(invoice: Pick<Invoice, 'id' | 'invoice_number'>): string {
return invoice.invoice_number ?? `utkast ${invoice.id.slice(0, 8)}`
}
/**
* Build a BFL-compliant verifikation description with event type and counterparty.
* Falls back to prefix + invoiceNumber if name is not provided (backward compat).
*/
function buildInvoiceDescription(
prefix: string, invoiceNumber: string, counterpartyName?: string
prefix: string, invoiceNumber: string | null, counterpartyName?: string,
invoiceId?: string,
): string {
const tag = invoiceNumber ?? (invoiceId ? `utkast ${invoiceId.slice(0, 8)}` : null)
const tagPart = tag ? ` ${tag}` : ''
return counterpartyName
? `${prefix} ${invoiceNumber}, ${counterpartyName}`
: `${prefix} ${invoiceNumber}`
? `${prefix}${tagPart}, ${counterpartyName}`
: `${prefix}${tagPart}`
}
/**
@@ -36,7 +52,7 @@ function generatePerRateLines(
items: InvoiceItem[],
invoiceVatTreatment: VatTreatment,
entityType: EntityType,
invoiceNumber: string,
invoiceTagText: string,
currency?: string | null,
exchangeRate?: number | null
): CreateJournalEntryLineInput[] {
@@ -64,7 +80,7 @@ function generatePerRateLines(
account_number: revenueAccount,
debit_amount: 0,
credit_amount: subtotalSek,
line_description: `Försäljning faktura ${invoiceNumber}`,
line_description: `Försäljning faktura ${invoiceTagText}`,
})
const totalVat = items.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
@@ -77,7 +93,7 @@ function generatePerRateLines(
account_number: vatAccount,
debit_amount: 0,
credit_amount: vatSek,
line_description: `Utgående moms`,
line_description: `Utgående moms faktura ${invoiceTagText}`,
})
} else {
const vatLines = generateSalesVatLines({
@@ -113,7 +129,7 @@ function generatePerRateLines(
account_number: revenueAccount,
debit_amount: 0,
credit_amount: roundedSubtotal,
line_description: `Försäljning faktura ${invoiceNumber}`,
line_description: `Försäljning faktura ${invoiceTagText}`,
})
const roundedVat = Math.round(toSek(group.vatAmount) * 100) / 100
@@ -123,7 +139,7 @@ function generatePerRateLines(
account_number: vatAccount,
debit_amount: 0,
credit_amount: roundedVat,
line_description: `Utgående moms ${rate}%`,
line_description: `Utgående moms ${rate}% faktura ${invoiceTagText}`,
})
}
}
@@ -166,13 +182,14 @@ export async function createInvoiceJournalEntry(
const lines: CreateJournalEntryLineInput[] = []
const isForeign = invoice.currency !== 'SEK'
const tag = invoiceTag(invoice)
// Credit lines: revenue + VAT per rate group (compute first to guarantee balance)
const creditLines: CreateJournalEntryLineInput[] = []
if (invoice.items && invoice.items.length > 0) {
creditLines.push(...generatePerRateLines(
invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number,
invoice.items, invoice.vat_treatment, entityType, tag,
invoice.currency, invoice.exchange_rate
))
} else {
@@ -184,7 +201,7 @@ export async function createInvoiceJournalEntry(
account_number: revenueAccount,
debit_amount: 0,
credit_amount: subtotalSek,
line_description: `Försäljning faktura ${invoice.invoice_number}`,
line_description: `Försäljning faktura ${tag}`,
})
if (invoice.vat_amount > 0) {
@@ -195,7 +212,7 @@ export async function createInvoiceJournalEntry(
account_number: vatAccount,
debit_amount: 0,
credit_amount: vatSek,
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
line_description: `Utgående moms faktura ${tag}`,
})
} else {
const vatLines = generateSalesVatLines({
@@ -218,7 +235,7 @@ export async function createInvoiceJournalEntry(
account_number: '1510',
debit_amount: debitAmount,
credit_amount: 0,
line_description: `Faktura ${invoice.invoice_number}`,
line_description: `Faktura ${tag}`,
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
})
@@ -227,7 +244,7 @@ export async function createInvoiceJournalEntry(
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: invoice.invoice_date,
description: buildInvoiceDescription('Kundfaktura', invoice.invoice_number, customerName),
description: buildInvoiceDescription('Kundfaktura', invoice.invoice_number, customerName, invoice.id),
source_type: 'invoice_created',
source_id: invoice.id,
lines,
@@ -262,7 +279,8 @@ export async function createInvoicePaymentJournalEntry(
const desc = buildInvoiceDescription(
isPartial ? 'Delbetalning kundfaktura' : 'Inbetalning kundfaktura',
invoice.invoice_number,
customerName
customerName,
invoice.id,
)
// When paymentAmount is provided, use it for the 1930/1510 line amounts.
@@ -365,6 +383,7 @@ export async function createCreditNoteJournalEntry(
}
const lines: CreateJournalEntryLineInput[] = []
const tag = invoiceTag(creditNote)
// Generate reversed revenue + VAT lines per rate group (debit side for credit notes)
const debitLines: CreateJournalEntryLineInput[] = []
@@ -372,7 +391,7 @@ export async function createCreditNoteJournalEntry(
if (creditNote.items && creditNote.items.length > 0) {
// Use absolute items for generatePerRateLines, then swap debit/credit
const creditLines = generatePerRateLines(
creditNote.items, creditNote.vat_treatment, entityType, creditNote.invoice_number,
creditNote.items, creditNote.vat_treatment, entityType, tag,
creditNote.currency, creditNote.exchange_rate
)
for (const line of creditLines) {
@@ -380,7 +399,7 @@ export async function createCreditNoteJournalEntry(
...line,
debit_amount: Math.abs(line.credit_amount),
credit_amount: Math.abs(line.debit_amount),
line_description: `Kreditfaktura ${creditNote.invoice_number}`,
line_description: `Kreditfaktura ${tag}`,
})
}
} else {
@@ -393,7 +412,7 @@ export async function createCreditNoteJournalEntry(
account_number: revenueAccount,
debit_amount: absSubtotal,
credit_amount: 0,
line_description: `Kreditfaktura ${creditNote.invoice_number}`,
line_description: `Kreditfaktura ${tag}`,
})
if (absVat > 0) {
@@ -402,7 +421,7 @@ export async function createCreditNoteJournalEntry(
account_number: vatAccount,
debit_amount: absVat,
credit_amount: 0,
line_description: `Moms kreditfaktura ${creditNote.invoice_number}`,
line_description: `Moms kreditfaktura ${tag}`,
})
}
}
@@ -415,13 +434,13 @@ export async function createCreditNoteJournalEntry(
account_number: '1510',
debit_amount: 0,
credit_amount: Math.round(totalDebits * 100) / 100,
line_description: `Kreditfaktura ${creditNote.invoice_number}`,
line_description: `Kreditfaktura ${tag}`,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: creditNote.invoice_date,
description: buildInvoiceDescription('Kreditfaktura', creditNote.invoice_number, customerName),
description: buildInvoiceDescription('Kreditfaktura', creditNote.invoice_number, customerName, creditNote.id),
source_type: 'credit_note',
source_id: creditNote.id,
lines,
@@ -455,13 +474,14 @@ export async function createInvoiceCashEntry(
const lines: CreateJournalEntryLineInput[] = []
const isForeign = invoice.currency !== 'SEK'
const tag = invoiceTag(invoice)
// Credit lines: revenue + VAT per rate group (compute first to guarantee balance)
const creditLines: CreateJournalEntryLineInput[] = []
if (invoice.items && invoice.items.length > 0) {
creditLines.push(...generatePerRateLines(
invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number,
invoice.items, invoice.vat_treatment, entityType, tag,
invoice.currency, invoice.exchange_rate
))
} else {
@@ -473,7 +493,7 @@ export async function createInvoiceCashEntry(
account_number: revenueAccount,
debit_amount: 0,
credit_amount: subtotalSek,
line_description: `Försäljning faktura ${invoice.invoice_number}`,
line_description: `Försäljning faktura ${tag}`,
})
if (invoice.vat_amount > 0) {
@@ -483,7 +503,7 @@ export async function createInvoiceCashEntry(
account_number: vatAccount,
debit_amount: 0,
credit_amount: vatSek,
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
line_description: `Utgående moms faktura ${tag}`,
})
}
}
@@ -494,7 +514,7 @@ export async function createInvoiceCashEntry(
account_number: '1930',
debit_amount: isForeign ? Math.round(totalCredits * 100) / 100 : resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate),
credit_amount: 0,
line_description: buildInvoiceDescription('Kontantbetalning kundfaktura', invoice.invoice_number, customerName),
line_description: buildInvoiceDescription('Kontantbetalning kundfaktura', invoice.invoice_number, customerName, invoice.id),
})
lines.push(...creditLines)
@@ -502,7 +522,7 @@ export async function createInvoiceCashEntry(
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: buildInvoiceDescription('Kontantbetalning kundfaktura', invoice.invoice_number, customerName),
description: buildInvoiceDescription('Kontantbetalning kundfaktura', invoice.invoice_number, customerName, invoice.id),
source_type: 'invoice_cash_payment',
source_id: invoice.id,
lines,
+6 -6
View File
@@ -12,7 +12,7 @@ import type { EntityType, InvoiceItem, VatTreatment } from '@/types'
export interface ProposePaymentLinesInput {
invoice: {
invoice_number: string
invoice_number: string | null
total: number
total_sek?: number | null
subtotal: number
@@ -44,7 +44,7 @@ function toFormAmount(n: number): string {
export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[] {
const { invoice, accountingMethod, entityType, exchangeRateDifference } = input
const paymentAccount = input.paymentAccount || '1930'
const desc = `Betalning faktura ${invoice.invoice_number}`
const desc = invoice.invoice_number ? `Betalning faktura ${invoice.invoice_number}` : 'Betalning faktura'
if (accountingMethod === 'accrual') {
return proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference)
@@ -148,7 +148,7 @@ function proposeCashLines(
account_number: revenueAccount,
debit_amount: '',
credit_amount: toFormAmount(toSek(subtotal)),
line_description: `Försäljning faktura ${invoice.invoice_number}`,
line_description: (invoice.invoice_number ? `Försäljning faktura ${invoice.invoice_number}` : 'Försäljning faktura'),
})
const totalVat = invoice.items.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
@@ -182,7 +182,7 @@ function proposeCashLines(
account_number: revenueAccount,
debit_amount: '',
credit_amount: toFormAmount(Math.round(toSek(group.subtotal) * 100) / 100),
line_description: `Försäljning faktura ${invoice.invoice_number}`,
line_description: (invoice.invoice_number ? `Försäljning faktura ${invoice.invoice_number}` : 'Försäljning faktura'),
})
const roundedVat = Math.round(toSek(group.vatAmount) * 100) / 100
@@ -205,7 +205,7 @@ function proposeCashLines(
account_number: revenueAccount,
debit_amount: '',
credit_amount: toFormAmount(subtotalSek),
line_description: `Försäljning faktura ${invoice.invoice_number}`,
line_description: (invoice.invoice_number ? `Försäljning faktura ${invoice.invoice_number}` : 'Försäljning faktura'),
})
if (invoice.vat_amount > 0) {
@@ -215,7 +215,7 @@ function proposeCashLines(
account_number: vatAccount,
debit_amount: '',
credit_amount: toFormAmount(vatSek),
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
line_description: (invoice.invoice_number ? `Utgående moms faktura ${invoice.invoice_number}` : 'Utgående moms faktura'),
})
}
}
+3 -3
View File
@@ -12,7 +12,7 @@ import type { EntityType, InvoiceItem, VatTreatment } from '@/types'
export interface ProposeSendLinesInput {
invoice: {
invoice_number: string
invoice_number: string | null
total: number
total_sek?: number | null
subtotal: number
@@ -43,7 +43,7 @@ export function proposeSendLines(input: ProposeSendLinesInput): FormLine[] {
const { invoice, entityType } = input
const lines: FormLine[] = []
const isForeign = invoice.currency !== 'SEK'
const desc = `Försäljning faktura ${invoice.invoice_number}`
const desc = invoice.invoice_number ? `Försäljning faktura ${invoice.invoice_number}` : 'Försäljning faktura'
const toSek = (amount: number): number => {
if (!isForeign) return amount
@@ -134,7 +134,7 @@ export function proposeSendLines(input: ProposeSendLinesInput): FormLine[] {
account_number: vatAccount,
debit_amount: '',
credit_amount: toFormAmount(vatSek),
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
line_description: (invoice.invoice_number ? `Utgående moms faktura ${invoice.invoice_number}` : 'Utgående moms faktura'),
})
}
}
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
type MockChain = {
from: ReturnType<typeof vi.fn>
rpc: ReturnType<typeof vi.fn>
}
function buildMockSupabase(): MockChain {
return {
from: vi.fn(),
rpc: vi.fn(),
}
}
describe('ensureInvoiceNumber', () => {
let supabase: MockChain
beforeEach(() => {
supabase = buildMockSupabase()
})
it('returns existing number without RPC when invoice already has one', async () => {
const invoice = { id: 'inv-1', invoice_number: 'F-2026001' }
const result = await ensureInvoiceNumber(supabase as never, 'company-1', invoice)
expect(result).toBe('F-2026001')
expect(supabase.rpc).not.toHaveBeenCalled()
expect(invoice.invoice_number).toBe('F-2026001')
})
it('calls RPC with invoice id and document_type=invoice when number is null', async () => {
const invoice: { id: string; invoice_number: string | null } = {
id: 'inv-1',
invoice_number: null,
}
supabase.rpc.mockResolvedValue({ data: 'F2026005', error: null })
const result = await ensureInvoiceNumber(supabase as never, 'company-1', invoice)
expect(result).toBe('F2026005')
expect(supabase.rpc).toHaveBeenCalledWith('generate_invoice_number', {
p_company_id: 'company-1',
p_invoice_id: 'inv-1',
p_document_type: 'invoice',
})
expect(invoice.invoice_number).toBe('F2026005')
})
it('passes document_type=proforma so the RPC produces a PF- prefix', async () => {
const invoice = {
id: 'inv-2',
invoice_number: null,
document_type: 'proforma' as const,
}
supabase.rpc.mockResolvedValue({ data: 'PF-2026005', error: null })
const result = await ensureInvoiceNumber(supabase as never, 'company-1', invoice)
expect(result).toBe('PF-2026005')
expect(supabase.rpc).toHaveBeenCalledWith('generate_invoice_number', {
p_company_id: 'company-1',
p_invoice_id: 'inv-2',
p_document_type: 'proforma',
})
expect(invoice.invoice_number).toBe('PF-2026005')
})
it('throws when RPC fails', async () => {
const invoice = { id: 'inv-1', invoice_number: null }
supabase.rpc.mockResolvedValue({ data: null, error: { message: 'RPC failed' } })
await expect(
ensureInvoiceNumber(supabase as never, 'company-1', invoice)
).rejects.toThrow('Failed to assign invoice number')
})
it('throws when RPC returns no data even without an error', async () => {
const invoice = { id: 'inv-1', invoice_number: null }
supabase.rpc.mockResolvedValue({ data: null, error: null })
await expect(
ensureInvoiceNumber(supabase as never, 'company-1', invoice)
).rejects.toThrow('no value returned')
})
})
@@ -0,0 +1,191 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
// Insert a customer + draft invoice (invoice_number=null) and return the invoice id.
async function insertDraftInvoice(params: {
userId: string
companyId: string
documentType?: 'invoice' | 'proforma'
}): Promise<string> {
const customerId = randomUUID()
await getPool().query(
`INSERT INTO public.customers (id, user_id, company_id, name)
VALUES ($1, $2, $3, 'Test Customer')`,
[customerId, params.userId, params.companyId],
)
const invoiceId = randomUUID()
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, document_type,
invoice_date, due_date, currency, subtotal, vat_amount, total,
vat_treatment, vat_rate, moms_ruta, status)
VALUES ($1, $2, $3, $4, NULL, $5,
'2026-04-27', '2026-05-27', 'SEK', 1000, 250, 1250,
'standard_25', 25, '10', 'draft')`,
[invoiceId, params.userId, params.companyId, customerId, params.documentType ?? 'invoice'],
)
return invoiceId
}
async function ensureCompanySettings(params: {
userId: string
companyId: string
invoicePrefix?: string
nextInvoiceNumber?: number
}): Promise<void> {
await getPool().query(
`INSERT INTO public.company_settings
(user_id, company_id, invoice_prefix, next_invoice_number)
VALUES ($1, $2, $3, $4)
ON CONFLICT (company_id) DO UPDATE
SET invoice_prefix = EXCLUDED.invoice_prefix,
next_invoice_number = EXCLUDED.next_invoice_number`,
[params.userId, params.companyId, params.invoicePrefix ?? 'F', params.nextInvoiceNumber ?? 1],
)
}
async function readCounter(companyId: string): Promise<number> {
const { rows } = await getPool().query<{ next_invoice_number: number }>(
'SELECT next_invoice_number FROM public.company_settings WHERE company_id = $1',
[companyId],
)
return rows[0]!.next_invoice_number
}
describe('generate_invoice_number RPC', () => {
it('assigns a number to a draft and persists it on the invoice row', async () => {
const { userId, companyId } = await seedCompany()
await ensureCompanySettings({ userId, companyId, invoicePrefix: 'F', nextInvoiceNumber: 1 })
const invoiceId = await insertDraftInvoice({ userId, companyId })
const { rows } = await getPool().query<{ generate_invoice_number: string }>(
'SELECT public.generate_invoice_number($1, $2, $3)',
[companyId, invoiceId, 'invoice'],
)
const assigned = rows[0]!.generate_invoice_number
expect(assigned).toMatch(/^F\d{4}\d{3}$/)
const persisted = await getPool().query<{ invoice_number: string }>(
'SELECT invoice_number FROM public.invoices WHERE id = $1',
[invoiceId],
)
expect(persisted.rows[0]!.invoice_number).toBe(assigned)
})
it('produces a PF- prefix when document_type is proforma', async () => {
const { userId, companyId } = await seedCompany()
await ensureCompanySettings({ userId, companyId, invoicePrefix: 'F', nextInvoiceNumber: 42 })
const invoiceId = await insertDraftInvoice({ userId, companyId, documentType: 'proforma' })
const { rows } = await getPool().query<{ generate_invoice_number: string }>(
'SELECT public.generate_invoice_number($1, $2, $3)',
[companyId, invoiceId, 'proforma'],
)
expect(rows[0]!.generate_invoice_number).toMatch(/^PF-\d{4}042$/)
})
it('is idempotent: a second call on the same invoice returns the same number without bumping the counter', async () => {
const { userId, companyId } = await seedCompany()
await ensureCompanySettings({ userId, companyId, invoicePrefix: 'F', nextInvoiceNumber: 10 })
const invoiceId = await insertDraftInvoice({ userId, companyId })
const first = await getPool().query<{ generate_invoice_number: string }>(
'SELECT public.generate_invoice_number($1, $2, $3)',
[companyId, invoiceId, 'invoice'],
)
const counterAfterFirst = await readCounter(companyId)
const second = await getPool().query<{ generate_invoice_number: string }>(
'SELECT public.generate_invoice_number($1, $2, $3)',
[companyId, invoiceId, 'invoice'],
)
const counterAfterSecond = await readCounter(companyId)
expect(second.rows[0]!.generate_invoice_number).toBe(first.rows[0]!.generate_invoice_number)
expect(counterAfterSecond).toBe(counterAfterFirst)
})
it('serializes concurrent calls on the same invoice — both see the same number, counter advances by 1', async () => {
const { userId, companyId } = await seedCompany()
await ensureCompanySettings({ userId, companyId, invoicePrefix: 'F', nextInvoiceNumber: 100 })
const invoiceId = await insertDraftInvoice({ userId, companyId })
const counterBefore = await readCounter(companyId)
// Race two RPC calls on dedicated clients so they really execute in parallel.
const a = getPool()
.connect()
.then(async (c) => {
try {
const { rows } = await c.query<{ generate_invoice_number: string }>(
'SELECT public.generate_invoice_number($1, $2, $3)',
[companyId, invoiceId, 'invoice'],
)
return rows[0]!.generate_invoice_number
} finally {
c.release()
}
})
const b = getPool()
.connect()
.then(async (c) => {
try {
const { rows } = await c.query<{ generate_invoice_number: string }>(
'SELECT public.generate_invoice_number($1, $2, $3)',
[companyId, invoiceId, 'invoice'],
)
return rows[0]!.generate_invoice_number
} finally {
c.release()
}
})
const [resultA, resultB] = await Promise.all([a, b])
const counterAfter = await readCounter(companyId)
expect(resultA).toBe(resultB)
expect(counterAfter - counterBefore).toBe(1)
})
it('different invoices in the same company get distinct sequential numbers', async () => {
const { userId, companyId } = await seedCompany()
await ensureCompanySettings({ userId, companyId, invoicePrefix: 'F', nextInvoiceNumber: 200 })
const invoiceA = await insertDraftInvoice({ userId, companyId })
const invoiceB = await insertDraftInvoice({ userId, companyId })
const a = await getPool().query<{ generate_invoice_number: string }>(
'SELECT public.generate_invoice_number($1, $2, $3)',
[companyId, invoiceA, 'invoice'],
)
const b = await getPool().query<{ generate_invoice_number: string }>(
'SELECT public.generate_invoice_number($1, $2, $3)',
[companyId, invoiceB, 'invoice'],
)
expect(a.rows[0]!.generate_invoice_number).toMatch(/200$/)
expect(b.rows[0]!.generate_invoice_number).toMatch(/201$/)
})
it('raises when the invoice id does not belong to the company', async () => {
const { userId, companyId } = await seedCompany()
await ensureCompanySettings({ userId, companyId })
const otherCompany = await seedCompany()
const invoiceId = await insertDraftInvoice({
userId: otherCompany.userId,
companyId: otherCompany.companyId,
})
await expect(
getPool().query('SELECT public.generate_invoice_number($1, $2, $3)', [
companyId,
invoiceId,
'invoice',
]),
).rejects.toThrow(/Invoice .* not found/)
})
})
@@ -0,0 +1,84 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
describe('invoices.invoice_number nullable + partial unique index', () => {
async function insertInvoice(params: {
userId: string
companyId: string
invoiceNumber: string | null
}): Promise<string> {
const id = randomUUID()
const customerId = randomUUID()
await getPool().query(
`INSERT INTO public.customers (id, user_id, company_id, name)
VALUES ($1, $2, $3, 'Test Customer')`,
[customerId, params.userId, params.companyId],
)
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number,
invoice_date, due_date, currency, subtotal, vat_amount, total,
vat_treatment, vat_rate, moms_ruta, status)
VALUES ($1, $2, $3, $4, $5,
'2026-04-27', '2026-05-27', 'SEK', 1000, 250, 1250,
'standard_25', 25, '10', 'draft')`,
[id, params.userId, params.companyId, customerId, params.invoiceNumber],
)
return id
}
it('accepts NULL invoice_number for drafts (constraint dropped)', async () => {
const { userId, companyId } = await seedCompany()
const id = await insertInvoice({ userId, companyId, invoiceNumber: null })
const { rows } = await getPool().query<{ invoice_number: string | null }>(
'SELECT invoice_number FROM public.invoices WHERE id = $1',
[id],
)
expect(rows[0]!.invoice_number).toBeNull()
})
it('allows multiple drafts with NULL invoice_number in the same company', async () => {
const { userId, companyId } = await seedCompany()
const a = await insertInvoice({ userId, companyId, invoiceNumber: null })
const b = await insertInvoice({ userId, companyId, invoiceNumber: null })
expect(a).not.toBe(b)
const { rows } = await getPool().query(
'SELECT count(*)::int FROM public.invoices WHERE company_id = $1 AND invoice_number IS NULL',
[companyId],
)
expect(rows[0]!.count).toBe(2)
})
it('still rejects duplicate non-NULL numbers within a company', async () => {
const { userId, companyId } = await seedCompany()
await insertInvoice({ userId, companyId, invoiceNumber: 'F-2026001' })
await expect(
insertInvoice({ userId, companyId, invoiceNumber: 'F-2026001' }),
).rejects.toThrow(/idx_invoices_company_invoice_number|duplicate key/i)
})
it('lets two different companies use the same invoice number', async () => {
const a = await seedCompany()
const b = await seedCompany()
await insertInvoice({ userId: a.userId, companyId: a.companyId, invoiceNumber: 'F-2026001' })
await insertInvoice({ userId: b.userId, companyId: b.companyId, invoiceNumber: 'F-2026001' })
// Scope the count to these two companies — earlier tests in the suite leave
// 'F-2026001' rows behind in their own companies, and pg-real has no
// per-test cleanup.
const { rows } = await getPool().query(
'SELECT count(*)::int FROM public.invoices WHERE invoice_number = $1 AND company_id = ANY($2::uuid[])',
['F-2026001', [a.companyId, b.companyId]],
)
expect(rows[0]!.count).toBe(2)
})
})
+5
View File
@@ -0,0 +1,5 @@
export const INVOICE_NUMBER_DRAFT_LABEL = '(Utkast)'
export function invoiceNumberDisplay(value: string | null | undefined): string {
return value ?? INVOICE_NUMBER_DRAFT_LABEL
}
+39
View File
@@ -0,0 +1,39 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Invoice, InvoiceDocumentType } from '@/types'
type InvoiceShape = Pick<Invoice, 'id' | 'invoice_number'> & {
invoice_number: string | null
document_type?: InvoiceDocumentType | null
}
/**
* Assign an invoice number to a draft invoice. Idempotent: if the row already
* has a number, returns it unchanged without consuming a sequence number.
*
* Concurrency is handled inside the generate_invoice_number RPC via row lock.
* Two callers racing on the same draft both return the same final number; the
* counter advances by exactly one. Proforma document_type produces a 'PF-'
* prefix; everything else uses the company's configured invoice_prefix.
*/
export async function ensureInvoiceNumber(
supabase: SupabaseClient,
companyId: string,
invoice: InvoiceShape,
): Promise<string> {
if (invoice.invoice_number) {
return invoice.invoice_number
}
const { data: assigned, error: rpcError } = await supabase.rpc('generate_invoice_number', {
p_company_id: companyId,
p_invoice_id: invoice.id,
p_document_type: invoice.document_type ?? 'invoice',
})
if (rpcError || !assigned) {
throw new Error(`Failed to assign invoice number: ${rpcError?.message ?? 'no value returned'}`)
}
invoice.invoice_number = assigned
return assigned
}
+35 -2
View File
@@ -214,6 +214,26 @@ const styles = StyleSheet.create({
creditNoteTitle: {
color: '#721c24',
},
draftBanner: {
marginBottom: 16,
padding: 10,
backgroundColor: '#fff3cd',
borderWidth: 2,
borderColor: '#856404',
borderRadius: 4,
},
draftBannerTitle: {
fontSize: 14,
fontWeight: 'bold',
color: '#856404',
textAlign: 'center',
marginBottom: 2,
},
draftBannerText: {
fontSize: 9,
color: '#856404',
textAlign: 'center',
},
footer: {
position: 'absolute',
bottom: 30,
@@ -305,13 +325,26 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Draft banner — visible warning when this PDF is rendered for an
invoice that has not yet been assigned a löpnummer. ML 17 kap 24§
requires a unique invoice number; without one the document is not
valid as fakturaunderlag and must not be sent to a customer. */}
{!invoice.invoice_number && (
<View style={styles.draftBanner}>
<Text style={styles.draftBannerTitle}>UTKAST inte en giltig faktura</Text>
<Text style={styles.draftBannerText}>
Denna faktura saknar löpnummer och kan inte användas som fakturaunderlag enligt ML 17 kap 24§. Skicka fakturan via systemet för att tilldela ett nummer.
</Text>
</View>
)}
{/* Header */}
<View style={styles.header}>
<View>
<Text style={[styles.title, isCreditNote ? styles.creditNoteTitle : {}]}>
{getDocumentTitle(invoice)}
</Text>
<Text style={{ marginTop: 5, color: '#666' }}>{invoice.invoice_number}</Text>
<Text style={{ marginTop: 5, color: '#666' }}>{invoice.invoice_number ?? 'FÖRHANDSGRANSKNING'}</Text>
</View>
<View style={styles.companyInfo}>
{company.logo_url && (
@@ -567,7 +600,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{(company.invoice_show_ocr ?? true) && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>OCR/Referens:</Text>
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{generateOcrReference(invoice.invoice_number)}</Text>
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{invoice.invoice_number ? generateOcrReference(invoice.invoice_number) : '—'}</Text>
</View>
)}
</View>
@@ -0,0 +1,11 @@
-- Make invoices.invoice_number nullable.
-- Drafts no longer reserve a number at creation; numbers are assigned at the
-- moment status transitions to 'sent'. The partial unique index
-- idx_invoices_company_invoice_number (WHERE invoice_number IS NOT NULL) from
-- 20260330130000_multi_tenant_company_refactor.sql already permits multiple
-- NULLs, so no index changes are required.
ALTER TABLE public.invoices
ALTER COLUMN invoice_number DROP NOT NULL;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,17 @@
-- Belt-and-suspenders for the nullable invoice_number column.
-- Invoices in any status that implies they have left the draft stage must carry
-- a number. ensureInvoiceNumber covers known send paths in application code,
-- but a future caller could transition status without going through that helper
-- and silently produce a sent invoice with no löpnummer (ML 17 kap 24§ violation).
--
-- 'draft' and 'cancelled' are the only statuses where invoice_number may legally
-- be NULL — drafts have not been numbered yet, and cancelled-from-draft never
-- needed one. Cancelled-after-send retains its existing number, so the rule
-- still holds. Status enum from invoices_status_check:
-- draft, sent, paid, partially_paid, overdue, cancelled, credited
ALTER TABLE public.invoices
ADD CONSTRAINT invoices_sent_requires_number
CHECK (status IN ('draft', 'cancelled') OR invoice_number IS NOT NULL);
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,91 @@
-- Atomic, document_type-aware invoice number generation.
--
-- Replaces the single-arg signature with one that:
-- 1. Locks the target invoice row (SELECT ... FOR UPDATE) so concurrent
-- callers serialize on the same draft.
-- 2. Returns the existing number if the row already has one — idempotent;
-- the loser of a race never consumes a sequence number.
-- 3. Allocates from company_settings.next_invoice_number only when needed.
-- 4. Persists the assigned number on the invoice row in the same transaction.
-- 5. Applies a 'PF-' prefix when document_type = 'proforma' so proformas
-- remain visually distinct from real invoices in the F-series.
--
-- Why this changes:
-- - The old single-arg version always advanced the per-company counter,
-- then a separate UPDATE in TS persisted it on the invoices row. Two
-- concurrent send calls on the same draft both incremented the counter,
-- and the loser's number was discarded — a permanent gap in the F-series.
-- Gaps are tolerated under Swedish practice but creating them through a
-- race is gratuitous and harms Skatteverket reconciliation traceability.
-- - The proforma 'PF-' prefix logic previously lived in the API route
-- (app/api/invoices/route.ts) and was lost when invoice_number became
-- nullable and assignment moved to ensureInvoiceNumber. Pushing the
-- prefix into the RPC keeps prefix logic next to the allocator.
DROP FUNCTION IF EXISTS public.generate_invoice_number(uuid);
CREATE OR REPLACE FUNCTION public.generate_invoice_number(
p_company_id uuid,
p_invoice_id uuid,
p_document_type text DEFAULT 'invoice'
)
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_existing text;
v_prefix text;
v_number integer;
v_year text;
v_final text;
BEGIN
-- 1. Lock the invoice row. Concurrent callers block here until the first
-- transaction commits, then see the persisted number on retry.
SELECT invoice_number INTO v_existing
FROM public.invoices
WHERE id = p_invoice_id AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Invoice % not found in company %', p_invoice_id, p_company_id;
END IF;
-- 2. Idempotent: if the number is already set, return it without consuming
-- the sequence. This is also the path concurrent callers take after
-- unblocking from the row lock.
IF v_existing IS NOT NULL THEN
RETURN v_existing;
END IF;
-- 3. Allocate from per-company counter atomically. UPDATE ... RETURNING is
-- serialized by Postgres on the company_settings row.
UPDATE public.company_settings
SET next_invoice_number = next_invoice_number + 1,
updated_at = now()
WHERE company_id = p_company_id
RETURNING invoice_prefix, next_invoice_number - 1
INTO v_prefix, v_number;
IF v_number IS NULL THEN
RAISE EXCEPTION 'Company settings not found for company %', p_company_id;
END IF;
-- 4. Compose: proforma -> 'PF-', otherwise use the company's invoice_prefix.
v_year := EXTRACT(YEAR FROM CURRENT_DATE)::text;
v_final := CASE
WHEN p_document_type = 'proforma' THEN 'PF-'
ELSE COALESCE(v_prefix, '')
END || v_year || LPAD(v_number::text, 3, '0');
-- 5. Persist on the invoice row in the same transaction.
UPDATE public.invoices
SET invoice_number = v_final
WHERE id = p_invoice_id AND company_id = p_company_id;
RETURN v_final;
END;
$function$;
NOTIFY pgrst, 'reload schema';
+2 -2
View File
@@ -591,8 +591,8 @@ export interface Invoice {
company_id: string
customer_id: string
// Invoice number (auto-generated)
invoice_number: string
// Invoice number (auto-generated at first send; null while draft)
invoice_number: string | null
// Dates
invoice_date: string