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:
Jakob Wennberg
2026-08-24 13:18:57 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 21c63b8b12
commit 78525bd391
15 changed files with 295 additions and 54 deletions
+58 -34
View File
@@ -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
<Label>{ts('agreement_ref_label')}</Label>
<Input placeholder={ts('agreement_ref_placeholder')} {...register('self_billing_agreement_ref')} />
</div>
{/* 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). */}
<div className="space-y-2">
<Label>{ts('invoice_date_label')}<RequiredMark /></Label>
<Input
type="date"
{...register('invoice_date')}
aria-required="true"
className="tabular-nums"
/>
{errors.invoice_date && (
<p className="text-sm text-destructive">{errors.invoice_date.message}</p>
)}
</div>
<div className="space-y-2">
<Label>{ts('received_date_label')}<RequiredMark /></Label>
<Input
type="date"
{...register('received_date')}
aria-required="true"
className="tabular-nums"
/>
{errors.received_date && (
<p className="text-sm text-destructive">{errors.received_date.message}</p>
)}
</div>
</div>
)}
</section>
@@ -2800,22 +2837,28 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
/>
</div>
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">
{t('invoice_date_label')}<RequiredMark />
</Label>
<div>
<Input
type="date"
{...register('invoice_date')}
aria-required="true"
className="h-8 w-40 text-[13px] tabular-nums"
/>
{errors.invoice_date && (
<p className="mt-1 text-xs text-destructive">{errors.invoice_date.message}</p>
)}
{/* 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 && (
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">
{t('invoice_date_label')}<RequiredMark />
</Label>
<div>
<Input
type="date"
{...register('invoice_date')}
aria-required="true"
className="h-8 w-40 text-[13px] tabular-nums"
/>
{errors.invoice_date && (
<p className="mt-1 text-xs text-destructive">{errors.invoice_date.message}</p>
)}
</div>
</div>
</div>
)}
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">
@@ -2834,25 +2877,6 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</div>
</div>
{isSelfBilled && (
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">
{ts('received_date_label')}<RequiredMark />
</Label>
<div>
<Input
type="date"
{...register('received_date')}
aria-required="true"
className="h-8 w-40 text-[13px] tabular-nums"
/>
{errors.received_date && (
<p className="mt-1 text-xs text-destructive">{errors.received_date.message}</p>
)}
</div>
</div>
)}
{watchDocumentType === 'invoice' && !isSelfBilled && (
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">{t('delivery_date_label')}</Label>
@@ -173,13 +173,30 @@ function chipsInput(overrides: Partial<ForvalChipsInput> = {}): 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' },
])
@@ -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 })