fix(invoices): make self-billed invoices creditable and their dates visible (#1827)
A self-billed invoice has invoice_number null by design (the counterparty's number lives in external_invoice_number), which broke the whole credit flow: the confirm input was disabled and compared against null, the API minted the literal number 'KR-null', and the credit-note PDF dropped its ML 17 kap 22 reference to the original. The editor also hid fakturadatum inside the collapsed Forval panel, so self-billed invoices silently registered with today's date and, being immutable, could not be corrected. - creditConfirmNumber() falls back to external_invoice_number; the credit page uses it for reason default, subtitle, original row, preview, confirm label/placeholder/disabled, mismatch check and submit gate - createCreditNote numbers 'KR-<external>' for self-billed originals and refuses with typed 400 INVOICE_CREDIT_NO_NUMBER when no number exists - mark-sent and send select external_invoice_number and fall back for the credit-note PDF's reference to the original - the Forval chip line now shows the invoice date in every mode, and self-billed mode renders fakturadatum + mottagningsdatum uncollapsed as transcription fields next to the external number Fixes #1820 Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
21c63b8b12
commit
78525bd391
@@ -353,6 +353,46 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Issue #1820: a self-billed original has invoice_number null (its number
|
||||
// lives in external_invoice_number); the credit-note PDF's ML 17 kap 22
|
||||
// reference to the original used to be silently dropped.
|
||||
it('falls back to the external number for the PDF reference on a self-billed original', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
id: 'inv-4',
|
||||
invoice_number: 'KR-SB-2026-17',
|
||||
status: 'draft',
|
||||
credited_invoice_id: 'inv-sb',
|
||||
customer,
|
||||
items: invoice.items,
|
||||
})
|
||||
|
||||
enqueue({ data: creditNote, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-sb',
|
||||
invoice_number: null,
|
||||
external_invoice_number: 'SB-2026-17',
|
||||
status: 'sent',
|
||||
journal_entry_id: 'original-je-1',
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
total: 12500,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [{ id: 'inv-4' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-4/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-4' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(InvoicePDF).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ originalInvoiceNumber: 'SB-2026-17' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed and restores the draft when credit-note booking cannot start', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
id: 'credit-1',
|
||||
|
||||
@@ -160,7 +160,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
|
||||
const { data: original } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
|
||||
.select('id, invoice_number, external_invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
|
||||
.eq('id', invoice.credited_invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
@@ -170,7 +170,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
}
|
||||
|
||||
const originalInvoice = original as CreditNoteOriginalInvoice
|
||||
const originalInvoiceNumber = original.invoice_number ?? undefined
|
||||
// Self-billed originals carry their number in external_invoice_number
|
||||
// (invoice_number is null by design); without the fallback the credit-note
|
||||
// PDF loses its ML 17 kap 22 reference to the original (issue #1820).
|
||||
const originalInvoiceNumber =
|
||||
original.invoice_number ?? original.external_invoice_number ?? undefined
|
||||
|
||||
const journalEntryRequired = creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
|
||||
const isRecovery = invoice.status === 'sent'
|
||||
|
||||
@@ -263,7 +263,7 @@ export const POST = withRouteContext(
|
||||
if (invoice.credited_invoice_id) {
|
||||
const { data: original } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
|
||||
.select('id, invoice_number, external_invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
|
||||
.eq('id', invoice.credited_invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
@@ -273,7 +273,12 @@ export const POST = withRouteContext(
|
||||
}
|
||||
|
||||
originalInvoice = original as CreditNoteOriginalInvoice
|
||||
originalInvoiceNumber = original.invoice_number ?? undefined
|
||||
// Self-billed originals carry their number in external_invoice_number
|
||||
// (invoice_number is null by design); without the fallback the
|
||||
// credit-note PDF loses its ML 17 kap 22 reference to the original
|
||||
// (issue #1820).
|
||||
originalInvoiceNumber =
|
||||
original.invoice_number ?? original.external_invoice_number ?? undefined
|
||||
}
|
||||
|
||||
// Preflight render: validate the PDF pipeline BEFORE consuming an F-series
|
||||
|
||||
@@ -625,6 +625,90 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// Regression for issue #1820: a self-billed original has invoice_number
|
||||
// null by design (its number lives in external_invoice_number), and the
|
||||
// credit note used to be numbered the literal string 'KR-null' with notes
|
||||
// saying 'Krediterar faktura null'.
|
||||
it('numbers the credit note from the external number for a self-billed original', async () => {
|
||||
const original = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
invoice_number: null as unknown as string,
|
||||
external_invoice_number: 'SB-2026-17',
|
||||
is_self_billed: true,
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
invoice_id: VALID_UUID,
|
||||
sort_order: 0,
|
||||
description: 'Provision',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 10000,
|
||||
line_total: 10000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 2500,
|
||||
created_at: '2026-08-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
const creditNote = makeInvoice({
|
||||
id: 'cn-sb',
|
||||
credited_invoice_id: VALID_UUID,
|
||||
status: 'draft',
|
||||
})
|
||||
|
||||
// Fetch original invoice
|
||||
enqueue({ data: original, error: null })
|
||||
// No existing credit-note draft
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert credit note
|
||||
enqueue({ data: creditNote, error: null })
|
||||
// Insert credit note items
|
||||
enqueue({ data: null, error: null })
|
||||
// Mark creation complete
|
||||
enqueue({ data: null, error: null })
|
||||
// Fetch complete credit note
|
||||
enqueue({ data: { ...creditNote, items: [] }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
const [invoiceInsert] = findCall('invoices', 'insert') ?? []
|
||||
expect(invoiceInsert).toMatchObject({
|
||||
invoice_number: 'KR-SB-2026-17',
|
||||
notes: 'Krediterar faktura SB-2026-17',
|
||||
})
|
||||
expect((invoiceInsert as { invoice_number: string }).invoice_number).not.toContain('null')
|
||||
expect((invoiceInsert as { notes: string }).notes).not.toContain('null')
|
||||
})
|
||||
|
||||
// Defensive path: both numbers null cannot happen for an issued invoice
|
||||
// (DB constraint), but a garbage 'KR-null' must never be minted.
|
||||
it('returns a typed 400 when the original carries no number at all', async () => {
|
||||
const original = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
invoice_number: null as unknown as string,
|
||||
})
|
||||
enqueue({ data: original, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NO_NUMBER')
|
||||
})
|
||||
|
||||
it('returns an existing credit-note draft instead of creating a duplicate', async () => {
|
||||
const original = makeInvoice({ id: VALID_UUID, status: 'sent' })
|
||||
const existing = makeInvoice({
|
||||
|
||||
@@ -301,6 +301,17 @@ async function createCreditNote(
|
||||
})
|
||||
}
|
||||
|
||||
// Self-billed originals have invoice_number null by design (the DB
|
||||
// constraint invoices_self_billed_numbering enforces it); their number
|
||||
// lives in external_invoice_number. Without this fallback the credit note
|
||||
// would be numbered the literal string 'KR-null' (issue #1820). Both null
|
||||
// is impossible for an issued invoice, but refuse defensively rather than
|
||||
// mint a garbage number.
|
||||
const originalRef = originalInvoice.invoice_number ?? originalInvoice.external_invoice_number
|
||||
if (!originalRef) {
|
||||
return errorResponseFromCode('INVOICE_CREDIT_NO_NUMBER', log, { requestId })
|
||||
}
|
||||
|
||||
// Returning the existing credit note makes the action idempotent. A
|
||||
// cancelled, unissued draft is reopened so the deterministic KR number can
|
||||
// be reused without colliding with the company-wide invoice-number key.
|
||||
@@ -325,7 +336,7 @@ async function createCreditNote(
|
||||
status: 'draft',
|
||||
invoice_date: today,
|
||||
due_date: today,
|
||||
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
|
||||
notes: input.reason || `Krediterar faktura ${originalRef}`,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', existingCreditNote.id)
|
||||
@@ -354,7 +365,7 @@ async function createCreditNote(
|
||||
return NextResponse.json({ data: maskEmbeddedCustomer(existingCreditNote) })
|
||||
}
|
||||
|
||||
const creditNoteNumber = `KR-${originalInvoice.invoice_number}`
|
||||
const creditNoteNumber = `KR-${originalRef}`
|
||||
|
||||
const { data: creditNote, error: creditNoteError } = await supabase
|
||||
.from('invoices')
|
||||
@@ -389,7 +400,7 @@ async function createCreditNote(
|
||||
: 0,
|
||||
deduction_personnummer_encrypted: originalInvoice.deduction_personnummer_encrypted ?? null,
|
||||
deduction_personnummer_last4: originalInvoice.deduction_personnummer_last4 ?? null,
|
||||
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
|
||||
notes: input.reason || `Krediterar faktura ${originalRef}`,
|
||||
credited_invoice_id: input.credited_invoice_id,
|
||||
// Copy the original's dimension bag so the credit-note verifikat nets
|
||||
// against the same dimension cells in reports (dimensions PR7).
|
||||
|
||||
Reference in New Issue
Block a user