diff --git a/DECISIONS.md b/DECISIONS.md index a4e4a6a3..9fd5cc7d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1171,4 +1171,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-22] Per-company invoice sending domains are gated by a manually granted capability (custom_sender_domain), deliberately NOT in PAID_CAPABILITIES: the opt-in must not be trial-seeded or written by the Stripe subscription sync, and non-grantees must see an unchanged invoicing settings page (the section hides on the 403 capability_blocked envelope). The sending-domain module has no Resend orphan-adoption path (a name that already exists is a 409), because the same Resend account holds the platform's own outbound domain. The delivery log was left untouched (no from_address column): adding it would re-open the hardened invoice_deliveries evidence triggers/redaction paths for a nice-to-have, and the log already measures delivered/bounced per send. [2026-08-22] company_sending_domains verification state (domain, status, resend_domain_id, dns_records, verified_at, last_checked_at) is service-role only via a BEFORE trigger keyed on the JWT role claim; tenant JWTs may only open a pending claim and edit sender_local_part/sender_name/enabled. Skeptic refutation: RLS alone let a granted admin insert {domain: platform sender domain, status: verified} through PostgREST and send invoice mail as the platform. The claim/verify helpers therefore take a separate service-role writer for those columns. Second refutation: a domain Resend later flips to failed made every invoice send for that company fail; the Resend adapter now retries once as the platform sender when an explicit company From is rejected (nothing was sent on the rejected attempt, so the retry cannot double-send). [2026-08-22] Sending-domain verification writes bind by (id, company_id, domain, resend_domain_id IS NULL) and verify/webhook compare Resend's domain name with the row before writing verified; resolveInvoiceSender additionally refuses reserved platform domains and non-hostnames at send time. Skeptic re-check: a tenant could delete and re-insert its pending row under the same id with a reserved domain during the claim's Resend round-trip (TOCTOU), and the service-role writer updated by id alone. Defense in depth over a single gate. +[2026-08-24] Issue #1820 self-billed credit fix: creditConfirmNumber()/originalRef fall back invoice_number -> external_invoice_number (typed 400 INVOICE_CREDIT_NO_NUMBER if both null) instead of relaxing the DB numbering constraint or dropping the type-the-number confirm step; the confirm step stays (dropping it is a founder call). The invoice-date Forval chip surfaces in ALL editor modes, not only self-billed: the silent today-default exists in every mode and the chip line already carries the due date. In self-billed mode fakturadatum + mottagningsdatum render uncollapsed next to the external number (transcription fields, not defaults); the panel rows are hidden there because registering the same RHF field twice desyncs the inputs. The v1 credit route's existing id-slice fallback was left unchanged (public API behavior). [2026-08-24] No-IBAN reconnect pairing (issue #1709) uses only per-currency exactly-one-each-side elimination, deliberately WITHOUT name equality: ASPSPs reformat product names between consents, so requiring it would silently disable the fix for the banks that need it, while the one-per-currency guard already bounds a mis-pair to skipping rows whose account+date+amount+occurrence all collide. upsertFromPsd2 needed no change: its explicit reuse_cash_account_id promote path already covers a same-connection holder, so the fix only names the paired row from the callback. diff --git a/app/(dashboard)/invoices/[id]/credit/page.tsx b/app/(dashboard)/invoices/[id]/credit/page.tsx index bfccab0d..8cff6be7 100644 --- a/app/(dashboard)/invoices/[id]/credit/page.tsx +++ b/app/(dashboard)/invoices/[id]/credit/page.tsx @@ -21,6 +21,7 @@ import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog' import { useCompany, useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode' +import { creditConfirmNumber } from '@/lib/invoices/display' import type { Invoice, InvoiceItem, Customer } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -97,7 +98,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id: } setInvoice(data as InvoiceWithRelations) - setReason(t('reason_default', { number: data.invoice_number ?? '' })) + setReason(t('reason_default', { number: creditConfirmNumber(data) ?? '' })) setIsLoading(false) } @@ -184,7 +185,11 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id: } } - const confirmMismatch = Boolean(confirmText) && confirmText !== invoice.invoice_number + // Self-billed invoices have invoice_number null by design; the confirm + // number falls back to the counterparty's external number, the one the + // user actually sees on the invoice (issue #1820). + const confirmNumber = creditConfirmNumber(invoice) + const confirmMismatch = Boolean(confirmText) && confirmText !== confirmNumber return (
@@ -217,7 +222,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
{/* data-ph-mask: the kicker carries the invoice number */}

- {t('subtitle', { number: invoice.invoice_number ?? '' })} + {t('subtitle', { number: confirmNumber ?? '' })}

{t('warning_title')} @@ -225,7 +230,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id: {/* Original invoice: read-only context as plain rows */} - {invoice.invoice_number} + {confirmNumber} {formatDate(invoice.invoice_date)} @@ -241,7 +246,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id: aside={ // data-ph-mask: the credit note number derives from the invoice number - {t('preview_card_description', { number: invoice.invoice_number ?? '' })} + {t('preview_card_description', { number: confirmNumber ?? '' })} } > @@ -339,15 +344,15 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id: setConfirmText(e.target.value)} - placeholder={invoice.invoice_number ?? ''} - disabled={!invoice.invoice_number} + placeholder={confirmNumber ?? ''} + disabled={!confirmNumber} className={cn( // ph-no-capture: the placeholder carries the invoice number, and // replay masking covers input values, not attributes. @@ -367,8 +372,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id: onClick={handleSubmit} disabled={ isSubmitting || - !invoice.invoice_number || - confirmText !== invoice.invoice_number || + !confirmNumber || + confirmText !== confirmNumber || !canWrite } title={!canWrite ? t('viewer_disabled_tooltip') : undefined} diff --git a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts index 9aaee87e..1aed887f 100644 --- a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts @@ -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', diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index 18b674ab..6e7e2717 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -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' diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 046c60a3..ad5f54de 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -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 diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index b69b9af6..c97a0251 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -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({ diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index 9c271ebd..6b85954b 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -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). diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index 912223db..7486f78a 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -1521,6 +1521,12 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat function focusSettingsField( name: 'invoice_date' | 'due_date' | 'received_date' | 'payment_link_url', ) { + // In self-billed mode fakturadatum and mottagningsdatum render uncollapsed + // next to the external number: focus directly, no panel to expand. + if (isSelfBilled && (name === 'invoice_date' || name === 'received_date')) { + setFocus(name) + return + } // The field lives in the collapsed Förval panel: expand first, focus once // the panel is visible (focus() is a no-op inside visibility: hidden). setSettingsOpen(true) @@ -1884,6 +1890,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat return chip.documentType === 'proforma' ? t('doctype_proforma') : t('doctype_delivery_note') case 'currency': return t('chip_currency', { currency: chip.currency }) + case 'invoice_date': + return t('chip_invoice_date', { date: chip.date }) case 'due_days': return t('chip_due_days', { days: chip.days, date: chip.date }) case 'due_date': @@ -2044,6 +2052,35 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat + {/* The counterparty's issue date and our received date are + mandatory transcription fields, not defaults: keep them + visible instead of collapsed into Förval, where the + silent today-default registered wrong dates on immutable + self-billed invoices (issue #1820). */} +
+ + + {errors.invoice_date && ( +

{errors.invoice_date.message}

+ )} +
+
+ + + {errors.received_date && ( +

{errors.received_date.message}

+ )} +
)} @@ -2800,22 +2837,28 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat /> -
- -
- - {errors.invoice_date && ( -

{errors.invoice_date.message}

- )} + {/* Self-billed mode renders fakturadatum and mottagningsdatum + uncollapsed next to the external number instead: they are + transcription fields there, and registering the same RHF + field twice would desync the inputs. */} + {!isSelfBilled && ( +
+ +
+ + {errors.invoice_date && ( +

{errors.invoice_date.message}

+ )} +
-
+ )}
- {isSelfBilled && ( -
- -
- - {errors.received_date && ( -

{errors.received_date.message}

- )} -
-
- )} - {watchDocumentType === 'invoice' && !isSelfBilled && (
diff --git a/components/invoices/__tests__/invoice-editor-flow.test.ts b/components/invoices/__tests__/invoice-editor-flow.test.ts index bad2765d..06b0e928 100644 --- a/components/invoices/__tests__/invoice-editor-flow.test.ts +++ b/components/invoices/__tests__/invoice-editor-flow.test.ts @@ -173,13 +173,30 @@ function chipsInput(overrides: Partial = {}): ForvalChipsInput } describe('deriveForvalChips', () => { - it('shows only currency and due terms for an all-default invoice', () => { + it('shows currency, invoice date and due terms for an all-default invoice', () => { expect(deriveForvalChips(chipsInput())).toEqual([ { kind: 'currency', currency: 'SEK' }, + { kind: 'invoice_date', date: '2026-08-17' }, { kind: 'due_days', days: 30, date: '2026-09-16' }, ]) }) + // Issue #1820: the invoice date silently defaulted to today inside the + // collapsed Förval panel; the chip makes the value visible in every mode. + it('surfaces the invoice date as a chip, and skips it while unset', () => { + expect(deriveForvalChips(chipsInput())).toContainEqual({ + kind: 'invoice_date', + date: '2026-08-17', + }) + expect(deriveForvalChips(chipsInput({ isSelfBilled: true }))).toContainEqual({ + kind: 'invoice_date', + date: '2026-08-17', + }) + expect( + deriveForvalChips(chipsInput({ invoiceDate: '' })).find((c) => c.kind === 'invoice_date'), + ).toBeUndefined() + }) + it('surfaces a deviating document type first', () => { expect(deriveForvalChips(chipsInput({ documentType: 'proforma' }))[0]).toEqual({ kind: 'doc_type', @@ -225,7 +242,7 @@ describe('deriveForvalChips', () => { }) }) - it('reduces to currency, due and received for self-billed mode', () => { + it('reduces to currency, invoice date, due and received for self-billed mode', () => { const chips = deriveForvalChips( chipsInput({ isSelfBilled: true, @@ -239,6 +256,7 @@ describe('deriveForvalChips', () => { ) expect(chips).toEqual([ { kind: 'currency', currency: 'SEK' }, + { kind: 'invoice_date', date: '2026-08-17' }, { kind: 'due_days', days: 30, date: '2026-09-16' }, { kind: 'received', date: '2026-08-15' }, ]) diff --git a/components/invoices/invoice-editor-flow.ts b/components/invoices/invoice-editor-flow.ts index 449781f1..40c50cd3 100644 --- a/components/invoices/invoice-editor-flow.ts +++ b/components/invoices/invoice-editor-flow.ts @@ -115,6 +115,7 @@ export function deriveNextStep(input: NextStepInput): NextStep { export type ForvalChip = | { kind: 'doc_type'; documentType: 'proforma' | 'delivery_note' } | { kind: 'currency'; currency: string } + | { kind: 'invoice_date'; date: string } | { kind: 'due_days'; days: number; date: string } | { kind: 'due_date'; date: string } | { kind: 'received'; date: string } @@ -152,6 +153,13 @@ export function deriveForvalChips(input: ForvalChipsInput): ForvalChip[] { chips.push({ kind: 'doc_type', documentType: input.documentType }) } chips.push({ kind: 'currency', currency: input.currency }) + // The invoice date always surfaces: it silently defaults to today inside + // the collapsed panel, and especially in self-billed mode (where the + // counterparty's issue date must be transcribed) an invisible default + // registers wrong invoices (issue #1820). + if (input.invoiceDate) { + chips.push({ kind: 'invoice_date', date: input.invoiceDate }) + } if (input.dueDate) { const days = dueDays(input.invoiceDate, input.dueDate) if (days !== null && days >= 0) chips.push({ kind: 'due_days', days, date: input.dueDate }) diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index e9d90da9..7f14feaa 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -926,6 +926,11 @@ const INVOICE: Record = { message_sv: 'Endast skickade, betalda eller förfallna fakturor kan krediteras.', message_en: 'Only sent, paid, or overdue invoices can be credited.', }, + INVOICE_CREDIT_NO_NUMBER: { + httpStatus: 400, + message_sv: 'Ursprungsfakturan saknar fakturanummer och kan inte krediteras.', + message_en: 'The original invoice has no invoice number and cannot be credited.', + }, INVOICE_CREDIT_ISSUE_INCOMPLETE: { httpStatus: 500, message_sv: diff --git a/lib/invoices/__tests__/display.test.ts b/lib/invoices/__tests__/display.test.ts index 093db3b0..31c1669c 100644 --- a/lib/invoices/__tests__/display.test.ts +++ b/lib/invoices/__tests__/display.test.ts @@ -1,5 +1,24 @@ import { describe, it, expect } from 'vitest' -import { isTextLikeLine } from '@/lib/invoices/display' +import { creditConfirmNumber, isTextLikeLine } from '@/lib/invoices/display' + +describe('creditConfirmNumber', () => { + it('uses our own invoice number when present', () => { + expect( + creditConfirmNumber({ invoice_number: 'F-2026010', external_invoice_number: null }), + ).toBe('F-2026010') + }) + + it('falls back to the external number for self-billed invoices (issue #1820)', () => { + expect( + creditConfirmNumber({ invoice_number: null, external_invoice_number: 'SB-2026-17' }), + ).toBe('SB-2026-17') + }) + + it('returns null when the invoice carries no number at all', () => { + expect(creditConfirmNumber({ invoice_number: null, external_invoice_number: null })).toBeNull() + expect(creditConfirmNumber({})).toBeNull() + }) +}) describe('isTextLikeLine', () => { it('is true for explicit text rows regardless of amounts', () => { diff --git a/lib/invoices/display.ts b/lib/invoices/display.ts index 6a9f7aba..0baaf520 100644 --- a/lib/invoices/display.ts +++ b/lib/invoices/display.ts @@ -17,6 +17,21 @@ export function invoiceDisplayNumber(invoice: { return invoice.invoice_number ?? invoice.external_invoice_number ?? INVOICE_NUMBER_DRAFT_LABEL } +/** + * The number a user must type to confirm crediting an invoice. Regular + * invoices confirm with their own `invoice_number`; self-billed invoices have + * `invoice_number` null by design, so the counterparty's + * `external_invoice_number` (the number shown everywhere in the UI) is the + * one to type. Null when the invoice carries no number at all: the credit + * flow must stay disabled then. + */ +export function creditConfirmNumber(invoice: { + invoice_number?: string | null + external_invoice_number?: string | null +}): string | null { + return invoice.invoice_number ?? invoice.external_invoice_number ?? null +} + /** * True when an invoice line should render as a pure text row: description * only, no quantity/unit/price/amount columns. Explicit text rows diff --git a/messages/en.json b/messages/en.json index a555eeef..578a4098 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3655,6 +3655,7 @@ "remove_row_aria_named": "Remove line: {description}", "validation_price_invalid": "Enter a unit price", "chip_currency": "Currency {currency}", + "chip_invoice_date": "Invoice date {date}", "chip_due_days": "Due in {days} days ({date})", "chip_due_date": "Due {date}", "chip_received": "Received {date}", diff --git a/messages/sv.json b/messages/sv.json index a400dd61..41cb13a1 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3655,6 +3655,7 @@ "remove_row_aria_named": "Ta bort rad: {description}", "validation_price_invalid": "Ange ett à-pris", "chip_currency": "Valuta {currency}", + "chip_invoice_date": "Fakturadatum {date}", "chip_due_days": "Förfaller {days} dagar ({date})", "chip_due_date": "Förfaller {date}", "chip_received": "Mottagen {date}",