Files
accounted/lib/documents/channel-context-notes.ts
T
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

120 lines
4.7 KiB
TypeScript

/**
* Render the verified human answers captured on a chat-sourced inbox item
* (invoice_inbox_items.channel_context, written by the WhatsApp intake bot)
* as ONE compact Swedish line for the booking notes path.
*
* The rendered string travels through the existing `notes` parameters
* (book-direct, convert, bulk-book via categorize-core) and ends up appended
* to the verifikat description, which caps at 500 chars in
* lib/bookkeeping/transaction-entries.ts. This renderer therefore stays well
* under that: at most CHANNEL_CONTEXT_NOTES_MAX chars, truncating the
* participant list by WHOLE names ("… och 3 till"), never mid-name.
*
* Precedence: representation answers first (Skatteverket's dokumentationskrav
* for representation: deltagare + syfte belong on the verifikat), then the
* sender's explicit note. Both are answers a human typed to a question the bot
* asked, having been told they reach the bookkeeping. The photo caption is the
* weakest signal: nobody was asked for it and nobody reviewed it, so it is
* OFF by default and only renders where a human sees the result before it is
* posted (see ChannelContextNotesOptions.includeCaption).
*
* Core lib: must not import from @/extensions. Deliberately Swedish-only
* output: the verifikat description is a regulatory surface (see
* .claude/rules/i18n.md), not UI chrome.
*/
import type { InboxChannelContext } from '@/types'
/**
* Cap on the rendered line. 220 leaves the description's 500-char cap plenty
* of room for the bank text / supplier prefix it is appended to.
*/
export const CHANNEL_CONTEXT_NOTES_MAX = 220
/** "Anna Berg (Volvo)" with a company, bare "Jakob W" without (the sender
* themselves usually answers without naming their own company). */
export function renderChannelParticipant(p: {
name: string
company: string | null
}): string {
const name = (p.name ?? '').trim()
if (!name) return ''
const company = (p.company ?? '').trim()
return company ? `${name} (${company})` : name
}
function buildLine(
names: string[],
droppedCount: number,
purpose: string | null,
userNote: string | null,
): string {
const parts: string[] = []
if (names.length > 0) {
const suffix = droppedCount > 0 ? ` … och ${droppedCount} till` : ''
parts.push(`Representation: ${names.join(', ')}${suffix}`)
}
if (purpose) parts.push(`Syfte: ${purpose}`)
if (userNote) parts.push(userNote)
return parts.join(' · ')
}
/** Last-resort cap for free text (purpose/note/caption): the whole-name rule
* above governs the participant list; a runaway free-text field is cut with
* an ellipsis instead. Result is always <= CHANNEL_CONTEXT_NOTES_MAX. */
function capFreeText(line: string): string {
if (line.length <= CHANNEL_CONTEXT_NOTES_MAX) return line
return `${line.slice(0, CHANNEL_CONTEXT_NOTES_MAX - 1).trimEnd()}…`
}
export interface ChannelContextNotesOptions {
/**
* Render the raw photo caption when there is neither a representation
* answer nor a sender note. Default false.
*
* Off by default on purpose. The representation answer and the note are
* replies to a question the bot asked, so the sender knew they were writing
* bookkeeping text; the caption is whatever happened to be typed next to a
* photo and nobody reviewed it. Every unattended path (bulk-book, the
* server-side note defaults used by MCP and API callers) writes straight
* into a posted verifikat, which BFL 5 kap 5 § only lets you change through
* a formal rättelse. Pass true only where a human sees the string and can
* edit or delete it before booking: today that is the Bokför direkt dialog
* prefill.
*/
includeCaption?: boolean
}
export function renderChannelContextNotes(
ctx: InboxChannelContext | null | undefined,
options: ChannelContextNotesOptions = {},
): string | null {
if (!ctx) return null
const names = (ctx.representation?.participants ?? [])
.map(renderChannelParticipant)
.filter((n) => n.length > 0)
const purpose = ctx.representation?.purpose?.trim() || null
const userNote = ctx.user_note?.trim() || null
const caption = options.includeCaption ? ctx.caption?.trim() || null : null
// Caption (when allowed) only if there is neither a representation answer
// nor a note.
if (names.length === 0 && !purpose && !userNote) {
return caption ? capFreeText(caption) : null
}
// Full participant list first; drop whole names from the end until the
// line fits. Keeps at least one name so the representation trail never
// degrades to a bare count.
let keep = names.length
for (;;) {
const line = buildLine(names.slice(0, keep), names.length - keep, purpose, userNote)
if (line.length <= CHANNEL_CONTEXT_NOTES_MAX) return line
if (keep > 1) {
keep--
continue
}
return capFreeText(line)
}
}