From fd1db896033bc58b5fe0b6029f37a1a9f1706f02 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:29:58 +0200 Subject: [PATCH] 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. --- app/(dashboard)/customers/[id]/page.tsx | 6 +- app/(dashboard)/invoices/[id]/credit/page.tsx | 10 +- app/(dashboard)/invoices/[id]/page.tsx | 19 +- app/(dashboard)/invoices/page.tsx | 5 +- .../fiscal-periods/__tests__/route.test.ts | 122 ++++++++--- app/api/bookkeeping/fiscal-periods/route.ts | 34 +++- app/api/invoices/[id]/convert/route.ts | 26 ++- app/api/invoices/[id]/mark-sent/route.ts | 12 ++ app/api/invoices/[id]/pdf/route.ts | 5 +- .../[id]/send/__tests__/route.test.ts | 63 ++++++ app/api/invoices/[id]/send/route.ts | 14 ++ app/api/invoices/__tests__/route.test.ts | 5 +- app/api/invoices/route.ts | 13 +- .../[id]/commit/__tests__/route.test.ts | 10 +- .../pending-operations/[id]/commit/route.ts | 25 ++- components/bookkeeping/CreatePeriodDialog.tsx | 26 +-- components/invoices/PaymentBookingDialog.tsx | 2 +- components/invoices/SendInvoiceDialog.tsx | 4 +- .../general/push-notifications/index.ts | 3 +- lib/bookkeeping/currency-revaluation.ts | 2 +- lib/bookkeeping/invoice-entries.ts | 70 ++++--- lib/bookkeeping/propose-payment-lines.ts | 12 +- lib/bookkeeping/propose-send-lines.ts | 6 +- .../__tests__/ensure-invoice-number.test.ts | 89 ++++++++ .../generate-invoice-number.pg.test.ts | 191 ++++++++++++++++++ .../invoice-number-nullable.pg.test.ts | 84 ++++++++ lib/invoices/display.ts | 5 + lib/invoices/ensure-invoice-number.ts | 39 ++++ lib/invoices/pdf-template.tsx | 37 +++- ...20260427120000_invoice_number_nullable.sql | 11 + ...000_invoice_sent_requires_number_check.sql | 17 ++ .../20260427150100_invoice_number_atomic.sql | 91 +++++++++ types/index.ts | 4 +- 33 files changed, 931 insertions(+), 131 deletions(-) create mode 100644 lib/invoices/__tests__/ensure-invoice-number.test.ts create mode 100644 lib/invoices/__tests__/generate-invoice-number.pg.test.ts create mode 100644 lib/invoices/__tests__/invoice-number-nullable.pg.test.ts create mode 100644 lib/invoices/display.ts create mode 100644 lib/invoices/ensure-invoice-number.ts create mode 100644 supabase/migrations/20260427120000_invoice_number_nullable.sql create mode 100644 supabase/migrations/20260427150000_invoice_sent_requires_number_check.sql create mode 100644 supabase/migrations/20260427150100_invoice_number_atomic.sql diff --git a/app/(dashboard)/customers/[id]/page.tsx b/app/(dashboard)/customers/[id]/page.tsx index cfa6d9fa..970c76ae 100644 --- a/app/(dashboard)/customers/[id]/page.tsx +++ b/app/(dashboard)/customers/[id]/page.tsx @@ -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 = { @@ -44,7 +46,7 @@ const customerTypeIcons: Record = { 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" >
-

{invoice.invoice_number}

+

{invoiceNumberDisplay(invoice.invoice_number)}

{invoice.invoice_date}

diff --git a/app/(dashboard)/invoices/[id]/credit/page.tsx b/app/(dashboard)/invoices/[id]/credit/page.tsx index 21c23312..2193f76c 100644 --- a/app/(dashboard)/invoices/[id]/credit/page.tsx +++ b/app/(dashboard)/invoices/[id]/credit/page.tsx @@ -311,7 +311,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id: 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:
-

{invoice.invoice_number}

+

{invoiceNumberDisplay(invoice.invoice_number)}

{isProforma && ( Proforma )} @@ -618,7 +623,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
Fakturanummer - {invoice.invoice_number} + {invoiceNumberDisplay(invoice.invoice_number)}
Fakturadatum @@ -948,7 +953,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st Ta bort fakturautkast - Ä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. diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 445d932c..58252d5c 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -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() {
-

{invoice.invoice_number}

+

{invoiceNumberDisplay(invoice.invoice_number)}

{formatCurrency(Number(invoice.total), invoice.currency)}

diff --git a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts index 7e44f4c6..7a58655b 100644 --- a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts +++ b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts @@ -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 = {} // 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 }), + }), }), }), } diff --git a/app/api/bookkeeping/fiscal-periods/route.ts b/app/api/bookkeeping/fiscal-periods/route.ts index ef3af8c8..aa34fab5 100644 --- a/app/api/bookkeeping/fiscal-periods/route.ts +++ b/app/api/bookkeeping/fiscal-periods/route.ts @@ -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 } ) } diff --git a/app/api/invoices/[id]/convert/route.ts b/app/api/invoices/[id]/convert/route.ts index 5800ecf9..b2304ad0 100644 --- a/app/api/invoices/[id]/convert/route.ts +++ b/app/api/invoices/[id]/convert/route.ts @@ -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, diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index 9c41bd47..d8406893 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -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') diff --git a/app/api/invoices/[id]/pdf/route.ts b/app/api/invoices/[id]/pdf/route.ts index 8f1304d0..077bea5c 100644 --- a/app/api/invoices/[id]/pdf/route.ts +++ b/app/api/invoices/[id]/pdf/route.ts @@ -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, diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index 48844841..a9d4c10b 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -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 }) diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 1c3e081b..3fc2c340 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -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 diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index 9b606e58..47927e7c 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -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' } }) diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index ee8bca61..f676a9b8 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -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 diff --git a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts index 41607fcc..5bfc0bb2 100644 --- a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts @@ -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 () => { diff --git a/app/api/pending-operations/[id]/commit/route.ts b/app/api/pending-operations/[id]/commit/route.ts index 56025e75..282d5fa3 100644 --- a/app/api/pending-operations/[id]/commit/route.ts +++ b/app/api/pending-operations/[id]/commit/route.ts @@ -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' }) diff --git a/components/bookkeeping/CreatePeriodDialog.tsx b/components/bookkeeping/CreatePeriodDialog.tsx index 2a2d7adc..f26d327c 100644 --- a/components/bookkeeping/CreatePeriodDialog.tsx +++ b/components/bookkeeping/CreatePeriodDialog.tsx @@ -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 } diff --git a/components/invoices/PaymentBookingDialog.tsx b/components/invoices/PaymentBookingDialog.tsx index aeb009e5..3485ac58 100644 --- a/components/invoices/PaymentBookingDialog.tsx +++ b/components/invoices/PaymentBookingDialog.tsx @@ -212,7 +212,7 @@ export default function PaymentBookingDialog({ - Bokför betalning — {invoice.invoice_number} + Bokför betalning{invoice.invoice_number ? ` — ${invoice.invoice_number}` : ''} {formatCurrency(invoice.total, invoice.currency)} {invoice.currency !== 'SEK' && invoice.total_sek && ( diff --git a/components/invoices/SendInvoiceDialog.tsx b/components/invoices/SendInvoiceDialog.tsx index 6f30b49e..30214ef1 100644 --- a/components/invoices/SendInvoiceDialog.tsx +++ b/components/invoices/SendInvoiceDialog.tsx @@ -188,7 +188,7 @@ export default function SendInvoiceDialog({ - {mode === 'email' ? 'Skicka faktura' : 'Markera som skickad'} — {invoice.invoice_number} + {mode === 'email' ? 'Skicka faktura' : 'Markera som skickad'}{invoice.invoice_number ? ` — ${invoice.invoice_number}` : ''} {formatCurrency(invoice.total, invoice.currency)} @@ -215,7 +215,7 @@ export default function SendInvoiceDialog({ ): 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, diff --git a/lib/bookkeeping/propose-payment-lines.ts b/lib/bookkeeping/propose-payment-lines.ts index 2f57596f..dec2923b 100644 --- a/lib/bookkeeping/propose-payment-lines.ts +++ b/lib/bookkeeping/propose-payment-lines.ts @@ -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'), }) } } diff --git a/lib/bookkeeping/propose-send-lines.ts b/lib/bookkeeping/propose-send-lines.ts index 122a0146..d9e2f8d6 100644 --- a/lib/bookkeeping/propose-send-lines.ts +++ b/lib/bookkeeping/propose-send-lines.ts @@ -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'), }) } } diff --git a/lib/invoices/__tests__/ensure-invoice-number.test.ts b/lib/invoices/__tests__/ensure-invoice-number.test.ts new file mode 100644 index 00000000..f084fdec --- /dev/null +++ b/lib/invoices/__tests__/ensure-invoice-number.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' + +type MockChain = { + from: ReturnType + rpc: ReturnType +} + +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') + }) +}) diff --git a/lib/invoices/__tests__/generate-invoice-number.pg.test.ts b/lib/invoices/__tests__/generate-invoice-number.pg.test.ts new file mode 100644 index 00000000..c4b26ebd --- /dev/null +++ b/lib/invoices/__tests__/generate-invoice-number.pg.test.ts @@ -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 { + 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 { + 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 { + 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/) + }) +}) diff --git a/lib/invoices/__tests__/invoice-number-nullable.pg.test.ts b/lib/invoices/__tests__/invoice-number-nullable.pg.test.ts new file mode 100644 index 00000000..15194010 --- /dev/null +++ b/lib/invoices/__tests__/invoice-number-nullable.pg.test.ts @@ -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 { + 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) + }) +}) diff --git a/lib/invoices/display.ts b/lib/invoices/display.ts new file mode 100644 index 00000000..771e80f3 --- /dev/null +++ b/lib/invoices/display.ts @@ -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 +} diff --git a/lib/invoices/ensure-invoice-number.ts b/lib/invoices/ensure-invoice-number.ts new file mode 100644 index 00000000..e13a63ff --- /dev/null +++ b/lib/invoices/ensure-invoice-number.ts @@ -0,0 +1,39 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { Invoice, InvoiceDocumentType } from '@/types' + +type InvoiceShape = Pick & { + 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 { + 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 +} diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index bdc68305..ad508bf3 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -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 ( + {/* 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 && ( + + UTKAST – inte en giltig faktura + + 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. + + + )} + {/* Header */} {getDocumentTitle(invoice)} - {invoice.invoice_number} + {invoice.invoice_number ?? 'FÖRHANDSGRANSKNING'} {company.logo_url && ( @@ -567,7 +600,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN {(company.invoice_show_ocr ?? true) && ( OCR/Referens: - {generateOcrReference(invoice.invoice_number)} + {invoice.invoice_number ? generateOcrReference(invoice.invoice_number) : '—'} )} diff --git a/supabase/migrations/20260427120000_invoice_number_nullable.sql b/supabase/migrations/20260427120000_invoice_number_nullable.sql new file mode 100644 index 00000000..4ee04b7b --- /dev/null +++ b/supabase/migrations/20260427120000_invoice_number_nullable.sql @@ -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'; diff --git a/supabase/migrations/20260427150000_invoice_sent_requires_number_check.sql b/supabase/migrations/20260427150000_invoice_sent_requires_number_check.sql new file mode 100644 index 00000000..9c3939d2 --- /dev/null +++ b/supabase/migrations/20260427150000_invoice_sent_requires_number_check.sql @@ -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'; diff --git a/supabase/migrations/20260427150100_invoice_number_atomic.sql b/supabase/migrations/20260427150100_invoice_number_atomic.sql new file mode 100644 index 00000000..94d5ea12 --- /dev/null +++ b/supabase/migrations/20260427150100_invoice_number_atomic.sql @@ -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'; diff --git a/types/index.ts b/types/index.ts index 7e7a38ed..a89fc6cc 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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