c62d00bcb3
Reviewing an invoice or a verifikat bilaga meant saving a file and opening it from the Downloads folder (user request, christian@odinaero.se 2026-07-25). - GET /api/invoices/[id]/pdf accepts ?disposition=inline and serves the PDF for in-browser review; anything else keeps the download behaviour every existing caller relies on. The filename still travels in the header, so the browser viewer's own save action produces the same name as the download button, and nosniff pins the content type. - The invoice detail page gets a "Förhandsgranska" action next to "Ladda ner PDF". It resolves the document through the same resolveInvoicePdfSource path as the download, so preview cannot become the shortcut that presents a re-render as the invoice the customer received: the archived delivery wins, a re-render is shown with its caveat, and an unreadable delivery history still asks instead of guessing. The archive dialog now remembers whether the user asked to view or to save, and its fallback does that. - DocumentViewButton (supplier-invoice underlag, staged agent previews) points at the existing /api/documents/:id/inline proxy, so bilagor render in the browser. Navigation now happens straight from the click, so the signed-URL fetch and its popup-blocker workaround are gone. - The three re-render caveat strings and the two archive-dialog descriptions lose their "you downloaded" wording so they stay true for both actions; five new keys in sv + en. Tests: route cases for the default, inline and unknown disposition values; invoiceRerenderUrl cases for both modes and id encoding. npm test 11364 passed, lint 0 errors. Button row screenshotted against the design system (pill outline, Eye icon) via a temporary sandbox route. Closes #1190 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
171 lines
6.7 KiB
TypeScript
171 lines
6.7 KiB
TypeScript
/**
|
|
* Which PDF the invoice detail page may hand the user, and what it must be
|
|
* called when it does.
|
|
*
|
|
* A sent invoice has an archived delivery: the exact file the customer
|
|
* received, captured before the provider call and kept in the WORM archive
|
|
* (`invoice_deliveries.document_attachment_id`, held down by
|
|
* `block_sent_invoice_document_deletion`). That file is räkenskapsinformation:
|
|
* BFL 7 kap requires it preserved for 7 years, in varaktigt läsbart skick, and
|
|
* producible on request as a faithful reproduction.
|
|
*
|
|
* `/api/invoices/[id]/pdf` does NOT return that file. It re-renders from
|
|
* today's invoice row, today's customer row, today's company_settings and
|
|
* today's branding/logo. Whenever any of those changed since the send, the
|
|
* re-render is a different document. It is a perfectly legitimate document in
|
|
* its own right; it is simply not the one that was sent, and it must never be
|
|
* served as though it were.
|
|
*
|
|
* Hence three outcomes that are never allowed to collapse into each other:
|
|
*
|
|
* - `archived`: the delivered file exists. Serve it. Nothing to explain.
|
|
* - `unavailable`: the delivery history could not be read, so whether an
|
|
* archived copy exists is unknown. Substituting a re-render
|
|
* here is the bug this module exists to prevent: the caller
|
|
* must ask the user, not guess on their behalf.
|
|
* - `rerender`: no archived copy can be served, and `reason` says why, so
|
|
* the caller can label what it hands over instead of
|
|
* presenting it as the sent document.
|
|
*/
|
|
|
|
/** Delivery statuses whose CHECK constraint guarantees `sent_at IS NOT NULL`. */
|
|
const COMPLETED_DELIVERY_STATUSES = ['sent', 'marked_sent'] as const
|
|
|
|
/**
|
|
* Invoice statuses that make an archived copy plausible enough to stop for when
|
|
* the delivery history cannot be read. A draft was never sent, and `cancelled`
|
|
* is reachable only for a draft (`DELETE /api/invoices/[id]` refuses anything
|
|
* else) or for a proforma, which is not a faktura and not
|
|
* räkenskapsinformation. Blocking those behind a dialog would trade a real cost
|
|
* (a draft you cannot download because an unrelated request failed) for a
|
|
* near-empty risk, so the gate stays narrow.
|
|
*
|
|
* This list only governs the unreadable case. When the history DID load it is
|
|
* the data, not the status, that decides: an emailed proforma that was later
|
|
* makulerad still has its archived copy and still gets served it.
|
|
*/
|
|
const POSSIBLY_DELIVERED_STATUSES = [
|
|
'sent',
|
|
'paid',
|
|
'partially_paid',
|
|
'overdue',
|
|
'credited',
|
|
] as const
|
|
|
|
export interface InvoicePdfDelivery {
|
|
id: string
|
|
status: 'pending' | 'sent' | 'failed' | 'marked_sent'
|
|
document_attachment_id: string | null
|
|
sent_at: string | null
|
|
}
|
|
|
|
export type InvoicePdfRerenderReason =
|
|
/** Never sent. The re-render is the only document there has ever been. */
|
|
| 'not_sent_yet'
|
|
/** Latest completed delivery was a manual mark: Accounted never held that file. */
|
|
| 'sent_outside_accounted'
|
|
/** Sent, but no archived delivery exists (invoice predates delivery history). */
|
|
| 'no_archived_copy'
|
|
/** An archived copy may exist but could not be retrieved; the user chose this. */
|
|
| 'archive_unreachable'
|
|
|
|
export type InvoicePdfSource =
|
|
| {
|
|
kind: 'archived'
|
|
url: string
|
|
deliveryId: string
|
|
sentAt: string | null
|
|
}
|
|
| {
|
|
kind: 'rerender'
|
|
url: string
|
|
reason: InvoicePdfRerenderReason
|
|
}
|
|
| {
|
|
kind: 'unavailable'
|
|
reason: 'delivery_history_unreadable'
|
|
}
|
|
|
|
/**
|
|
* The re-render endpoint. `inline` asks it to serve the PDF for in-browser
|
|
* 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 {
|
|
const base = `/api/invoices/${encodeURIComponent(invoiceId)}/pdf`
|
|
return options?.inline ? `${base}?disposition=inline` : base
|
|
}
|
|
|
|
/**
|
|
* Decide which file the download button may fetch.
|
|
*
|
|
* `deliveries` is the list as returned by `/api/invoices/[id]/deliveries`,
|
|
* newest first (`list_invoice_delivery_summaries` orders by `created_at DESC`).
|
|
* `deliveriesLoaded` must be false whenever that request failed or returned an
|
|
* unexpected shape: an empty array from a failed read is indistinguishable from
|
|
* a genuinely empty history, and treating the two alike is exactly what turns a
|
|
* network blip into a substituted document.
|
|
*/
|
|
export function resolveInvoicePdfSource(input: {
|
|
invoiceId: string
|
|
invoiceStatus: string
|
|
deliveriesLoaded: boolean
|
|
deliveries: InvoicePdfDelivery[]
|
|
}): InvoicePdfSource {
|
|
const rerenderUrl = invoiceRerenderUrl(input.invoiceId)
|
|
const couldHaveBeenDelivered = (POSSIBLY_DELIVERED_STATUSES as readonly string[]).includes(
|
|
input.invoiceStatus,
|
|
)
|
|
|
|
// The history is the record of what was sent, so when it is readable it
|
|
// decides on its own. Reaching for the status first would discard a real
|
|
// archived copy behind a status that merely makes one unlikely.
|
|
if (!input.deliveriesLoaded) {
|
|
return couldHaveBeenDelivered
|
|
? { kind: 'unavailable', reason: 'delivery_history_unreadable' }
|
|
: { kind: 'rerender', url: rerenderUrl, reason: 'not_sent_yet' }
|
|
}
|
|
|
|
const latestCompleted = input.deliveries.find((delivery) =>
|
|
(COMPLETED_DELIVERY_STATUSES as readonly string[]).includes(delivery.status),
|
|
)
|
|
|
|
if (latestCompleted?.status === 'sent' && latestCompleted.document_attachment_id) {
|
|
return {
|
|
kind: 'archived',
|
|
url: `/api/documents/${encodeURIComponent(latestCompleted.document_attachment_id)}/inline`,
|
|
deliveryId: latestCompleted.id,
|
|
sentAt: latestCompleted.sent_at,
|
|
}
|
|
}
|
|
|
|
if (latestCompleted?.status === 'marked_sent') {
|
|
return { kind: 'rerender', url: rerenderUrl, reason: 'sent_outside_accounted' }
|
|
}
|
|
|
|
// No completed delivery in a history that loaded fine. For a status that was
|
|
// never sent there is no sent document to be confused with; otherwise the
|
|
// invoice went out before delivery history existed.
|
|
return {
|
|
kind: 'rerender',
|
|
url: rerenderUrl,
|
|
reason:
|
|
latestCompleted !== undefined || couldHaveBeenDelivered
|
|
? 'no_archived_copy'
|
|
: 'not_sent_yet',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* What must be disclosed about the file the user just received, or null when it
|
|
* may be presented plainly as the invoice. Only the archived delivery earns
|
|
* that, plus a never-sent invoice, where there is no sent document to be
|
|
* confused with in the first place.
|
|
*/
|
|
export function invoiceDocumentCaveat(
|
|
source: InvoicePdfSource,
|
|
): Exclude<InvoicePdfRerenderReason, 'not_sent_yet'> | null {
|
|
if (source.kind !== 'rerender') return null
|
|
return source.reason === 'not_sent_yet' ? null : source.reason
|
|
}
|