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:
@@ -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 ? (
|
||||
|
||||
@@ -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 på att du vill ta bort utkast {invoice.invoice_number}? Detta kan inte ångras.
|
||||
Är du säker på att du vill ta bort {invoice.invoice_number ? `utkast ${invoice.invoice_number}` : 'utkastet'}? Detta kan inte ångras.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user