Files
accounted/lib/documents/link-documents.ts
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

141 lines
5.3 KiB
TypeScript

/**
* Link underlag (receipts, invoices, other räkenskapsinformation) to a
* journal entry via POST /api/documents/{id}/link.
*
* Why this is a shared helper and not an inline loop per dialog:
*
* BFL 5 kap 7 § requires a verifikation to carry a reference to the underlag
* it rests on, and BFL 7 kap requires that underlag to be archived with the
* verifikat for 7 years. By the time this runs the verifikat is already
* committed (the voucher number is assigned atomically), so a failed link
* cannot be rolled back: it leaves a verifikat whose underlag is missing from
* the archive. That is a compliance defect, and the only way it becomes
* recoverable is if the user is told about it. Callers must therefore never
* report plain success while `failed` is non-empty, and must never discard the
* user's files on a failure.
*
* The contract: this function never throws and never swallows. Every target
* ends up in exactly one of `linked` or `failed`, and a failure carries enough
* (file name, HTTP status, error code) to name the specific documents that did
* not attach.
*/
export interface DocumentLinkTarget {
/** documents.id: the row POST /api/documents/{id}/link is addressed at. */
documentId: string
/** Shown to the user when this document fails to attach. */
fileName?: string | null
/** Inbox item consumed by this link (the "choose from inbox" flow). */
inboxItemId?: string | null
/** Bank transaction the document should also be pinned to. */
transactionId?: string | null
}
export interface DocumentLinkFailure {
documentId: string
fileName: string | null
/** HTTP status, or 0 when the request never produced a response (offline). */
status: number
/** Structured error code from the API envelope, when the server sent one. */
code: string | null
/** Best-effort human-readable reason (envelope message or thrown message). */
reason: string | null
}
export interface LinkDocumentsResult {
/** documentIds that attached, in input order. */
linked: string[]
/** Everything that did not attach. Empty means the underlag is on the books. */
failed: DocumentLinkFailure[]
/** First document that attached: the "first linked doc wins" pin source. */
firstLinkedId: string | null
/** True when nothing failed (also true for an empty target list). */
allLinked: boolean
}
export interface LinkDocumentsOptions {
/** Injectable fetch, for tests. Defaults to the global fetch. */
fetchImpl?: typeof fetch
}
interface ErrorEnvelope {
error?: { code?: string; message?: string; message_en?: string }
}
async function readErrorEnvelope(res: Response): Promise<{ code: string | null; reason: string | null }> {
try {
const body = (await res.json()) as ErrorEnvelope | null
const err = body?.error
return { code: err?.code ?? null, reason: err?.message ?? err?.message_en ?? null }
} catch {
// Non-JSON body (proxy error page, empty 502): the status alone is the
// signal. Still a failure, never a silent success.
return { code: null, reason: null }
}
}
/**
* Link every target to `journalEntryId`, sequentially (input order is the
* "first linked doc wins" order the link route relies on for its transaction
* pin). Resolves with a per-document verdict; never rejects.
*/
export async function linkDocuments(
targets: DocumentLinkTarget[],
journalEntryId: string,
options: LinkDocumentsOptions = {},
): Promise<LinkDocumentsResult> {
// Wrapped rather than assigned so the global fetch keeps its receiver.
const doFetch: typeof fetch =
options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init))
const linked: string[] = []
const failed: DocumentLinkFailure[] = []
for (const target of targets) {
const fileName = target.fileName?.trim() ? target.fileName.trim() : null
try {
const res = await doFetch(`/api/documents/${encodeURIComponent(target.documentId)}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
journal_entry_id: journalEntryId,
...(target.inboxItemId ? { inbox_item_id: target.inboxItemId } : {}),
...(target.transactionId ? { transaction_id: target.transactionId } : {}),
}),
})
if (res.ok) {
linked.push(target.documentId)
continue
}
// A 4xx/5xx here is the exact case the old inline loops missed: the
// request completed, so nothing threw, but the underlag never attached.
const { code, reason } = await readErrorEnvelope(res)
failed.push({ documentId: target.documentId, fileName, status: res.status, code, reason })
} catch (err) {
failed.push({
documentId: target.documentId,
fileName,
status: 0,
code: null,
reason: err instanceof Error ? err.message : null,
})
}
}
return {
linked,
failed,
firstLinkedId: linked[0] ?? null,
allLinked: failed.length === 0,
}
}
/**
* Comma-separated file names for the "these did not attach" message. Falls
* back to the document id so a nameless document is still identifiable: the
* user must always be able to tell WHICH underlag is missing.
*/
export function formatFailedDocumentNames(failures: DocumentLinkFailure[]): string {
return failures.map((f) => f.fileName ?? f.documentId).join(', ')
}