Å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.
120 lines
4.7 KiB
TypeScript
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)
|
|
}
|
|
}
|