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
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user