diff --git a/DECISIONS.md b/DECISIONS.md index 58333a78..1c8ad5a1 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -775,3 +775,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-02] WhatsApp channel tables: whatsapp_messages/conversations/link_codes are RLS-enabled with NO policies (service-role only): rows hold third-party PII (phone hashes, chat text) with no company scope and no v1 UI reader; whatsapp_phone_links is USER-scoped (auth.uid()), not company-scoped, because a phone binding belongs to a person. whatsapp_conversations triaged into ARCHIVE_EXCLUDED_TABLES (bot state, company_id is only a pin; receipts live in document_attachments). check_and_increment_whatsapp_sender_quota is EXECUTE-granted to service_role only (quota-drain lesson from 20260726090000). [2026-08-02] whatsapp-inbox link codes: a bare 6-char body without the AC prefix must contain a digit to count as a code; the ambiguity-free alphabet makes ordinary words ("hejhej") valid code shapes, and greeting text must earn M1, not a confusing "wrong code" M2. Prefixed codes (what the panel and wa.me prefill always send) are never rejected. [2026-08-02] whatsapp-inbox webhook: truly unknown inbound types (reactions, ephemeral, future Meta additions) get silence + a skipped row instead of the M15 unsupported-content reply; answering a thumbs-up reaction with "I only take images and PDF" is noise. M15 stays for explicit content types (video/sticker/location/contacts). +[2026-08-02] Bulk-book notes x channel_context: the shared batch note and the per-item rendered WhatsApp context are JOINED (' · '), not either/or. Bulk booking has no per-item notes review, so a caller-wins rule would silently drop the only copy of the representation documentation; in book-direct/convert the caller DOES see the field (UI prefills the rendered string), so there a supplied value wins and the server default only fills empty/absent notes. +[2026-08-02] renderChannelContextNotes caps at 220 chars by dropping WHOLE participant names ("… och N till", first names kept, min 1); free text (syfte/note/caption) is ellipsis-cut only as a last resort. Caption ranks below representation and user_note because it rides along with the photo rather than answering a question. Output is Swedish-only on purpose: it lands in the verifikat description, a regulatory surface. +[2026-08-03] Notes on chat-sourced items: PRESENCE of the `notes` field decides, not truthiness (supersedes the 2026-08-02 "server default fills empty/absent notes" line). BookDirectlyDialog now always submits `notes`, '' included, and book-direct/convert only default from channel_context when the field is ABSENT. Reason: the dialog prefills the chat note, so a user who reads it, disagrees and deletes it was having it written back onto an immutable verifikat, removable only via rättelse. Kept `notes: z.string().max(2000).optional()` deliberately: `.default('')` or a min(1) would collapse "cleared" and "no opinion" into one value again. +[2026-08-03] renderChannelContextNotes takes { includeCaption } and leaves the photo caption OUT by default. Representation answers and user_note are replies to a question the bot asked; the caption is unreviewed chat text. Bulk-book and the server-side route defaults run with no per-item review (the MCP approval preview deliberately carries no per-item PII), and what they write lands in the immutable verifikat description, so only Bokför direkt (editable field, user reads it first) opts the caption in. +[2026-08-03] invoice_inbox_items moved from ARCHIVE_EXCLUDED_TABLES into the archive dump as a COLUMN PROJECTION (id, created_at, source, status, document_id, matched_transaction_id, created_journal_entry_id, created_supplier_invoice_id, channel_context). Picked over making the verifikat line loss-free: the line caps at 220 chars by design and Skatteverket wants every deltagare, so the full representation answer must survive in the archive a leaving company keeps as its BFL 7-year record. New MasterDataTableSpec.columns keeps the inbox workflow state (email bodies, OCR output, error messages) out; additive only, like denormalize. diff --git a/components/extensions/general/BookDirectlyDialog.tsx b/components/extensions/general/BookDirectlyDialog.tsx index d7c606ed..d1df8345 100644 --- a/components/extensions/general/BookDirectlyDialog.tsx +++ b/components/extensions/general/BookDirectlyDialog.tsx @@ -32,13 +32,18 @@ import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import { resolveAccount } from '@/lib/cash-accounts/resolve-account' -import type { BASAccount, BookingTemplateLibrary, CashAccount, FiscalPeriod, InvoiceExtractionResult } from '@/types' +import { renderChannelContextNotes } from '@/lib/documents/channel-context-notes' +import type { BASAccount, BookingTemplateLibrary, CashAccount, FiscalPeriod, InboxChannelContext, InvoiceExtractionResult } from '@/types' interface InboxItem { id: string document_id: string | null matched_transaction_id: string | null extracted_data: InvoiceExtractionResult | null + // Verified human answers from the delivering chat (WhatsApp items): + // prefills the notes field so representation deltagare + syfte reach the + // verifikat. Absent for email/upload items. + channel_context?: InboxChannelContext | null } interface PickerTransaction { @@ -225,7 +230,16 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = const supplier = item.extracted_data?.supplier?.name?.trim() || '' const invoiceNum = item.extracted_data?.invoice?.invoiceNumber?.trim() || '' setDescription([supplier, invoiceNum].filter(Boolean).join(' · ') || 'Bokföring från inkorg') - setNotes('') + // WhatsApp items: prefill with the rendered chat context (representation + // deltagare + syfte, sender note) so it lands on the verifikat unless the + // user edits it away. This is the one place the photo caption is included: + // the user reads it here and can change or delete it before booking, which + // no other path offers (see channel-context-notes.ts). + // + // The dialog always submits the field, empty string included, so clearing + // the prefill really clears it: the server only defaults when the field is + // absent from the request. + setNotes(renderChannelContextNotes(item.channel_context, { includeCaption: true }) ?? '') // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, item.id]) @@ -506,7 +520,11 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = fiscal_period_id: periodId, entry_date: entryDate, description: description.trim(), - notes: notes.trim() || undefined, + // Always send the field, '' included: the server treats an absent + // `notes` as "default it from the chat context" and a present one as + // the user's own value. Sending undefined for a cleared prefill would + // resurrect the text the user just deleted onto an immutable verifikat. + notes: notes.trim(), lines: lines.map((l) => ({ account_number: l.account_number.trim(), debit_amount: parseFloat(l.debit_amount) || 0, diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 6fedf8d0..27d4a934 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -35,6 +35,7 @@ import { X, ChevronDown, Sparkles, + MessageCircle, } from 'lucide-react' import Link from 'next/link' import { cn, formatCurrency } from '@/lib/utils' @@ -44,7 +45,8 @@ import { copyInboxAddress, type AddressCopyState } from '@/components/extensions import { useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import type { InvoiceExtractionResult } from '@/types' +import type { InboxChannelContext, InvoiceExtractionResult } from '@/types' +import { renderChannelParticipant } from '@/lib/documents/channel-context-notes' import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog' import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog' import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog' @@ -61,7 +63,7 @@ type AccountingMethod = 'accrual' | 'cash' interface InboxItem { id: string status: 'received' | 'error' - source: 'email' | 'upload' + source: 'email' | 'upload' | 'whatsapp' created_at: string email_from: string | null email_subject: string | null @@ -84,6 +86,10 @@ interface InboxItem { // Distinct from status='error' (extraction failed) and from extracted_data // having empty fields (extraction ran but found nothing). extraction_skipped: boolean + // Verified human answers from the delivering chat (source='whatsapp'): + // photo caption, representation deltagare + syfte, sender note, and the + // open-question state. Null/absent for email and upload items. + channel_context?: InboxChannelContext | null // Set client-side only while a manual upload is in flight. Replaced by a // real server-side row once the AI extraction completes. isPlaceholder?: boolean @@ -1349,6 +1355,7 @@ function InboxRow({ checkbox visible (otherwise it's hover-only on desktop). */ anyChecked: boolean }) { + const t = useTranslations('inbox_workspace') const amount = pickAmount(item) const supplierName = pickSupplierName(item) const isPlaceholder = !!item.isPlaceholder @@ -1356,6 +1363,11 @@ function InboxRow({ const isErrored = status === 'error' const isBooked = status === 'booked' const isLinkedToTransaction = status === 'linked' + // A chat question the sender never answered (48h TTL hit): the missing + // info should be completed here instead. Quiet hint, not a status: the + // item still books normally. Booked items drop the reminder. + const hasUnansweredQuestion = + !isBooked && item.channel_context?.pending_question?.status === 'moved_to_app' return (
  • ) : item.source === 'email' ? ( + ) : item.source === 'whatsapp' ? ( + ) : ( )} @@ -1417,9 +1431,16 @@ function InboxRow({
    {isPlaceholder ? ( Tolkar dokument med AI… - ) : item.extraction_skipped ? ( + ) : item.extraction_skipped || hasUnansweredQuestion ? ( - Inte AI-tolkad + {item.extraction_skipped && ( + Inte AI-tolkad + )} + {hasUnansweredQuestion && ( + + {t('wa_question_badge')} + + )} {timeAgo(item.email_received_at ?? item.created_at)} ) : ( @@ -1780,6 +1801,24 @@ function FieldsRail({ const [isRetrying, setIsRetrying] = useState(false) const t = useTranslations('inbox_workspace') + // WhatsApp chat context: verified human answers captured by the intake bot + // (photo caption, representation deltagare + syfte, sender note). Rendered + // as read-only provenance above the editable fields, mirroring the email + // metadata block. `moved_to_app` means the bot asked a question in the chat + // that was never answered (48h TTL): the missing info should be completed + // here before booking. + const waCtx = item.source === 'whatsapp' ? item.channel_context ?? null : null + const waParticipants = (waCtx?.representation?.participants ?? []) + .map(renderChannelParticipant) + .filter((n) => n.length > 0) + const waPurpose = waCtx?.representation?.purpose?.trim() || null + const waCaption = waCtx?.caption?.trim() || null + const waNote = waCtx?.user_note?.trim() || null + const waUnanswered = + !isResolved && waCtx?.pending_question?.status === 'moved_to_app' + const showWaBlock = + waParticipants.length > 0 || !!waPurpose || !!waCaption || !!waNote || waUnanswered + // Surface a quiet hint when extraction caught a supplier name but no existing // supplier matched. The actual creation flow lives on the leverantörsfaktura // form (Skapa & välj), so we don't render a separate button here. @@ -1841,6 +1880,42 @@ function FieldsRail({
    )} + {/* WhatsApp chat context (see waCtx derivation above). */} + {showWaBlock && ( +
    +

    + {t('wa_block_title')} +

    + {waCaption && ( +
    + {t('wa_caption_label')} + {waCaption} +
    + )} + {waParticipants.length > 0 && ( +
    + {t('wa_participants_label')} + {waParticipants.join(', ')} +
    + )} + {waPurpose && ( +
    + {t('wa_purpose_label')} + {waPurpose} +
    + )} + {waNote && ( +
    + {t('wa_note_label')} + {waNote} +
    + )} + {waUnanswered && ( + {t('wa_question_unanswered')} + )} +
    + )} + {/* AI classification: what kind of document this is and how it was paid. Read-only context above the editable fields; absent for extractions from before the fields existed. */} diff --git a/extensions/general/invoice-inbox/__tests__/book-direct-route.test.ts b/extensions/general/invoice-inbox/__tests__/book-direct-route.test.ts index 953878d0..6e0aa632 100644 --- a/extensions/general/invoice-inbox/__tests__/book-direct-route.test.ts +++ b/extensions/general/invoice-inbox/__tests__/book-direct-route.test.ts @@ -217,6 +217,209 @@ describe('POST /items/:id/book-direct', () => { expect(createJournalEntryMock).not.toHaveBeenCalled() }) + // ── WhatsApp channel-context notes default ────────────────── + // The chat answers (representation deltagare + syfte, sender note) reach + // the verifikat even through callers that never saw the chat: absent/empty + // request notes default server-side to the rendered channel_context. + + const WA_CONTEXT = { + channel: 'whatsapp' as const, + caption: 'Kvitto lunch', + representation: { + participants: [ + { name: 'Anna Berg', company: 'Volvo' }, + { name: 'Jakob W', company: null }, + ], + purpose: 'uppföljning av avtal', + event_date: null, + raw_answer: 'Anna Berg Volvo och jag, uppföljning av avtal', + answered_at: '2026-08-01T12:00:00Z', + }, + } + + it('defaults notes to the rendered channel context when the request sends none', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: makeInvoiceInboxItem({ + document_id: 'doc-1', + source: 'whatsapp', + channel_context: WA_CONTEXT, + }), + }) + enqueue({ data: null }) // inbox item update + + const ctx = buildCtx(supabase) + const request = createMockRequest('/items/item-1/book-direct', { + method: 'POST', + body: VALID_BODY, // no notes field + searchParams: { _id: 'item-1' }, + }) + const res = await route.handler(request, ctx) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(createJournalEntryMock).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ + notes: 'Representation: Anna Berg (Volvo), Jakob W · Syfte: uppföljning av avtal', + }), + ) + }) + + it('lets caller-supplied notes win over the channel context', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: makeInvoiceInboxItem({ + document_id: 'doc-1', + source: 'whatsapp', + channel_context: WA_CONTEXT, + }), + }) + enqueue({ data: null }) + + const ctx = buildCtx(supabase) + const request = createMockRequest('/items/item-1/book-direct', { + method: 'POST', + body: { ...VALID_BODY, notes: 'Min egen anteckning' }, + searchParams: { _id: 'item-1' }, + }) + const res = await route.handler(request, ctx) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(createJournalEntryMock).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ notes: 'Min egen anteckning' }), + ) + }) + + // Regression (adversarial review): the dialog prefills the chat note, so a + // user who reads it, disagrees and DELETES it submits notes:''. Falling back + // to the rendered context on any falsy value re-attached the deleted text to + // a posted verifikat, where only a formal rättelse can remove it. Presence + // of the field, not its truthiness, decides. + it('honors an explicitly cleared notes field instead of resurrecting the chat context', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: makeInvoiceInboxItem({ + document_id: 'doc-1', + source: 'whatsapp', + channel_context: WA_CONTEXT, + }), + }) + enqueue({ data: null }) // inbox item update + + const ctx = buildCtx(supabase) + const request = createMockRequest('/items/item-1/book-direct', { + method: 'POST', + body: { ...VALID_BODY, notes: '' }, + searchParams: { _id: 'item-1' }, + }) + const res = await route.handler(request, ctx) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(createJournalEntryMock).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ notes: undefined }), + ) + const [, , , input] = createJournalEntryMock.mock.calls[0] as [unknown, string, string, { notes?: string }] + expect(input.notes ?? '').not.toContain('Representation') + }) + + // Whitespace is a cleared field too, not "no opinion". + it('treats a whitespace-only notes value as cleared', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: makeInvoiceInboxItem({ + document_id: 'doc-1', + source: 'whatsapp', + channel_context: WA_CONTEXT, + }), + }) + enqueue({ data: null }) + + const ctx = buildCtx(supabase) + const request = createMockRequest('/items/item-1/book-direct', { + method: 'POST', + body: { ...VALID_BODY, notes: ' ' }, + searchParams: { _id: 'item-1' }, + }) + const res = await route.handler(request, ctx) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(createJournalEntryMock).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ notes: undefined }), + ) + }) + + // The photo caption is unreviewed chat text: this default runs for callers + // that never saw it (MCP, API), so it must not reach the verifikat. + it('never defaults an unreviewed caption onto the verifikat', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: makeInvoiceInboxItem({ + document_id: 'doc-1', + source: 'whatsapp', + channel_context: { + channel: 'whatsapp', + caption: 'kvittot från igår, Annas sjukbesök, hon betalade', + }, + }), + }) + enqueue({ data: null }) + + const ctx = buildCtx(supabase) + const request = createMockRequest('/items/item-1/book-direct', { + method: 'POST', + body: VALID_BODY, // no notes field: the server default applies + searchParams: { _id: 'item-1' }, + }) + const res = await route.handler(request, ctx) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(createJournalEntryMock).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ notes: undefined }), + ) + }) + + it('leaves notes undefined when the item has no channel context', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: makeInvoiceInboxItem({ document_id: 'doc-1' }) }) + enqueue({ data: null }) + + const ctx = buildCtx(supabase) + const request = createMockRequest('/items/item-1/book-direct', { + method: 'POST', + body: VALID_BODY, + searchParams: { _id: 'item-1' }, + }) + const res = await route.handler(request, ctx) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(createJournalEntryMock).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ notes: undefined }), + ) + }) + it('books with transaction link: source_type=bank_transaction, source_id=transaction.id', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: makeInvoiceInboxItem({ document_id: 'doc-1' }) }) diff --git a/extensions/general/invoice-inbox/__tests__/convert-route.test.ts b/extensions/general/invoice-inbox/__tests__/convert-route.test.ts index e9a7b5e8..545785b0 100644 --- a/extensions/general/invoice-inbox/__tests__/convert-route.test.ts +++ b/extensions/general/invoice-inbox/__tests__/convert-route.test.ts @@ -216,6 +216,131 @@ describe('POST /items/:id/convert', () => { expect(body.data.inbox_item_id).toBe('item-1') }) + it('defaults the supplier invoice notes to the rendered WhatsApp channel context', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ + data: makeInvoiceInboxItem({ + status: 'received', + document_id: 'doc-1', + source: 'whatsapp', + channel_context: { channel: 'whatsapp', user_note: 'Serverlicens för Q3' }, + }), + }) + enqueue({ data: makeSupplier({ id: 'supplier-1' }) }) + enqueue({ data: 42 }) + enqueue({ data: { id: 'invoice-1', status: 'registered' } }) + enqueue({ data: null, error: null }) + enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) }) + enqueue({ data: null, error: null }) + + const ctx = buildCtx(supabase) + const request = createMockRequest('/items/item-1/convert', { + method: 'POST', + body: VALID_CONVERT_BODY, // no notes field + searchParams: { _id: 'item-1' }, + }) + const res = await route.handler(request, ctx) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(200) + const insertArgs = findCall('supplier_invoices', 'insert') + expect(insertArgs?.[0]).toMatchObject({ notes: 'Serverlicens för Q3' }) + }) + + // Same rule as book-direct: an explicit '' is the caller clearing the field, + // not "no opinion", so the chat context must not be written back in. + it('honors an explicitly cleared notes field on convert', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ + data: makeInvoiceInboxItem({ + status: 'received', + source: 'whatsapp', + channel_context: { channel: 'whatsapp', user_note: 'Från chatten' }, + }), + }) + enqueue({ data: makeSupplier({ id: 'supplier-1' }) }) + enqueue({ data: 42 }) + enqueue({ data: { id: 'invoice-1', status: 'registered' } }) + enqueue({ data: null, error: null }) + enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) }) + enqueue({ data: null, error: null }) + + const ctx = buildCtx(supabase) + const request = createMockRequest('/items/item-1/convert', { + method: 'POST', + body: { ...VALID_CONVERT_BODY, notes: '' }, + searchParams: { _id: 'item-1' }, + }) + const res = await route.handler(request, ctx) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(200) + const insertArgs = findCall('supplier_invoices', 'insert') + expect(insertArgs?.[0]).toMatchObject({ notes: null }) + }) + + // The convert form never shows the chat context, so an unreviewed photo + // caption must not ride along onto the leverantörsfaktura either. + it('never defaults an unreviewed caption onto the supplier invoice', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ + data: makeInvoiceInboxItem({ + status: 'received', + source: 'whatsapp', + channel_context: { channel: 'whatsapp', caption: 'lunch med Anna, hon bjöd tillbaka' }, + }), + }) + enqueue({ data: makeSupplier({ id: 'supplier-1' }) }) + enqueue({ data: 42 }) + enqueue({ data: { id: 'invoice-1', status: 'registered' } }) + enqueue({ data: null, error: null }) + enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) }) + enqueue({ data: null, error: null }) + + const ctx = buildCtx(supabase) + const request = createMockRequest('/items/item-1/convert', { + method: 'POST', + body: VALID_CONVERT_BODY, // no notes field + searchParams: { _id: 'item-1' }, + }) + const res = await route.handler(request, ctx) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(200) + const insertArgs = findCall('supplier_invoices', 'insert') + expect(insertArgs?.[0]).toMatchObject({ notes: null }) + }) + + it('keeps caller-supplied notes over the channel context on convert', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ + data: makeInvoiceInboxItem({ + status: 'received', + source: 'whatsapp', + channel_context: { channel: 'whatsapp', user_note: 'Från chatten' }, + }), + }) + enqueue({ data: makeSupplier({ id: 'supplier-1' }) }) + enqueue({ data: 42 }) + enqueue({ data: { id: 'invoice-1', status: 'registered' } }) + enqueue({ data: null, error: null }) + enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) }) + enqueue({ data: null, error: null }) + + const ctx = buildCtx(supabase) + const request = createMockRequest('/items/item-1/convert', { + method: 'POST', + body: { ...VALID_CONVERT_BODY, notes: 'Egen anteckning' }, + searchParams: { _id: 'item-1' }, + }) + const res = await route.handler(request, ctx) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(200) + const insertArgs = findCall('supplier_invoices', 'insert') + expect(insertArgs?.[0]).toMatchObject({ notes: 'Egen anteckning' }) + }) + it('emits supplier_invoice.registered and supplier_invoice.confirmed events', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: makeInvoiceInboxItem({ status: 'received' }) }) diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index f403129c..5f470a05 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -50,6 +50,7 @@ import { } from '@/lib/currency/supplier-invoice-rate' import { roundOre } from '@/lib/money' import { linkToJournalEntry } from '@/lib/core/documents/document-service' +import { renderChannelContextNotes } from '@/lib/documents/channel-context-notes' import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema, BulkBookInboxSchema } from '@/lib/api/schemas' import { bulkBookMatchedInboxItems } from '@/lib/transactions/categorize-core' import { hasCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability' @@ -57,7 +58,7 @@ import { CAPABILITY } from '@/lib/entitlements/keys' import { appendProcessingHistory } from '@/lib/processing-history/append' import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox' import { simpleParser } from 'mailparser' -import type { InvoiceExtractionResult, InvoiceInboxItem, SupplierInvoice, SupplierInvoiceItem } from '@/types' +import type { InboxChannelContext, InvoiceExtractionResult, InvoiceInboxItem, SupplierInvoice, SupplierInvoiceItem } from '@/types' const MAX_ATTACHMENTS_PER_EMAIL = 20 @@ -275,7 +276,7 @@ export const invoiceInboxExtension: Extension = { email_received_at, email_body_text, error_message, created_supplier_invoice_id, matched_transaction_id, created_journal_entry_id, - resend_email_id, extraction_skipped + resend_email_id, extraction_skipped, channel_context `) .eq('company_id', ctx.companyId) .order('created_at', { ascending: false }) @@ -1772,7 +1773,20 @@ export const invoiceInboxExtension: Extension = { total_sek: totalSek, remaining_amount: total, document_id: item.document_id || null, - notes: body.notes || null, + // WhatsApp-sourced items: when the request carries NO notes field + // at all, default to the rendered chat context (representation + // deltagare + syfte, sender note) so the human answers from the + // chat reach the leverantörsfaktura. Presence decides, not + // truthiness: `notes: ""` is an explicit clear and stays empty + // (same rule as book-direct, where the value lands on an + // immutable verifikat). The caption is excluded: this form never + // shows the chat context, so nobody reviewed it. + notes: + body.notes === undefined + ? renderChannelContextNotes( + (item as { channel_context?: InboxChannelContext | null }).channel_context, + ) + : body.notes.trim() || null, }) .select() .single() @@ -2022,7 +2036,7 @@ export const invoiceInboxExtension: Extension = { const { data: item, error: fetchError } = await ctx.supabase .from('invoice_inbox_items') - .select('id, document_id, status, created_supplier_invoice_id, created_journal_entry_id, matched_transaction_id, correlation_id') + .select('id, document_id, status, created_supplier_invoice_id, created_journal_entry_id, matched_transaction_id, correlation_id, channel_context') .eq('id', id) .eq('company_id', ctx.companyId) .maybeSingle() @@ -2074,6 +2088,24 @@ export const invoiceInboxExtension: Extension = { transaction = tx } + // WhatsApp-sourced items: when the request carries NO notes field at + // all, default to the rendered chat context (representation deltagare + // + syfte, sender note) so the audit text reaches the verifikat even + // through clients that never saw the chat (MCP, older UI). + // + // Presence decides, not truthiness: `notes: ""` is the UI saying the + // user emptied the field, and resurrecting the prefill there would + // write text onto an immutable verifikat against an explicit user + // action (removable only via rättelse). So an empty string clears, + // and only an absent field defaults. The caption is excluded: this + // path can run without a human ever seeing the string. + const effectiveNotes = + body.notes === undefined + ? renderChannelContextNotes( + (item as { channel_context?: InboxChannelContext | null }).channel_context, + ) ?? undefined + : body.notes.trim() || undefined + // Create the journal entry via the engine. Source-tracks back to // the inbox item so the audit trail is preserved even when no // transaction is involved. @@ -2085,7 +2117,7 @@ export const invoiceInboxExtension: Extension = { description: body.description, source_type: transaction ? 'bank_transaction' : 'inbox_item', source_id: transaction ? transaction.id : item.id, - notes: body.notes, + notes: effectiveNotes, lines: body.lines, }) } catch (err) { diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 083bd80b..10d5bfc5 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -1383,6 +1383,12 @@ export const BookInboxItemDirectlySchema = z.object({ fiscal_period_id: uuid, entry_date: isoDate, description: z.string().min(1, 'Beskrivning krävs'), + // `.optional()` here carries meaning the route depends on: ABSENT means + // "caller has no opinion", so the route may default the notes from the + // item's chat context, while an explicit '' means the user cleared the + // prefilled note and nothing must be written back onto the verifikat. + // Keep it `.optional()`, never `.default('')` or a min(1): both would + // collapse those two cases into one. notes: z.string().max(2000).optional(), lines: z.array(CreateJournalEntryLineSchema).min(2, 'Minst två rader krävs för dubbel bokföring'), transaction_id: uuid.optional(), diff --git a/lib/documents/__tests__/channel-context-notes.test.ts b/lib/documents/__tests__/channel-context-notes.test.ts new file mode 100644 index 00000000..19b43257 --- /dev/null +++ b/lib/documents/__tests__/channel-context-notes.test.ts @@ -0,0 +1,210 @@ +/** + * renderChannelContextNotes: the one string WhatsApp chat context becomes on + * its way into a verifikat description. Pins precedence (representation > + * user_note > caption), the caption opt-in, the 220-char cap, and whole-name + * participant truncation ("… och N till", never a name cut mid-way). + */ +import { describe, it, expect } from 'vitest' +import { + renderChannelContextNotes, + renderChannelParticipant, + CHANNEL_CONTEXT_NOTES_MAX, +} from '../channel-context-notes' +import type { InboxChannelContext } from '@/types' + +function repCtx( + participants: { name: string; company: string | null }[], + purpose: string | null = null, + extra: Partial = {}, +): InboxChannelContext { + return { + channel: 'whatsapp', + representation: { + participants, + purpose, + event_date: null, + raw_answer: 'raw', + answered_at: '2026-08-01T12:00:00Z', + }, + ...extra, + } +} + +describe('renderChannelContextNotes', () => { + it('renders a full representation answer: participants with company, then purpose', () => { + const ctx = repCtx( + [ + { name: 'Anna Berg', company: 'Volvo' }, + { name: 'Jakob W', company: null }, + ], + 'uppföljning av avtal', + ) + expect(renderChannelContextNotes(ctx)).toBe( + 'Representation: Anna Berg (Volvo), Jakob W · Syfte: uppföljning av avtal', + ) + }) + + it('renders a self participant (no company) as the bare name', () => { + expect(renderChannelParticipant({ name: ' Jakob W ', company: null })).toBe('Jakob W') + expect(renderChannelParticipant({ name: 'Anna', company: ' ' })).toBe('Anna') + const ctx = repCtx([{ name: 'Jakob W', company: null }], 'lunch med kund') + expect(renderChannelContextNotes(ctx)).toBe( + 'Representation: Jakob W · Syfte: lunch med kund', + ) + }) + + it('renders purpose alone when the participant list is empty', () => { + const ctx = repCtx([], 'kundmiddag') + expect(renderChannelContextNotes(ctx)).toBe('Syfte: kundmiddag') + }) + + it('truncates the participant list by whole names with "… och N till"', () => { + const participants = Array.from({ length: 12 }, (_, i) => ({ + name: `Deltagare Efternamnsson ${i + 1}`, + company: 'Företagsnamnet Aktiebolag', + })) + const ctx = repCtx(participants, 'branschmässa i Göteborg') + const line = renderChannelContextNotes(ctx)! + + expect(line.length).toBeLessThanOrEqual(CHANNEL_CONTEXT_NOTES_MAX) + expect(line).toMatch(/… och \d+ till/) + // Whole names only: every rendered participant appears in full or not at + // all. The first is always kept; the last is always dropped here. + expect(line).toContain('Deltagare Efternamnsson 1 (Företagsnamnet Aktiebolag)') + expect(line).not.toContain('Deltagare Efternamnsson 12') + // The dropped count accounts for every name not printed. + const [, dropped] = line.match(/… och (\d+) till/)! + const printed = participants.filter((p) => line.includes(`${p.name} (${p.company})`)).length + expect(printed + Number(dropped)).toBe(participants.length) + // No name was cut mid-way: the truncation marker follows a complete + // "(company)" closing paren, not a partial name. + expect(line).toMatch(/\(Företagsnamnet Aktiebolag\) … och \d+ till/) + // Purpose survives truncation. + expect(line).toContain('Syfte: branschmässa i Göteborg') + }) + + it('keeps at least one participant even when the line still overflows, then caps free text', () => { + const ctx = repCtx( + [ + { name: 'Anna Berg', company: 'Volvo' }, + { name: 'Bo Ek', company: null }, + ], + 'x'.repeat(400), + ) + const line = renderChannelContextNotes(ctx)! + expect(line.length).toBeLessThanOrEqual(CHANNEL_CONTEXT_NOTES_MAX) + expect(line).toContain('Representation: Anna Berg (Volvo)') + expect(line.endsWith('…')).toBe(true) + }) + + it('renders user_note alone', () => { + const ctx: InboxChannelContext = { + channel: 'whatsapp', + user_note: 'Parkering vid kundbesök i Uppsala', + } + expect(renderChannelContextNotes(ctx)).toBe('Parkering vid kundbesök i Uppsala') + }) + + it('appends user_note after the representation answer', () => { + const ctx = repCtx([{ name: 'Anna Berg', company: 'Volvo' }], 'avtalslunch', { + user_note: 'Betald privat', + }) + expect(renderChannelContextNotes(ctx)).toBe( + 'Representation: Anna Berg (Volvo) · Syfte: avtalslunch · Betald privat', + ) + }) + + it('falls back to the caption only when nothing else exists AND it was asked for', () => { + const captionOnly: InboxChannelContext = { + channel: 'whatsapp', + caption: 'Kvitto taxi till kundmöte', + } + expect(renderChannelContextNotes(captionOnly, { includeCaption: true })).toBe( + 'Kvitto taxi till kundmöte', + ) + + // Caption is ignored the moment an explicit answer exists. + const withNote: InboxChannelContext = { + channel: 'whatsapp', + caption: 'Kvitto taxi till kundmöte', + user_note: 'Resa till Arlanda', + } + expect(renderChannelContextNotes(withNote, { includeCaption: true })).toBe('Resa till Arlanda') + }) + + // The caption is chat text nobody was asked for and nobody reviewed, while + // every unattended caller (bulk-book, the routes' server-side defaults) + // writes the result into an immutable verifikat. Opt-in, never default. + it('leaves the caption out by default', () => { + const captionOnly: InboxChannelContext = { + channel: 'whatsapp', + caption: 'kvittot från igår, Annas sjukbesök, hon betalade', + } + expect(renderChannelContextNotes(captionOnly)).toBeNull() + expect(renderChannelContextNotes(captionOnly, {})).toBeNull() + expect(renderChannelContextNotes(captionOnly, { includeCaption: false })).toBeNull() + }) + + it('renders the answered parts and drops the caption when captions are off', () => { + const ctx = repCtx([{ name: 'Anna Berg', company: 'Volvo' }], 'avtalslunch', { + caption: 'privat text som ingen granskat', + user_note: 'Betald privat', + }) + const line = renderChannelContextNotes(ctx)! + expect(line).toBe('Representation: Anna Berg (Volvo) · Syfte: avtalslunch · Betald privat') + expect(line).not.toContain('privat text som ingen granskat') + }) + + it('caps a runaway caption', () => { + const ctx: InboxChannelContext = { channel: 'whatsapp', caption: 'k'.repeat(500) } + const line = renderChannelContextNotes(ctx, { includeCaption: true })! + expect(line.length).toBeLessThanOrEqual(CHANNEL_CONTEXT_NOTES_MAX) + expect(line.endsWith('…')).toBe(true) + }) + + it('returns null for empty input', () => { + expect(renderChannelContextNotes(null)).toBeNull() + expect(renderChannelContextNotes(undefined)).toBeNull() + expect(renderChannelContextNotes({ channel: 'whatsapp' })).toBeNull() + expect( + renderChannelContextNotes({ + channel: 'whatsapp', + caption: 'ignorerad utan opt-in', + user_note: '', + representation: { + participants: [{ name: ' ', company: null }], + purpose: ' ', + event_date: null, + raw_answer: 'nej', + answered_at: '2026-08-01T12:00:00Z', + }, + }), + ).toBeNull() + expect( + renderChannelContextNotes( + { + channel: 'whatsapp', + caption: ' ', + user_note: '', + representation: { + participants: [{ name: ' ', company: null }], + purpose: ' ', + event_date: null, + raw_answer: 'nej', + answered_at: '2026-08-01T12:00:00Z', + }, + }, + { includeCaption: true }, + ), + ).toBeNull() + }) + + it('is deterministic (same input, same output)', () => { + const ctx = repCtx( + [{ name: 'Anna Berg', company: 'Volvo' }], + 'uppföljning av avtal', + { user_note: 'Kortköp' }, + ) + expect(renderChannelContextNotes(ctx)).toBe(renderChannelContextNotes(ctx)) + }) +}) diff --git a/lib/documents/channel-context-notes.ts b/lib/documents/channel-context-notes.ts new file mode 100644 index 00000000..7c1ecdc6 --- /dev/null +++ b/lib/documents/channel-context-notes.ts @@ -0,0 +1,119 @@ +/** + * 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) + } +} diff --git a/lib/reports/__tests__/full-archive-export.test.ts b/lib/reports/__tests__/full-archive-export.test.ts index e9b59ab6..5f9a2ad8 100644 --- a/lib/reports/__tests__/full-archive-export.test.ts +++ b/lib/reports/__tests__/full-archive-export.test.ts @@ -118,6 +118,7 @@ function buildMasterDataQueue(opts: { describe('generateFullArchive', () => { let supabase: ReturnType['supabase'] let enqueueMany: ReturnType['enqueueMany'] + let findCall: ReturnType['findCall'] beforeEach(() => { vi.clearAllMocks() @@ -125,6 +126,7 @@ describe('generateFullArchive', () => { const mock = createQueuedMockSupabase() supabase = mock.supabase enqueueMany = mock.enqueueMany + findCall = mock.findCall }) describe('scope: period', () => { @@ -825,6 +827,72 @@ describe('generateFullArchive', () => { ]) }) + // The archive is what a company leaving Accounted keeps as its BFL + // 7-year record. A representation answer given in chat is documented in + // full only in invoice_inbox_items.channel_context: the verifikat line + // caps at 220 chars and drops whole names ("… och N till"), while + // Skatteverket wants every deltagare. So the answers must be exported. + it('exports the chat answers behind a verifikat, in full', async () => { + const participants = Array.from({ length: 10 }, (_, i) => ({ + name: `Deltagare Efternamnsson ${i + 1}`, + company: 'Företagsnamnet Aktiebolag', + })) + const inboxRow = { + id: 'inbox-1', + created_at: '2024-06-01T10:00:00Z', + source: 'whatsapp', + status: 'confirmed', + document_id: 'doc-1', + matched_transaction_id: 'tx-1', + created_journal_entry_id: 'je-1', + created_supplier_invoice_id: null, + channel_context: { + channel: 'whatsapp', + representation: { + participants, + purpose: 'avtalsförhandling', + event_date: '2024-06-01', + raw_answer: 'tio personer, avtalsförhandling', + answered_at: '2024-06-01T18:00:00Z', + }, + }, + } + + enqueueMany([ + { data: COMPANY_ROW }, + { data: [PERIOD_2024] }, + { data: [] }, // document_attachments + { data: [] }, // sie_imports + { data: [] }, // sie_account_mappings + ...buildMasterDataQueue({ direct: { invoice_inbox_items: [inboxRow] } }), + ]) + + const buffer = await generateFullArchive(supabase as any, 'company-1', { + scope: 'all', + }) + const zip = await JSZip.loadAsync(buffer) + const rows = JSON.parse( + await zip.file('data/invoice_inbox_items.json')!.async('text') + ) as Array> + + // Every deltagare survives, not the three that fit on the verifikat line. + expect(rows[0].channel_context.representation.participants).toHaveLength(10) + expect(rows[0].channel_context.representation.raw_answer).toBe( + 'tio personer, avtalsförhandling' + ) + // Tied to what was booked from it, so a revisor can find the verifikat. + expect(rows[0].created_journal_entry_id).toBe('je-1') + + // A projection, not the whole row: inbox workflow state (email bodies, + // OCR output, error messages) stays out of the archive. + const select = findCall('invoice_inbox_items', 'select')?.[0] as string + expect(select).toContain('channel_context') + expect(select).toContain('created_journal_entry_id') + expect(select).not.toContain('email_body_text') + expect(select).not.toContain('extracted_data') + expect(select).not.toBe('*') + }) + it('skips raw SIE blobs when include_documents is false but keeps metadata', async () => { enqueueMany([ { data: COMPANY_ROW }, diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 01ef03d8..77e286c1 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -779,6 +779,18 @@ export interface MasterDataTableSpec { * table through `fk IN (...)` chunks. */ via?: { parent: string; fk: string } + /** + * PostgREST select list for a narrow projection. Defaults to `*`. + * + * Only for tables where part of the row is räkenskapsinformation and the + * rest is workflow state that has no place in a portable archive (see + * invoice_inbox_items). Must include the page key. + * + * Additive only, like `denormalize`: an archive already handed to a revisor + * must keep every key it shipped with, so append columns and never drop + * one. + */ + columns?: string /** * Parent columns copied onto every child row as ``. * @@ -838,6 +850,28 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [ denormalize: { prefix: 'supplier_invoice_', columns: ['currency', 'exchange_rate'] }, }, { name: 'supplier_invoice_payments', file: 'supplier_invoice_payments.json' }, + // Underlag intake: the chat answers behind a verifikat. + // + // A projection, not the whole table. `channel_context` holds the human + // answers the WhatsApp bot collected (representation deltagare + syfte + + // raw_answer), and it is the ONLY complete copy: the verifikat line carries + // a 220-char render that drops whole names ("… och N till"), and Skatte- + // verket's dokumentationskrav wants every deltagare. Without this file a + // company that leaves with its archive keeps an incomplete representation + // trail. The booking columns come along so each answer can be tied to the + // verifikat it belongs to. + // + // Everything else on the row (email bodies, OCR output, error messages, + // retry state) is inbox workflow state and stays out; the documents + // themselves are in dokument/. + { + name: 'invoice_inbox_items', + file: 'invoice_inbox_items.json', + orderBy: 'created_at', + columns: + 'id, created_at, source, status, document_id, matched_transaction_id, ' + + 'created_journal_entry_id, created_supplier_invoice_id, channel_context', + }, // Receipts { name: 'receipts', file: 'receipts.json', orderBy: 'receipt_date' }, // `receipts` has no exchange_rate column, so only the currency is copied: @@ -960,7 +994,6 @@ export const ARCHIVE_EXCLUDED_TABLES: Record = { graph_transaction_counterparties: 'derived AI context graph, regenerable', idempotency_keys: 'infrastructure', inbox_rate_counters: 'infrastructure', - invoice_inbox_items: 'inbox workflow state; the files live in document_attachments', mcp_tasks: 'MCP task handles: transient tool-call state with a 1-hour TTL', metered_events: 'billing telemetry', notification_log: 'notification dedup log', @@ -1077,7 +1110,7 @@ async function writeMasterData( : t.via ? await fetchChildTableRows(supabase, companyId, t) : await fetchAllRows>(({ from, to }) => { - let q = supabase.from(t.name).select('*').eq('company_id', companyId) + let q = supabase.from(t.name).select(t.columns ?? '*').eq('company_id', companyId) if (t.orderBy) { q = q.order(t.orderBy, { ascending: true }) } @@ -1086,7 +1119,15 @@ async function writeMasterData( // silently SKIPS/DUPLICATES rows across page boundaries: data loss in // a statutory 7-year retention archive. dedupeBy is defense-in-depth // against the duplicate case. - return q.order(pageKey, { ascending: true }).range(from, to) + // + // The select list is built at runtime (spec.columns), so + // PostgREST's literal-string type inference cannot resolve it and + // falls back to an error type; the runtime shape is the declared + // columns, by construction. Same cast as fetchChildTableRows. + return q.order(pageKey, { ascending: true }).range(from, to) as unknown as PromiseLike<{ + data: Record[] | null + error: { message: string } | null + }> }, { dedupeBy: (r) => String(r[pageKey]) }) data.file(t.file, JSON.stringify(rows, null, 2)) } catch (err) { diff --git a/lib/transactions/__tests__/categorize-core.bulk.test.ts b/lib/transactions/__tests__/categorize-core.bulk.test.ts index 64ff00f7..fdb88a50 100644 --- a/lib/transactions/__tests__/categorize-core.bulk.test.ts +++ b/lib/transactions/__tests__/categorize-core.bulk.test.ts @@ -349,6 +349,175 @@ describe('bulkBookMatchedInboxItems: booking', () => { }) }) +describe('bulkBookMatchedInboxItems: WhatsApp channel-context notes threading', () => { + const WA_CONTEXT = { + channel: 'whatsapp', + representation: { + participants: [ + { name: 'Anna Berg', company: 'Volvo' }, + { name: 'Jakob W', company: null }, + ], + purpose: 'uppföljning av avtal', + event_date: null, + raw_answer: 'raw', + answered_at: '2026-08-01T12:00:00Z', + }, + } + const RENDERED = 'Representation: Anna Berg (Volvo), Jakob W · Syfte: uppföljning av avtal' + + const bookableWaItem = () => [ + { data: { id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null, channel_context: WA_CONTEXT } }, + { data: { id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK', cash_account_id: null, journal_entry_id: null } }, + { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: [{ id: 'fp-1' }] }, + { error: null }, + { data: [] }, + ] + + it('threads the rendered channel context into the verifikat notes', async () => { + const supabase = queuedSupabase(bookableWaItem()) + + const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', { + item_ids: ['i1'], + category: 'expense_software', + }) + + expect(skipped).toEqual([]) + expect(booked).toHaveLength(1) + // createTransactionJournalEntry(supabase, companyId, userId, tx, mapping, notes) + expect(mockCreateJE).toHaveBeenCalledWith( + expect.anything(), + 'c1', + 'u1', + expect.objectContaining({ id: 'tx-1' }), + expect.anything(), + RENDERED, + ) + }) + + it('keeps the shared batch note AND the per-item channel context', async () => { + const supabase = queuedSupabase(bookableWaItem()) + + await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', { + item_ids: ['i1'], + category: 'expense_software', + notes: 'Gemensam batchanteckning', + }) + + expect(mockCreateJE).toHaveBeenCalledWith( + expect.anything(), + 'c1', + 'u1', + expect.objectContaining({ id: 'tx-1' }), + expect.anything(), + `Gemensam batchanteckning · ${RENDERED}`, + ) + }) + + // Regression (adversarial review): the photo caption is chat text nobody + // was asked for and nobody reviewed, and this loop books every selected item + // with no per-item notes field. Burning it into a posted verifikat + // description would need a formal rättelse to undo, so only the answered + // parts (representation, user_note) are threaded here. + it('does NOT thread an unreviewed photo caption into the verifikat notes', async () => { + const supabase = queuedSupabase([ + { + data: { + id: 'i1', + matched_transaction_id: 'tx-1', + created_journal_entry_id: null, + created_supplier_invoice_id: null, + channel_context: { + channel: 'whatsapp', + caption: 'kvittot från igår, Annas sjukbesök, hon betalade', + }, + }, + }, + { data: { id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK', cash_account_id: null, journal_entry_id: null } }, + { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: [{ id: 'fp-1' }] }, + { error: null }, + { data: [] }, + ]) + + await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', { + item_ids: ['i1'], + category: 'expense_software', + notes: 'Gemensam batchanteckning', + }) + + expect(mockCreateJE).toHaveBeenCalledWith( + expect.anything(), + 'c1', + 'u1', + expect.objectContaining({ id: 'tx-1' }), + expect.anything(), + 'Gemensam batchanteckning', + ) + const passedNotes = mockCreateJE.mock.calls[0][5] as string | undefined + expect(passedNotes).not.toContain('sjukbesök') + }) + + it('threads the answered parts of a captioned item and drops the caption', async () => { + const supabase = queuedSupabase([ + { + data: { + id: 'i1', + matched_transaction_id: 'tx-1', + created_journal_entry_id: null, + created_supplier_invoice_id: null, + channel_context: { ...WA_CONTEXT, caption: 'random bildtext' }, + }, + }, + { data: { id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK', cash_account_id: null, journal_entry_id: null } }, + { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: [{ id: 'fp-1' }] }, + { error: null }, + { data: [] }, + ]) + + await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', { + item_ids: ['i1'], + category: 'expense_software', + }) + + expect(mockCreateJE).toHaveBeenCalledWith( + expect.anything(), + 'c1', + 'u1', + expect.objectContaining({ id: 'tx-1' }), + expect.anything(), + RENDERED, + ) + }) + + it('passes notes through unchanged for items without channel context', async () => { + const supabase = queuedSupabase([ + { data: { id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null, channel_context: null } }, + { data: { id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK', cash_account_id: null, journal_entry_id: null } }, + { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: [{ id: 'fp-1' }] }, + { error: null }, + { data: [] }, + ]) + + await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', { + item_ids: ['i1'], + category: 'expense_software', + notes: 'Bara batchanteckningen', + }) + + expect(mockCreateJE).toHaveBeenCalledWith( + expect.anything(), + 'c1', + 'u1', + expect.objectContaining({ id: 'tx-1' }), + expect.anything(), + 'Bara batchanteckningen', + ) + }) +}) + describe('bulkBookMatchedInboxItems: intra-batch duplicate handling', () => { /** Six queued from() results for one successfully-booked item. */ const bookableItem = (itemId: string, txId: string, amount: number) => [ diff --git a/lib/transactions/categorize-core.ts b/lib/transactions/categorize-core.ts index a1b0549f..1fa2eb47 100644 --- a/lib/transactions/categorize-core.ts +++ b/lib/transactions/categorize-core.ts @@ -30,6 +30,7 @@ import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-ent import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { linkToJournalEntry } from '@/lib/core/documents/document-service' +import { renderChannelContextNotes } from '@/lib/documents/channel-context-notes' import { detectBookingDuplicate, type BookedDuplicateCandidate, @@ -38,7 +39,7 @@ import { import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry' import { appendProcessingHistory } from '@/lib/processing-history/append' import { createLogger } from '@/lib/logger' -import type { Transaction, TransactionCategory, EntityType, VatTreatment } from '@/types' +import type { InboxChannelContext, Transaction, TransactionCategory, EntityType, VatTreatment } from '@/types' const log = createLogger('transactions/categorize-core') @@ -501,7 +502,7 @@ export async function bulkBookMatchedInboxItems( for (const itemId of item_ids) { const { data: item, error: itemError } = await supabase .from('invoice_inbox_items') - .select('id, matched_transaction_id, created_journal_entry_id, created_supplier_invoice_id') + .select('id, matched_transaction_id, created_journal_entry_id, created_supplier_invoice_id, channel_context') .eq('id', itemId) .eq('company_id', companyId) .maybeSingle() @@ -523,6 +524,24 @@ export async function bulkBookMatchedInboxItems( continue } + // WhatsApp-sourced underlag carry verified human context (representation + // deltagare + syfte, sender note) in channel_context. Thread it into the + // verifikat description ALONGSIDE the caller's shared batch note: bulk + // booking never shows a per-item notes field, so dropping the chat + // answers here would silently lose the Skatteverket representation + // documentation that only exists on this one item. + // + // Answers only, never the photo caption (the renderer leaves it out + // unless asked for it): this loop books without any per-item review and + // the verifikat description is immutable under BFL 5 kap, so unreviewed + // chat text must not land there. Captions only reach a verifikat through + // Bokför direkt, where the user reads them in an editable field first. + const channelNotes = renderChannelContextNotes( + (item as { channel_context?: InboxChannelContext | null }).channel_context, + ) + const itemNotes = + [notes?.trim(), channelNotes].filter(Boolean).join(' · ') || undefined + let result: CategorizeCoreResult try { result = await categorizeMatchedTransaction( @@ -530,7 +549,7 @@ export async function bulkBookMatchedInboxItems( userId, companyId, item.matched_transaction_id as string, - { category, vatTreatment: vat_treatment, vatAmount: vat_amount, notes, allowDuplicate: allow_duplicate, dimensions }, + { category, vatTreatment: vat_treatment, vatAmount: vat_amount, notes: itemNotes, allowDuplicate: allow_duplicate, dimensions }, // Snapshot copies so the guard sees only the prior bookings of this batch. { excludeTransactionIds: [...bookedTransactionIds], excludeJournalEntryIds: [...bookedJournalEntryIds] }, ) diff --git a/messages/en.json b/messages/en.json index fa516905..e8c9588c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2844,7 +2844,14 @@ "pages_partial_note": "Extracted from the first {analyzed} of {total} pages.", "heic_hint": "HEIC images cannot be AI-extracted yet. Upload the receipt as JPEG or PDF, or fill in the fields manually.", "skipped_hint": "AI extraction did not run for this document. You can link the document to a transaction or create a supplier invoice manually.", - "retry_overwrite_confirm": "Re-running extraction overwrites the fields, including your own edits. Continue?" + "retry_overwrite_confirm": "Re-running extraction overwrites the fields, including your own edits. Continue?", + "wa_block_title": "From WhatsApp", + "wa_caption_label": "Message", + "wa_participants_label": "Participants", + "wa_purpose_label": "Purpose", + "wa_note_label": "Note", + "wa_question_unanswered": "A question from the chat went unanswered. Complete the details here before booking.", + "wa_question_badge": "Unanswered question" }, "inbox_custom_domain": { "title": "Custom inbox domain", diff --git a/messages/sv.json b/messages/sv.json index 5ac33aac..9db7b557 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2844,7 +2844,14 @@ "pages_partial_note": "Tolkad från de första {analyzed} av {total} sidorna.", "heic_hint": "HEIC-bilder kan inte AI-tolkas ännu. Ladda upp kvittot som JPEG eller PDF, eller fyll i fälten manuellt.", "skipped_hint": "AI-tolkning kördes inte för det här dokumentet. Du kan koppla dokumentet till en transaktion eller skapa leverantörsfaktura manuellt.", - "retry_overwrite_confirm": "Ny tolkning skriver över fälten, även ändringar du gjort själv. Fortsätta?" + "retry_overwrite_confirm": "Ny tolkning skriver över fälten, även ändringar du gjort själv. Fortsätta?", + "wa_block_title": "Från WhatsApp", + "wa_caption_label": "Meddelande", + "wa_participants_label": "Deltagare", + "wa_purpose_label": "Syfte", + "wa_note_label": "Anteckning", + "wa_question_unanswered": "En fråga från chatten blev obesvarad. Komplettera uppgifterna här innan du bokför.", + "wa_question_badge": "Fråga obesvarad" }, "inbox_custom_domain": { "title": "Egen domän för inkorgen",