From 867767a22fc05ddcb02ce101b2afe484b557cd91 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:50:42 +0200 Subject: [PATCH] feat(inbox): document-type badge and filter, +lev/+ver plus-addressing (#2129) (#2148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(inbox): document-type badge and filter, +lev/+ver plus-addressing (#2129) Phase 1: every inbox row shows its document kind (Kvitto, Leverantorsfaktura, Myndighetsbrev, Ovrigt) from the existing AI documentKind, and a second menu next to the status filter narrows the list to leverantorsfakturor or underlag. Pure predicate in lib/documents/inbox-kind.ts with tests. Phase 2: the shared inbox address accepts RFC 5233 plus-addressing. The webhook splits the local part at the first + and looks up the base, so +anything@ now reaches the company instead of 404ing. +lev and +ver land in the new nullable invoice_inbox_items.kind_hint column (CHECK supplier_invoice | receipt), threaded through EmailMeta into both inbox inserts and returned by GET /items. kind_hint wins over documentKind for the badge and the filter and survives re-extraction because it is a column. The sources panel shows both tagged addresses with a one-line hint (sv + en). Tests: filter predicate per kind and null; parser and tag mapping; webhook routes +LEV and an unknown tag; pg test pins the CHECK and NULL default. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr * fix(inbox): honest empty state under a type filter, detail pane shares the row's kind resolution Skeptic findings on #2148: with a type filter narrowing 'Att göra' to zero the empty state claimed 'allt är bearbetat' while the status trigger still counted pending rows; it now says no items of that type are here (sv + en). The fields rail printed the AI documentKind only, so a +lev hint could disagree with the row badge; it now uses resolveInboxKind like the list. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr * fix(inbox): keep the type-filter empty state off purchase lists, carry kind_hint onto rejected attachment rows CodeRabbit on #2148: the purchase lists (Saknar underlag, Hämta från portal) ignore the type menu, so a leftover kind filter must not pick their empty-state copy. A rejected attachment (unsupported MIME, too large) now keeps the sender's +lev / +ver hint on its error row like every other inbox insert; the allowlist test covers it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr * fix(inbox): set the +lev/+ver kind hint only when the shared address resolved the company CodeRabbit on #2148: the hint was computed before recipient resolution, so a tag on an unknown or retired shared address could ride along onto a custom-domain match. It is now assigned inside the active shared-inbox branch only; regression test covers the multi-recipient case. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr --------- Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 1 + .../general/InvoiceInboxWorkspace.tsx | 181 +++++++++++++++--- .../__tests__/inbound-webhook.test.ts | 148 +++++++++++++- .../__tests__/resend-inbound.test.ts | 61 +++++- extensions/general/invoice-inbox/index.ts | 24 ++- .../invoice-inbox/lib/resend-inbound.ts | 41 +++- .../invoice-inbox/lib/upload-and-extract.ts | 7 + lib/documents/__tests__/inbox-kind.test.ts | 80 ++++++++ lib/documents/inbox-kind.ts | 63 ++++++ messages/en.json | 5 + messages/sv.json | 5 + ...20260901210000_invoice_inbox_kind_hint.sql | 22 +++ tests/pg/invoice-inbox-kind-hint.pg.test.ts | 50 +++++ types/index.ts | 3 + 14 files changed, 644 insertions(+), 47 deletions(-) create mode 100644 lib/documents/__tests__/inbox-kind.test.ts create mode 100644 lib/documents/inbox-kind.ts create mode 100644 supabase/migrations/20260901210000_invoice_inbox_kind_hint.sql create mode 100644 tests/pg/invoice-inbox-kind-hint.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 606b1388..074e0898 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1475,6 +1475,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-01] mcp.tool_called gets errorCause = errorCauseTag(err) on the two execution catch paths only (#2051): SQLSTATE or coded-error code, else the error class name, capped at 64 chars; a plain Error deliberately tags null because the class name 'Error' is noise, and pre-execution denials pass nothing since their errorCode already IS the vocabulary. Raw driver messages stay out of event_log on purpose: a constraint-violation message can quote row values. [2026-09-01] counterparty_aliases joins the categorization_templates audit-trigger strip list (20260901200000) instead of staying logged: prod falsified the original exclusion list within 30 minutes of 20260901103000 going live (15 of the first 16 UPDATE audit rows were alias+learning noise, ~800/day projected vs ~50/day of real rule changes), because the learning path merges aliases in the same write that bumps occurrence_count. Explicit trade-off: a human editing ONLY aliases is no longer logged; accepted since alias growth is overwhelmingly automatic and any change also touching accounts/VAT/pattern/active still logs (first real one, 19:02:17Z same day, captured correctly). Pre-fix noise rows stay in audit_log (append-only) and the read model stops labelling the column so they render as no-ops. [2026-09-01] MCP catalog budget attacked at the duplicated staged envelope rather than by demoting more reads: measuring the payload by segment showed outputSchema is 38 % of the whole catalog (23 290 tokens) and STAGED_OPERATION_SCHEMA alone 14 736 of it, the same envelope transmitted 58 times, while descriptions (what the three previous rounds trimmed) are only 10 %. period_status now carries its shape in one sentence instead of declared JSON Schema, matching actor/approve/preview which were always bare objects; 2 552 tokens reclaimed with no tool demoted and no field removed. Every edit is in the LOOSER direction because the server emits structuredContent for every tool and the documented failure mode is a declaration too tight making a strict client reject a successful call. next kept additionalProperties: false: staging.test.ts pins it closed and a guard whose reason is not in front of you is not one to loosen for 420 tokens. Ceiling ratcheted to 60 000 rather than the usual ~300 margin, leaving ~1 070 deliberate working margin: server.ts took 70 commits in 14 days and the previous 116-token margin is what starts the ratchet-block-bump-demote cycle visible in the bench log. +[2026-09-01] Inbox +lev/+ver plus-addressing (#2129) stores the sender's tag in a new nullable invoice_inbox_items.kind_hint column rather than inside extracted_data.documentKind: retry-extraction overwrites that JSONB container wholesale, and the sender's statement must outlive the AI's guess. Unknown tags route with kind_hint NULL (a typo never loses a document); the type filter's two narrow entries exclude unclassified rows on purpose (a narrow filter promises a known kind; 'Alla typer' is where the rest live), and the whole type menu stays hidden until at least one row is classified so an unclassified inbox gets no dead control. [2026-09-01] Ta bort underlag (#2132): detach gate and 409 mapping live in a pure helper (components/transactions/detach-underlag.ts) with file-level assertions, not a rendered component test, because Vitest runs in node and never renders. The skeptic pass refuted the frontend-only plan: the existing DELETE attach-document route left invoice_inbox_items.matched_transaction_id set, and propagateUnderlagForBookedTransaction selects on exactly that column at booking time, so the detached receipt would have been re-anchored onto the new verifikation as immutable underlag (BFL 5 kap 7 §). The route now clears the back-link for the detached doc (scoped to items not yet consumed by a verifikat), mirroring the invoice-inbox unmatch. Accepted limitation: the history list cannot see transaction_voucher_links, so bulk-booked rows (journal_entry_id null) still show the item and get the route 409 with the storno message; the same row-classification gap already applies to Matcha mot befintlig verifikation and Ta bort on those rows, and fixing it means new list-side data plumbing, filed as follow-up with the MCP detach tool. [2026-09-01] Checklist "Anslut till Claude" done-signal = unrevoked api_keys row named MCP-klient (OAuth) for the USER, not per company: the OAuth token route is the only writer of that name and the key company_id is whatever was active at sign-in (null for companyless signups), so a company filter would miss real connections; the AI-profile flag it replaced never meant "connected to Claude" (#2133). Counted through the service client with an explicit user_id filter, not the user client: api_keys' SELECT policy is company-scoped (20260330130000), which hides companyless and archived-company keys and left the step open for exactly the user who had just connected (skeptic refutation on PR #2147). The manual create route reserves the name (400) rather than adding a source column: a migration for a cosmetic tick is not worth it, and the name is already the only marker every reader of that row uses. The consent-page default (all scopes pre-selected, founder decision 2026-08-26) is described, not changed; the compliance swarm's GDPR Art.25(2) finding on this PR targets that decision and is Emil's call, not this docs fix. [2026-09-01] PR #2130 CodeRabbit P1 (org-number twin inherits a grant): the ombud path binds SKV system-credential access to an org number, and org numbers are public and tenant-editable, so while more than one live (non-archived) company claims the same 12-digit org number NO company may verify, mint a deep link, or be granted by the nightly sync on it (409 ORG_NUMBER_CONTESTED; cron counts them as contested and changes nothing). This does not re-add the company-creation org-number guard (org-number reuse stays allowed); it only fences the one feature where the org number is the authority boundary. Also: the cron honours summarizeGrants.recognized (unknown role codes = pinning problem, never a denial), mirroring probeViaOmbudsregister. diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 7e929147..be3305c9 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -66,6 +66,12 @@ import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registr import type { InboxChannelContext, InvoiceExtractionResult, InboxItemSource } from '@/types' import { renderChannelParticipant } from '@/lib/documents/channel-context-notes' import { selectInboxFields } from '@/lib/documents/inbox-field-visibility' +import { + matchesInboxKindFilter, + resolveInboxKind, + INBOX_KIND_FILTERS, + type InboxKindFilter, +} from '@/lib/documents/inbox-kind' import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog' import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog' import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog' @@ -171,6 +177,10 @@ interface InboxItem { email_body_text: string | null document_id: string | null extracted_data: InvoiceExtractionResult | null + // Sender-declared kind from the +lev / +ver plus-address tag. A column, so + // it survives re-extraction; wins over extracted_data.documentKind for the + // row badge and the type filter. Absent on client-side placeholders. + kind_hint?: 'supplier_invoice' | 'receipt' | null matched_supplier_id: string | null matched_transaction_id: string | null created_supplier_invoice_id: string | null @@ -214,6 +224,15 @@ interface InboxAddress { status: string } +// `acme-x7f2@inbox.example` + 'lev' → `acme-x7f2+lev@inbox.example`. The +// webhook splits the local part at the first `+` and looks up what is before +// it, so the tag never changes which company the mail reaches. +function plusAddress(address: string, tag: string): string { + const at = address.indexOf('@') + if (at === -1) return address + return `${address.slice(0, at)}+${tag}${address.slice(at)}` +} + // How far the underlag behind the selected row got. // // `none` is the only state that may claim "Inget underlag bifogat": it means the @@ -407,6 +426,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const [filter, setFilter] = useState< 'todo' | 'linked' | 'booked' | 'error' | 'all' | 'missing' | 'portal' >('todo') + // Document-type filter (#2129): leverantörsfakturor vs underlag, on top of + // the status filter. Not persisted, same as the status filter. + const [kindFilter, setKindFilter] = useState('all') const [searchTerm, setSearchTerm] = useState('') // Bulk selection. Items linked to a supplier invoice are skipped at delete // time (server returns 409); we still allow them to be selected so the @@ -755,8 +777,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { ) }, [portalPurchases, otherPurchases, filter, searchTerm]) - const filteredItems = useMemo(() => { - const term = searchTerm.trim().toLowerCase() + const statusFilteredItems = useMemo(() => { if (filter === 'missing' || filter === 'portal') return [] return items.filter((item) => { // Status filter. "todo" is the active inbox: everything except booked. @@ -766,6 +787,39 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { if (filter === 'booked' && status !== 'booked') return false if (filter === 'error' && status !== 'error') return false // 'all' → no status narrowing + return true + }) + }, [items, filter]) + + // Per-kind counts for the type menu, over the status-filtered list so the + // numbers match what picking an entry would show. + const kindCounts = useMemo(() => { + const counts: Record = { + all: statusFilteredItems.length, + supplier_invoice: 0, + underlag: 0, + } + for (const item of statusFilteredItems) { + const kind = resolveInboxKind(item) + if (matchesInboxKindFilter(kind, 'supplier_invoice')) counts.supplier_invoice += 1 + else if (matchesInboxKindFilter(kind, 'underlag')) counts.underlag += 1 + } + return counts + }, [statusFilteredItems]) + + // The type menu only earns its row once something is classified (or the + // user has already narrowed): an inbox of unclassified rows has nothing to + // split. + const showKindFilter = + filter !== 'missing' && + filter !== 'portal' && + (kindFilter !== 'all' || kindCounts.supplier_invoice > 0 || kindCounts.underlag > 0) + + const filteredItems = useMemo(() => { + const term = searchTerm.trim().toLowerCase() + return statusFilteredItems.filter((item) => { + // Type filter: sender hint first, then the AI classification. + if (!matchesInboxKindFilter(resolveInboxKind(item), kindFilter)) return false // Search filter: supplier name, email subject/from, placeholder filename if (term === '') return true @@ -780,7 +834,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { .toLowerCase() return haystack.includes(term) }) - }, [items, filter, searchTerm]) + }, [statusFilteredItems, kindFilter, searchTerm]) // ── Selection ────────────────────────────────────────────── @@ -1475,6 +1529,15 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
{inboxAddress.address} + {/* Plus-addressing (#2129): the sender sorts the mail by + writing +lev or +ver before the @. Both variants spelled + out, since a tag is easier to copy than to construct. */} +

+ {t('address_plus_hint', { + lev: plusAddress(inboxAddress.address, 'lev'), + ver: plusAddress(inboxAddress.address, 'ver'), + })} +

+ {/* Document type (#2129): the Fortnox-style split between + leverantörsfakturor and bokföringsunderlag, as a second + menu in the same shape as the status one. */} + {showKindFilter && ( + + + + + + {INBOX_KIND_FILTERS.map((key) => ( + setKindFilter(key)} + className="justify-between text-xs" + > + + + {t(`kind_filter_${key}`)} + + {kindCounts[key]} + + ))} + + + )} )} {selectedIds.size > 0 && ( @@ -1736,13 +1841,19 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { beside it reads 50. */} {searchTerm.trim() !== '' ? `Inga träffar på ”${searchTerm.trim()}”.` - : filter === 'todo' - ? 'Inget att åtgärda; allt är bearbetat.' - : filter === 'portal' - ? 'Inga köp väntar på en faktura från en portal.' - : filter === 'missing' - ? 'Varje köp har sitt underlag.' - : 'Inga poster matchar filtret.'} + : kindFilter !== 'all' && filter !== 'missing' && filter !== 'portal' + // Same trap as the search term: "allt är bearbetat" would be + // false while the status trigger above still counts rows the + // type menu is hiding. Purchase lists ignore the type menu, + // so a leftover kind filter must not speak for them. + ? t('empty_no_kind_hits') + : filter === 'todo' + ? 'Inget att åtgärda; allt är bearbetat.' + : filter === 'portal' + ? 'Inga köp väntar på en faktura från en portal.' + : filter === 'missing' + ? 'Varje köp har sitt underlag.' + : 'Inga poster matchar filtret.'} ) : (
    @@ -2145,6 +2256,7 @@ function InboxRow({ const supplierName = pickSupplierName(item) const invoiceDate = pickInvoiceDate(item) const isPlaceholder = !!item.isPlaceholder + const kind = resolveInboxKind(item) const status = deriveInboxStatus(item) const isErrored = status === 'error' const isBooked = status === 'booked' @@ -2246,28 +2358,34 @@ function InboxRow({
    {isPlaceholder ? ( Tolkar dokument med AI… - ) : isExtracting ? ( + ) : ( - - - {t('processing_chip')} - - {receivedMeta} - - ) : item.extraction_skipped || hasUnansweredQuestion ? ( - - {item.extraction_skipped && ( - Inte AI-tolkad - )} - {hasUnansweredQuestion && ( - - {t('wa_question_badge')} + {/* Document kind (#2129): sender's +lev / +ver hint first, then + the AI classification. Nothing when neither is known. */} + {kind && ( + + {t(`doc_kind_${kind}`)} )} + {isExtracting ? ( + + + {t('processing_chip')} + + ) : ( + <> + {item.extraction_skipped && ( + Inte AI-tolkad + )} + {hasUnansweredQuestion && ( + + {t('wa_question_badge')} + + )} + + )} {receivedMeta} - ) : ( - receivedMeta )} {!isPlaceholder && amount != null && ( @@ -2769,6 +2887,7 @@ function FieldsRail({ const hasAi = useCapability(CAPABILITY.ai) const { appName } = useBranding() const data = item.extracted_data + const resolvedKind = resolveInboxKind(item) const [proposal, setProposal] = useState(null) const [editOpen, setEditOpen] = useState(false) // A proposal belongs to one item; carrying it to the next would offer the @@ -2930,16 +3049,18 @@ function FieldsRail({ {/* 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. */} - {(data?.documentKind || + {(resolvedKind || data?.payment?.method || data?.pages || (data?.totals?.total == null && (data?.prominentAmounts ?? []).some((a) => Number.isFinite(a.amount) && a.amount !== 0))) && (
    - {data?.documentKind && ( + {/* Same resolution as the list row (sender's +lev / +ver hint first, + then the AI), so the pane never contradicts the badge. */} + {resolvedKind && (
    {t('doc_kind_label')} - {t(`doc_kind_${data.documentKind}`)} + {t(`doc_kind_${resolvedKind}`)}
    )} {data?.payment?.method && ( diff --git a/extensions/general/invoice-inbox/__tests__/inbound-webhook.test.ts b/extensions/general/invoice-inbox/__tests__/inbound-webhook.test.ts index 59b83559..6b02b0a5 100644 --- a/extensions/general/invoice-inbox/__tests__/inbound-webhook.test.ts +++ b/extensions/general/invoice-inbox/__tests__/inbound-webhook.test.ts @@ -178,6 +178,47 @@ describe('POST /inbound', () => { expect(vi.mocked(uploadAndExtract).mock.calls[0][2]).toBe('company-9') }) + it('does not carry a shared-address tag onto a custom-domain match (#2129)', async () => { + // The tagged shared address is retired, so the custom domain resolves the + // company. The +lev tag belonged to the retired address and must not stamp + // the custom-domain company's row. + const to = ['old-inbox-abcd+lev@arcim.io', 'fakturor@hansbolag.example'] + vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent({ to, attachments: [] }) as never) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'inbox-old', company_id: 'company-old', status: 'deprecated' } }) // shared lookup + enqueue({ data: [{ company_id: 'company-9', domain: 'hansbolag.example' }] }) // verified domain + enqueue({ data: { created_by: 'user-owner-9' } }) // company owner + enqueue({ data: null }) // body-document dedupe check finds nothing + vi.mocked(createClient).mockReturnValue(supabase as never) + vi.mocked(uploadAndExtract).mockResolvedValue({ inbox_item_id: 'item-9' } as never) + vi.mocked(fetchReceivingEmail).mockResolvedValue({ + object: 'email', + id: 'em_123', + to, + from: 'billing@supplier.com', + created_at: '2026-04-20T10:00:00Z', + subject: 'Invoice #5678', + bcc: null, + cc: null, + reply_to: null, + html: null, + text: 'Body', + headers: {}, + message_id: '', + raw: null, + attachments: [], + } as never) + + const request = createMockRequest('/inbound', { method: 'POST', body: {} }) + const res = await webhookRoute.handler(request) + const body = await res.json() + expect(res.status).toBe(200) + expect(body.data.reason).toBe('email_body') + const [, , companyId, , , emailMeta] = vi.mocked(uploadAndExtract).mock.calls[0] + expect(companyId).toBe('company-9') + expect(emailMeta?.kindHint).toBeNull() + }) + it('does not route mail for an unverified custom domain', async () => { vi.mocked(verifyInboundWebhook).mockReturnValue( mockReceivedEvent({ to: ['faktura@pending-bolag.example'] }) as never @@ -279,6 +320,98 @@ describe('POST /inbound', () => { expect(res.status).toBe(404) }) + it('routes a +lev plus-address to the base inbox and hints supplier_invoice (#2129)', async () => { + vi.mocked(verifyInboundWebhook).mockReturnValue( + mockReceivedEvent({ to: ['Acme-AB-x7f2+LEV@arcim.io'] }) as never, + ) + const { supabase, enqueue, calls } = createQueuedMockSupabase() + enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } }) + enqueue({ data: { created_by: 'user-owner-1' } }) + enqueue({ data: null }) // per-attachment dup check finds nothing + vi.mocked(createClient).mockReturnValue(supabase as never) + vi.mocked(uploadAndExtract).mockResolvedValue({ inbox_item_id: 'item-lev-1' } as never) + vi.mocked(fetchReceivingEmail).mockResolvedValue({ + object: 'email', + id: 'em_123', + to: ['Acme-AB-x7f2+LEV@arcim.io'], + from: 'billing@supplier.com', + created_at: '2026-04-20T10:00:00Z', + subject: 'Faktura', + bcc: null, + cc: null, + reply_to: null, + html: null, + text: 'Se bifogad faktura', + headers: {}, + message_id: '', + raw: null, + attachments: [ + { id: 'att_1', filename: 'faktura.pdf', size: 100, content_type: 'application/pdf', content_id: 'cid', content_disposition: 'attachment' }, + ], + } as never) + vi.mocked(fetchInboundAttachment).mockResolvedValue({ + id: 'att_1', + filename: 'faktura.pdf', + contentType: 'application/pdf', + buffer: new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer as ArrayBuffer, + }) + + const request = createMockRequest('/inbound', { method: 'POST', body: {} }) + const res = await webhookRoute.handler(request) + const body = await res.json() + expect(res.status).toBe(200) + expect(body.data.results[0].inbox_item_id).toBe('item-lev-1') + + // The lookup used the local part WITHOUT the tag; before the split this + // mail 404ed as "Address not found". + const lookup = calls.find((c) => c.table === 'company_inboxes' && c.method === 'eq') + expect(lookup?.args).toEqual(['local_part', 'acme-ab-x7f2']) + + const [, , , , , emailMeta] = vi.mocked(uploadAndExtract).mock.calls[0] + expect(emailMeta?.kindHint).toBe('supplier_invoice') + }) + + it('routes an unknown plus-tag with no kind hint instead of dropping the mail (#2129)', async () => { + vi.mocked(verifyInboundWebhook).mockReturnValue( + mockReceivedEvent({ to: ['acme-ab-x7f2+faktura@arcim.io'], attachments: [] }) as never, + ) + const { supabase, enqueue, calls } = createQueuedMockSupabase() + enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } }) + enqueue({ data: { created_by: 'user-owner-1' } }) + enqueue({ data: null }) // body-document dup check finds nothing + vi.mocked(createClient).mockReturnValue(supabase as never) + vi.mocked(uploadAndExtract).mockResolvedValue({ inbox_item_id: 'item-body-1' } as never) + vi.mocked(fetchReceivingEmail).mockResolvedValue({ + object: 'email', + id: 'em_123', + to: ['acme-ab-x7f2+faktura@arcim.io'], + from: 'billing@supplier.com', + created_at: '2026-04-20T10:00:00Z', + subject: 'Kvitto', + bcc: null, + cc: null, + reply_to: null, + html: '

    Kvitto 120 kr

    ', + text: 'Kvitto 120 kr', + headers: {}, + message_id: '', + raw: null, + attachments: [], + } as never) + + const request = createMockRequest('/inbound', { method: 'POST', body: {} }) + const res = await webhookRoute.handler(request) + const body = await res.json() + expect(res.status).toBe(200) + expect(body.data.reason).toBe('email_body') + + const lookup = calls.find((c) => c.table === 'company_inboxes' && c.method === 'eq') + expect(lookup?.args).toEqual(['local_part', 'acme-ab-x7f2']) + + const [, , , , , emailMeta] = vi.mocked(uploadAndExtract).mock.calls[0] + expect(emailMeta?.kindHint).toBeNull() + }) + it('returns 410 when the address is deprecated', async () => { vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never) const { supabase, enqueue } = createQueuedMockSupabase() @@ -539,9 +672,11 @@ describe('POST /inbound', () => { expect(stored).toContain('
    Faktura 123: 500 kr
    ') }) - it('still rejects attachment types outside the email allowlist', async () => { - vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never) - const { supabase, enqueue } = createQueuedMockSupabase() + it('still rejects attachment types outside the email allowlist, keeping the sender kind hint on the error row', async () => { + vi.mocked(verifyInboundWebhook).mockReturnValue( + mockReceivedEvent({ to: ['acme-ab-x7f2+ver@arcim.io'] }) as never, + ) + const { supabase, enqueue, calls } = createQueuedMockSupabase() enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } }) enqueue({ data: { created_by: 'user-owner-1' } }) enqueue({ data: null }) // per-attachment dup check finds nothing @@ -550,7 +685,7 @@ describe('POST /inbound', () => { vi.mocked(fetchReceivingEmail).mockResolvedValue({ object: 'email', id: 'em_123', - to: ['acme-ab-x7f2@arcim.io'], + to: ['acme-ab-x7f2+ver@arcim.io'], from: 'billing@supplier.com', created_at: '2026-04-20T10:00:00Z', subject: 'Zip', @@ -579,5 +714,10 @@ describe('POST /inbound', () => { expect(res.status).toBe(200) expect(body.data.results[0].error).toBe('Unsupported type application/zip') expect(uploadAndExtract).not.toHaveBeenCalled() + + // The rejected row still carries what the sender said (#2129), so it can + // be found under the Underlag filter like any other inbox item. + const rejection = calls.find((c) => c.table === 'invoice_inbox_items' && c.method === 'insert') + expect(rejection?.args[0]).toMatchObject({ status: 'error', kind_hint: 'receipt' }) }) }) diff --git a/extensions/general/invoice-inbox/__tests__/resend-inbound.test.ts b/extensions/general/invoice-inbox/__tests__/resend-inbound.test.ts index 466afaac..86ee59e8 100644 --- a/extensions/general/invoice-inbox/__tests__/resend-inbound.test.ts +++ b/extensions/general/invoice-inbox/__tests__/resend-inbound.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest' -import { extractLocalPartForDomain, parseRecipients } from '@/extensions/general/invoice-inbox/lib/resend-inbound' +import { + extractLocalPartForDomain, + kindHintFromTag, + parseRecipients, +} from '@/extensions/general/invoice-inbox/lib/resend-inbound' describe('extractLocalPartForDomain', () => { it('returns the local part when a recipient matches the domain', () => { @@ -7,7 +11,7 @@ describe('extractLocalPartForDomain', () => { ['acme-ab-x7f2@arcim.io', 'billing@acme.se'], 'arcim.io' ) - expect(result).toBe('acme-ab-x7f2') + expect(result).toEqual({ localPart: 'acme-ab-x7f2', tag: null }) }) it('lowercases the local part and matches domain case-insensitively', () => { @@ -15,7 +19,39 @@ describe('extractLocalPartForDomain', () => { ['ACME-AB-X7F2@ARCIM.IO'], 'arcim.io' ) - expect(result).toBe('acme-ab-x7f2') + expect(result).toEqual({ localPart: 'acme-ab-x7f2', tag: null }) + }) + + it('splits a plus-address into local part and tag', () => { + expect(extractLocalPartForDomain(['acme-ab-x7f2+lev@arcim.io'], 'arcim.io')).toEqual({ + localPart: 'acme-ab-x7f2', + tag: 'lev', + }) + expect(extractLocalPartForDomain(['acme-ab-x7f2+ver@arcim.io'], 'arcim.io')).toEqual({ + localPart: 'acme-ab-x7f2', + tag: 'ver', + }) + }) + + it('lowercases the tag and splits at the first plus only', () => { + expect(extractLocalPartForDomain(['Acme-AB-x7f2+LEV+extra@arcim.io'], 'arcim.io')).toEqual({ + localPart: 'acme-ab-x7f2', + tag: 'lev+extra', + }) + }) + + it('treats an empty tag as no tag', () => { + expect(extractLocalPartForDomain(['acme-ab-x7f2+@arcim.io'], 'arcim.io')).toEqual({ + localPart: 'acme-ab-x7f2', + tag: null, + }) + }) + + it('does not read a plus in a foreign-domain recipient', () => { + expect(extractLocalPartForDomain(['x+lev@acme.se', 'acme-ab-x7f2@arcim.io'], 'arcim.io')).toEqual({ + localPart: 'acme-ab-x7f2', + tag: null, + }) }) it('returns null when no recipient matches', () => { @@ -39,7 +75,7 @@ describe('extractLocalPartForDomain', () => { ['first-abcd@arcim.io', 'second-efgh@arcim.io'], 'arcim.io' ) - expect(result).toBe('first-abcd') + expect(result?.localPart).toBe('first-abcd') }) it('trims whitespace inside candidate addresses', () => { @@ -47,7 +83,22 @@ describe('extractLocalPartForDomain', () => { [' acme-xxx@arcim.io '], 'arcim.io' ) - expect(result).toBe('acme-xxx') + expect(result?.localPart).toBe('acme-xxx') + }) +}) + +describe('kindHintFromTag', () => { + it('maps the two documented tags', () => { + expect(kindHintFromTag('lev')).toBe('supplier_invoice') + expect(kindHintFromTag('ver')).toBe('receipt') + }) + + it('returns null for unknown, empty or missing tags', () => { + expect(kindHintFromTag('faktura')).toBeNull() + expect(kindHintFromTag('lev+extra')).toBeNull() + expect(kindHintFromTag('')).toBeNull() + expect(kindHintFromTag(null)).toBeNull() + expect(kindHintFromTag(undefined)).toBeNull() }) }) diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index 39cac73d..5dd60c60 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -34,6 +34,8 @@ import { fetchReceivingEmail, fetchInboundAttachment, extractLocalPartForDomain, + kindHintFromTag, + type InboxKindHint, parseRecipients, isEmailReceivedEvent, ResendSignatureError, @@ -599,7 +601,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, channel_context + resend_email_id, extraction_skipped, channel_context, kind_hint `) .eq('company_id', ctx.companyId) .order('created_at', { ascending: false }) @@ -1784,7 +1786,14 @@ export const invoiceInboxExtension: Extension = { let companyId: string | null = null let sharedInboxStatus: string | null = null - const localPart = extractLocalPartForDomain(to, domain) + const sharedRecipient = extractLocalPartForDomain(to, domain) + const localPart = sharedRecipient?.localPart ?? null + // Sender-declared kind from the +lev / +ver tag on the shared + // address. Set only when that address is the one that resolved the + // company: a tag on an unknown or retired shared address must not + // ride along onto a custom-domain match further down. Custom domains + // are catch-all and stay unhinted. + let kindHint: InboxKindHint | null = null if (localPart) { const { data: inbox } = await serviceSupabase .from('company_inboxes') @@ -1793,7 +1802,10 @@ export const invoiceInboxExtension: Extension = { .maybeSingle() if (inbox) { sharedInboxStatus = inbox.status - if (inbox.status === 'active') companyId = inbox.company_id + if (inbox.status === 'active') { + companyId = inbox.company_id + kindHint = kindHintFromTag(sharedRecipient?.tag) + } } } @@ -1943,6 +1955,7 @@ export const invoiceInboxExtension: Extension = { messageId: message_id, bodyText, resendEmailId: email_id, + kindHint, } ) return NextResponse.json( @@ -1963,6 +1976,7 @@ export const invoiceInboxExtension: Extension = { email_received_at: created_at, email_body_text: bodyText, resend_email_id: email_id, + kind_hint: kindHint, error_message: 'Email had no attachments', raw_email_payload: { messageId: message_id }, }) @@ -1997,6 +2011,7 @@ export const invoiceInboxExtension: Extension = { email_body_text: bodyText, resend_email_id: email_id, resend_attachment_id: attachmentId, + kind_hint: kindHint, error_message: reason.slice(0, 500), raw_email_payload: { messageId: message_id, @@ -2059,6 +2074,7 @@ export const invoiceInboxExtension: Extension = { bodyText, resendEmailId: email_id, resendAttachmentId: att.id, + kindHint, } ) results.push({ attachment_id: att.id, inbox_item_id: innerBodyResult.inbox_item_id }) @@ -2103,6 +2119,7 @@ export const invoiceInboxExtension: Extension = { bodyText, resendEmailId: email_id, resendAttachmentId: innerId, + kindHint, } ) results.push({ attachment_id: innerId, inbox_item_id: innerResult.inbox_item_id }) @@ -2142,6 +2159,7 @@ export const invoiceInboxExtension: Extension = { bodyText, resendEmailId: email_id, resendAttachmentId: att.id, + kindHint, } ) results.push({ attachment_id: att.id, inbox_item_id: result.inbox_item_id }) diff --git a/extensions/general/invoice-inbox/lib/resend-inbound.ts b/extensions/general/invoice-inbox/lib/resend-inbound.ts index 03e70ffe..da77fac1 100644 --- a/extensions/general/invoice-inbox/lib/resend-inbound.ts +++ b/extensions/general/invoice-inbox/lib/resend-inbound.ts @@ -81,19 +81,50 @@ export async function fetchInboundAttachment( } } -// Parses the first recipient whose domain matches our configured inbound domain, -// returning just the local_part. Returns null if no match. -export function extractLocalPartForDomain(recipients: string[], domain: string): string | null { +export interface SharedInboxRecipient { + /** The company_inboxes.local_part candidate, lowercased, without any +tag. */ + localPart: string + /** Plus-address tag (the part after the first `+`), lowercased; null when absent or empty. */ + tag: string | null +} + +// Parses the first recipient whose domain matches our configured inbound +// domain, returning its local_part split at the first `+` (RFC 5233 +// sub-addressing): `acme-x7f2+lev@inbox` matches the inbox row for +// `acme-x7f2` and carries tag `lev`. Before this split a +tagged mail 404ed, +// because the whole `acme-x7f2+lev` was looked up as the local_part. +// Returns null if no recipient is on the domain. +export function extractLocalPartForDomain( + recipients: string[], + domain: string, +): SharedInboxRecipient | null { const normalized = domain.toLowerCase() for (const addr of recipients) { const match = addr.match(/^\s*([^@\s]+)@([^@\s]+?)\s*$/) if (!match) continue - const [, localPart, addrDomain] = match - if (addrDomain.toLowerCase() === normalized) return localPart.toLowerCase() + const [, rawLocal, addrDomain] = match + if (addrDomain.toLowerCase() !== normalized) continue + const lower = rawLocal.toLowerCase() + const plus = lower.indexOf('+') + if (plus === -1) return { localPart: lower, tag: null } + const tag = lower.slice(plus + 1) + return { localPart: lower.slice(0, plus), tag: tag.length > 0 ? tag : null } } return null } +/** Sender-declared document kind, stored on invoice_inbox_items.kind_hint. */ +export type InboxKindHint = 'supplier_invoice' | 'receipt' + +// Maps the plus-address tag to a kind hint. `+lev` (leverantörsfaktura) and +// `+ver` (verifikation/underlag) are the two documented tags; anything else +// routes to the inbox with no hint, so a typo never loses a document. +export function kindHintFromTag(tag: string | null | undefined): InboxKindHint | null { + if (tag === 'lev') return 'supplier_invoice' + if (tag === 'ver') return 'receipt' + return null +} + // Splits every parseable recipient into { localPart, domain }, lowercased and // in original order. Used to match recipients against per-company verified // custom domains when none of them is on the shared inbound domain. diff --git a/extensions/general/invoice-inbox/lib/upload-and-extract.ts b/extensions/general/invoice-inbox/lib/upload-and-extract.ts index 589fb77b..369c9f8e 100644 --- a/extensions/general/invoice-inbox/lib/upload-and-extract.ts +++ b/extensions/general/invoice-inbox/lib/upload-and-extract.ts @@ -2,6 +2,7 @@ import { after } from 'next/server' import { uploadDocument } from '@/lib/core/documents/document-service' import { extractInvoiceFields, emptyResult, fetchOwnCompanyIdentity } from './extract-invoice-fields' import { mirrorExtractionToDocument } from './mirror-extraction' +import type { InboxKindHint } from './resend-inbound' import { getAiStatus } from '@/lib/ai' import { hasCapability } from '@/lib/entitlements/has-capability' import { CAPABILITY } from '@/lib/entitlements/keys' @@ -191,6 +192,10 @@ export interface EmailMeta { bodyText?: string | null resendEmailId?: string | null resendAttachmentId?: string | null + // Sender-declared document kind from the +lev / +ver plus-address tag. + // Lands in its own column (not extracted_data) so re-extraction cannot + // overwrite what the sender said. + kindHint?: InboxKindHint | null } // Chat-channel provenance (whatsapp-inbox extension). When present, the inbox @@ -454,6 +459,7 @@ export async function processArchivedDocument( email_body_text: emailMeta?.bodyText || null, resend_email_id: emailMeta?.resendEmailId || null, resend_attachment_id: emailMeta?.resendAttachmentId || null, + kind_hint: emailMeta?.kindHint ?? null, raw_email_payload: emailMeta?.messageId ? { messageId: emailMeta.messageId, filename: file.name } : null, @@ -548,6 +554,7 @@ export async function processArchivedDocument( email_body_text: emailMeta?.bodyText || null, resend_email_id: emailMeta?.resendEmailId || null, resend_attachment_id: emailMeta?.resendAttachmentId || null, + kind_hint: emailMeta?.kindHint ?? null, raw_email_payload: emailMeta?.messageId ? { messageId: emailMeta.messageId, filename: file.name } : null, diff --git a/lib/documents/__tests__/inbox-kind.test.ts b/lib/documents/__tests__/inbox-kind.test.ts new file mode 100644 index 00000000..44a3a29f --- /dev/null +++ b/lib/documents/__tests__/inbox-kind.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { + INBOX_KIND_FILTERS, + matchesInboxKindFilter, + resolveInboxKind, + type InboxDocumentKind, +} from '@/lib/documents/inbox-kind' + +describe('resolveInboxKind', () => { + it.each(['receipt', 'supplier_invoice', 'government_letter', 'other'] as const)( + 'returns the AI documentKind %s when there is no sender hint', + (kind) => { + expect(resolveInboxKind({ extracted_data: { documentKind: kind } })).toBe(kind) + expect(resolveInboxKind({ kind_hint: null, extracted_data: { documentKind: kind } })).toBe(kind) + }, + ) + + it('returns null when nothing is classified', () => { + expect(resolveInboxKind({})).toBeNull() + expect(resolveInboxKind({ extracted_data: null })).toBeNull() + expect(resolveInboxKind({ extracted_data: {} })).toBeNull() + expect(resolveInboxKind({ extracted_data: { documentKind: null } })).toBeNull() + }) + + it('returns null for a documentKind outside the vocabulary', () => { + expect(resolveInboxKind({ extracted_data: { documentKind: 'parking_ticket' } })).toBeNull() + expect(resolveInboxKind({ kind_hint: 'invoice' })).toBeNull() + }) + + it('lets the sender hint win over the AI classification', () => { + expect( + resolveInboxKind({ kind_hint: 'supplier_invoice', extracted_data: { documentKind: 'receipt' } }), + ).toBe('supplier_invoice') + expect( + resolveInboxKind({ kind_hint: 'receipt', extracted_data: { documentKind: 'supplier_invoice' } }), + ).toBe('receipt') + }) + + it('uses the sender hint even before extraction has landed', () => { + expect(resolveInboxKind({ kind_hint: 'receipt', extracted_data: null })).toBe('receipt') + }) +}) + +describe('matchesInboxKindFilter', () => { + const kinds: Array = [ + 'receipt', + 'supplier_invoice', + 'government_letter', + 'other', + null, + ] + + it("'all' passes every kind including unclassified", () => { + for (const kind of kinds) expect(matchesInboxKindFilter(kind, 'all')).toBe(true) + }) + + it("'supplier_invoice' passes only supplier invoices", () => { + expect(matchesInboxKindFilter('supplier_invoice', 'supplier_invoice')).toBe(true) + expect(matchesInboxKindFilter('receipt', 'supplier_invoice')).toBe(false) + expect(matchesInboxKindFilter('government_letter', 'supplier_invoice')).toBe(false) + expect(matchesInboxKindFilter('other', 'supplier_invoice')).toBe(false) + expect(matchesInboxKindFilter(null, 'supplier_invoice')).toBe(false) + }) + + it("'underlag' passes receipt, government_letter and other", () => { + expect(matchesInboxKindFilter('receipt', 'underlag')).toBe(true) + expect(matchesInboxKindFilter('government_letter', 'underlag')).toBe(true) + expect(matchesInboxKindFilter('other', 'underlag')).toBe(true) + expect(matchesInboxKindFilter('supplier_invoice', 'underlag')).toBe(false) + }) + + it('keeps unclassified items out of both narrow filters', () => { + expect(matchesInboxKindFilter(null, 'underlag')).toBe(false) + expect(matchesInboxKindFilter(null, 'supplier_invoice')).toBe(false) + }) + + it('exposes the three filters in menu order', () => { + expect(INBOX_KIND_FILTERS).toEqual(['all', 'supplier_invoice', 'underlag']) + }) +}) diff --git a/lib/documents/inbox-kind.ts b/lib/documents/inbox-kind.ts new file mode 100644 index 00000000..8236a277 --- /dev/null +++ b/lib/documents/inbox-kind.ts @@ -0,0 +1,63 @@ +/** + * Which kind of document an inbox item is, for the list-row badge and the + * type filter (issue #2129: "one Inkorg view that shows whether each row is + * a leverantörsfaktura or bokföringsunderlag"). + * + * Two sources, in priority order: + * 1. kind_hint: what the sender declared through the +lev / +ver + * plus-address tag. A column, so it survives re-extraction. + * 2. extracted_data.documentKind: the AI classification. + * + * Anything outside the known vocabulary resolves to null and shows nothing, + * rather than guessing. + * + * React-free on purpose: this repo has no jsdom or testing-library, so the + * predicate is tested here rather than through the component. + */ + +export const INBOX_DOCUMENT_KINDS = [ + 'receipt', + 'supplier_invoice', + 'government_letter', + 'other', +] as const + +export type InboxDocumentKind = (typeof INBOX_DOCUMENT_KINDS)[number] + +/** + * 'underlag' is everything that is not a supplier invoice: receipts, + * government letters and other documents all book as bokföringsunderlag. + */ +export type InboxKindFilter = 'all' | 'supplier_invoice' | 'underlag' + +export const INBOX_KIND_FILTERS: readonly InboxKindFilter[] = ['all', 'supplier_invoice', 'underlag'] + +export interface InboxKindSource { + kind_hint?: string | null + extracted_data?: { documentKind?: string | null } | null +} + +function isInboxDocumentKind(value: unknown): value is InboxDocumentKind { + return typeof value === 'string' && (INBOX_DOCUMENT_KINDS as readonly string[]).includes(value) +} + +/** The kind to show for an item: the sender's hint first, then the AI's. */ +export function resolveInboxKind(item: InboxKindSource): InboxDocumentKind | null { + if (isInboxDocumentKind(item.kind_hint)) return item.kind_hint + const aiKind = item.extracted_data?.documentKind + return isInboxDocumentKind(aiKind) ? aiKind : null +} + +/** + * Whether a resolved kind passes the type filter. An unclassified item + * (null) only passes 'all': the narrow filters promise a known kind. + */ +export function matchesInboxKindFilter( + kind: InboxDocumentKind | null, + filter: InboxKindFilter, +): boolean { + if (filter === 'all') return true + if (kind === null) return false + if (filter === 'supplier_invoice') return kind === 'supplier_invoice' + return kind !== 'supplier_invoice' +} diff --git a/messages/en.json b/messages/en.json index e7df2b6e..d177ade6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3601,6 +3601,11 @@ "doc_kind_supplier_invoice": "Supplier invoice", "doc_kind_government_letter": "Government letter", "doc_kind_other": "Other document", + "kind_filter_all": "All types", + "kind_filter_supplier_invoice": "Supplier invoices", + "kind_filter_underlag": "Receipts and other", + "empty_no_kind_hits": "No items of that type here. Pick All types to see the rest.", + "address_plus_hint": "Add +lev before the @ for supplier invoices and +ver for receipts: {lev} or {ver}", "payment_label": "Paid with", "payment_card": "Card", "payment_swish": "Swish", diff --git a/messages/sv.json b/messages/sv.json index 74e7c39b..9646dc3d 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3601,6 +3601,11 @@ "doc_kind_supplier_invoice": "Leverantörsfaktura", "doc_kind_government_letter": "Myndighetsbrev", "doc_kind_other": "Övrigt dokument", + "kind_filter_all": "Alla typer", + "kind_filter_supplier_invoice": "Leverantörsfakturor", + "kind_filter_underlag": "Underlag", + "empty_no_kind_hits": "Inga poster av den typen här. Välj Alla typer för att se resten.", + "address_plus_hint": "Skriv +lev före @ för leverantörsfakturor och +ver för underlag: {lev} eller {ver}", "payment_label": "Betalsätt", "payment_card": "Kort", "payment_swish": "Swish", diff --git a/supabase/migrations/20260901210000_invoice_inbox_kind_hint.sql b/supabase/migrations/20260901210000_invoice_inbox_kind_hint.sql new file mode 100644 index 00000000..ea33eded --- /dev/null +++ b/supabase/migrations/20260901210000_invoice_inbox_kind_hint.sql @@ -0,0 +1,22 @@ +-- Sender-declared document kind on inbox items (issue #2129). +-- +-- The shared inbox address now accepts plus-addressing: a mail sent to +-- +lev@ is a leverantörsfaktura, +ver is bokföringsunderlag +-- (kvitto). The tag is the sender's statement, so it lives in its own column +-- rather than inside extracted_data: retry-extraction overwrites that JSONB +-- container wholesale, and the sender's choice must survive it. The inbox UI +-- lets kind_hint win over the AI's extracted_data.documentKind for the row +-- badge and the type filter. +-- +-- Nullable on purpose: every existing row and every untagged mail stays +-- unhinted and keeps showing the AI classification. + +ALTER TABLE public.invoice_inbox_items + ADD COLUMN IF NOT EXISTS kind_hint text NULL + CONSTRAINT invoice_inbox_items_kind_hint_check + CHECK (kind_hint IN ('supplier_invoice', 'receipt')); + +COMMENT ON COLUMN public.invoice_inbox_items.kind_hint IS + 'Sender-declared document kind from the +lev / +ver plus-address tag. Wins over extracted_data.documentKind in the inbox UI. NULL when the sender said nothing.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/invoice-inbox-kind-hint.pg.test.ts b/tests/pg/invoice-inbox-kind-hint.pg.test.ts new file mode 100644 index 00000000..93955cb0 --- /dev/null +++ b/tests/pg/invoice-inbox-kind-hint.pg.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { getPool } from './setup' +import { seedCompany } from './fixtures' + +/** + * invoice_inbox_items.kind_hint (migration 20260901210000, issue #2129). + * + * The plus-address tag on the shared inbox address (+lev / +ver) lands here + * as the sender's declared document kind. These tests pin the two accepted + * values, that the CHECK refuses anything else, and that the column is + * nullable with a NULL default so untagged mail and pre-migration rows are + * untouched. + */ +describe('invoice_inbox_items.kind_hint (pg)', () => { + it.each(['supplier_invoice', 'receipt'])('accepts %s', async (hint) => { + const { userId, companyId } = await seedCompany() + + const { rows } = await getPool().query<{ kind_hint: string }>( + `INSERT INTO public.invoice_inbox_items (company_id, user_id, source, kind_hint) + VALUES ($1, $2, 'email', $3) + RETURNING kind_hint`, + [companyId, userId, hint], + ) + expect(rows[0].kind_hint).toBe(hint) + }) + + it('refuses a hint outside the two documented tags', async () => { + const { userId, companyId } = await seedCompany() + + await expect( + getPool().query( + `INSERT INTO public.invoice_inbox_items (company_id, user_id, source, kind_hint) + VALUES ($1, $2, 'email', 'government_letter')`, + [companyId, userId], + ), + ).rejects.toThrow(/invoice_inbox_items_kind_hint_check|violates check constraint/i) + }) + + it('defaults to NULL when the writer says nothing', async () => { + const { userId, companyId } = await seedCompany() + + const { rows } = await getPool().query<{ kind_hint: string | null }>( + `INSERT INTO public.invoice_inbox_items (company_id, user_id, source) + VALUES ($1, $2, 'upload') + RETURNING kind_hint`, + [companyId, userId], + ) + expect(rows[0].kind_hint).toBeNull() + }) +}) diff --git a/types/index.ts b/types/index.ts index 31019a74..0e18f83c 100644 --- a/types/index.ts +++ b/types/index.ts @@ -3118,6 +3118,9 @@ export interface InvoiceInboxItem { email_body_text: string | null resend_email_id: string | null resend_attachment_id: string | null + // Sender-declared kind from the +lev / +ver plus-address tag (migration + // 20260901210000). Wins over extracted_data.documentKind in the inbox UI. + kind_hint?: 'supplier_invoice' | 'receipt' | null document_id: string | null extracted_data: Record | null matched_supplier_id: string | null