fix(invoices): say what is missing when an invoice preview cannot be rendered (#2303)

* fix(invoices): say what is missing when an invoice preview cannot be rendered

The PDF route already refuses with a structured envelope that names exactly
what the invoice lacks (no bankgiro, plusgiro, Swish or bank account for a
SEK invoice; no IBAN account for a foreign currency) and where to add it.
Two clients threw that away:

- The settings preview dialog (Inställningar -> Fakturering -> Förhandsvisa
  faktura) wrapped the envelope's inner object in new Error(), which
  stringified it to "[object Object]" and left only the generic "Kunde inte
  hantera fakturan. Försök igen." fallback. The parsed body now goes to the
  error mapper whole, with the invoice context and status.

- The invoice page's Förhandsgranska navigated a new tab straight to the
  re-render URL, so a 400 showed the raw JSON in that tab. The tab is now
  opened blank inside the click's activation window, the PDF is fetched
  first, and the tab gets the PDF as a blob URL or is closed again with the
  refusal in a toast. The archived delivery copy keeps the direct open.
  Ladda ner on the same page had a fixed "Kunde inte generera PDF" for
  re-render refusals and now maps the body the same way.

Regression test on the mapper covers the exact call shape the two surfaces
use and pins the old mangled shape as the fallback it produced.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUdZPW46a16GWUdgt2qSZA

* fix(invoices): probe the PDF route before opening the preview tab

Resolves the review findings on the first push in one pass.

Skeptic (correctness): the archived-copy branch still called window.open
with 'noopener', which returns null by spec even on success, so every
successful archived preview also fired the "popup blocked" toast (#1613
had the same defect). Both branches now go through openDeferredTab, which
opens with a real handle and severs the opener itself.

Skeptic (regression): serving the re-render as a blob URL lost the
Content-Disposition filename and gave the tab an address that dies on
reload. The route gains ?probe=1, which runs every refusal check and
answers 204 without rendering; the page probes first, shows a refusal as a
toast, and otherwise points the tab at the real inline URL. Filename,
reload and the single render are all kept. The blob URL is gone, which
also settles the compliance swarm's noopener and unrevoked-blob notes and
CodeRabbit's revoke request.

CodeRabbit: the probe fetch is bounded by AbortSignal.timeout so a stalled
route cannot leave a blank tab open, and the network-error mapper now
receives the active locale and invoice context.

Tests: route probe (204 without render, same 400 envelope as the render,
unknown value ignored) and the URL helper's probe flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUdZPW46a16GWUdgt2qSZA

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-05 11:55:45 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 34bf5a7387
commit 9418de585f
9 changed files with 207 additions and 13 deletions
@@ -605,6 +605,19 @@ describe('getErrorMessage: INVOICE_SEND_PAYMENT_ACCOUNT_MISSING (#2126)', () =>
const unknown = getErrorMessage(envelope('JPY'), { statusCode: 400 })
expect(unknown).toBe(getErrorEntry('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')!.message_sv)
})
// The preview surfaces (settings dialog, invoice page) hand the whole
// parsed body to the mapper with the invoice context. The context fallback
// must not shadow the specific text, and the stringified-object shape the
// old `new Error(body.error)` produced must be recognisably the bug.
it('preview surfaces: whole body + invoice context still yields the specific text', () => {
const msg = getErrorMessage(envelope('SEK'), { statusCode: 400, context: 'invoice', locale: 'sv' })
expect(msg).toContain('bankgiro')
expect(msg).not.toBe('Kunde inte hantera fakturan. Försök igen.')
const mangled = getErrorMessage(new Error(String(envelope('SEK').error)), { context: 'invoice' })
expect(mangled).toBe('Kunde inte hantera fakturan. Försök igen.')
})
})
@@ -232,6 +232,18 @@ describe('invoiceRerenderUrl', () => {
'/api/invoices/a%2Fb/pdf?disposition=inline',
)
})
// The preview probes the route before pointing the tab at it, so a refusal
// (no payment account for the currency) is shown in the app instead of as
// raw JSON in the new tab.
it('adds the probe flag next to the inline disposition', () => {
expect(invoiceRerenderUrl(INVOICE_ID, { inline: true, probe: true })).toBe(
`/api/invoices/${INVOICE_ID}/pdf?disposition=inline&probe=1`,
)
expect(invoiceRerenderUrl(INVOICE_ID, { probe: true })).toBe(
`/api/invoices/${INVOICE_ID}/pdf?probe=1`,
)
})
})
// #1693: the betalningsbekräftelse is always a re-render and always says so,
+13 -2
View File
@@ -97,9 +97,20 @@ export type InvoicePdfSource =
* review instead of a download (#1190); the archived-delivery URL below is
* already an inline proxy, so both source kinds can be previewed the same way.
*/
export function invoiceRerenderUrl(invoiceId: string, options?: { inline?: boolean }): string {
export function invoiceRerenderUrl(
invoiceId: string,
options?: { inline?: boolean; probe?: boolean },
): string {
const base = `/api/invoices/${encodeURIComponent(invoiceId)}/pdf`
return options?.inline ? `${base}?disposition=inline` : base
const params = new URLSearchParams()
if (options?.inline) params.set('disposition', 'inline')
// `probe=1` runs every refusal check the render would run and answers 204
// without rendering. The preview asks this first so a refusal can be shown
// as a message in the app, and the tab is then pointed at the real inline
// URL, keeping the filename and a reloadable address.
if (options?.probe) params.set('probe', '1')
const query = params.toString()
return query ? `${base}?${query}` : base
}
/**