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
parent 21c63b8b12
commit 78525bd391
15 changed files with 295 additions and 54 deletions
+1
View File
@@ -1171,4 +1171,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+15 -10
View File
@@ -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 (
<div className="space-y-8 stagger-enter">
@@ -217,7 +222,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
</div>
{/* data-ph-mask: the kicker carries the invoice number */}
<p data-ph-mask="" className="mt-1 text-sm text-muted-foreground">
{t('subtitle', { number: invoice.invoice_number ?? '' })}
{t('subtitle', { number: confirmNumber ?? '' })}
</p>
<AttnLine className="mt-3">{t('warning_title')}</AttnLine>
</div>
@@ -225,7 +230,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
{/* Original invoice: read-only context as plain rows */}
<DetailSection kicker={t('original_card_title')}>
<DefRow label={t('invoice_number_label')}>
<span className="tabular-nums">{invoice.invoice_number}</span>
<span className="tabular-nums">{confirmNumber}</span>
</DefRow>
<DefRow label={t('date_label')}>
<span className="tabular-nums">{formatDate(invoice.invoice_date)}</span>
@@ -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
<span data-ph-mask="" className="text-[11px] tabular-nums text-muted-foreground">
{t('preview_card_description', { number: invoice.invoice_number ?? '' })}
{t('preview_card_description', { number: confirmNumber ?? '' })}
</span>
}
>
@@ -339,15 +344,15 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
<Label htmlFor="confirm-invoice-number" className="block text-sm font-normal leading-5 text-muted-foreground">
{t('confirm_card_description_1')}
{/* data-ph-mask: the invoice number is user data */}
<span data-ph-mask="" className="font-mono font-semibold text-foreground">{invoice.invoice_number}</span>
<span data-ph-mask="" className="font-mono font-semibold text-foreground">{confirmNumber}</span>
{t('confirm_card_description_2')}
</Label>
<Input
id="confirm-invoice-number"
value={confirmText}
onChange={(e) => 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}
@@ -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',
+6 -2
View File
@@ -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'
+7 -2
View File
@@ -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
+84
View File
@@ -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({
+14 -3
View File
@@ -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).
+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 })
+5
View File
@@ -926,6 +926,11 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
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:
+20 -1
View File
@@ -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', () => {
+15
View File
@@ -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
+1
View File
@@ -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}",
+1
View File
@@ -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}",