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
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user