fix(inbox): never extract the receiving company as its own supplier (#2080)

* fix(inbox): never extract the receiving company as its own supplier

The model sometimes reads the Kund/Kunduppgifter block of bank agreements
and similar documents as the issuer, so the inbox offered to create the
user's own company as a leverantor. Two layers: a supplier-direction rule
in the extraction prompt (issuer, never the customer/recipient block), and
a deterministic post-extraction guard that nulls the supplier block when
its org number, derived VAT number, or exact name equals the receiving
company's own. All AI extraction paths (upload, deferred, retry,
attach-document, document-extraction, MCP) pass the company identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb

* fix(inbox): keep the own-company guard alive for photographed documents

Skeptic findings on PR #2080: normalizeImageForExtraction rebuilt the input
without ownCompany, so the guard never fired for HEIC/oversized phone photos
(the motivating case). Spread the original input instead. Also let a
provably different extracted org number outvote a name coincidence, and
accept 12-digit personnummer-form own org numbers (19/20 century prefix,
enskild firma) alongside the 16-prefixed organisation form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb

* docs(compliance): add RoPA entry for the own-company-identity lookup

Compliance swarm (GDPR Art. 30): fetchOwnCompanyIdentity reads
companies.name and org_number on every extraction; record the processing
activity (transient in-memory comparison, never sent to the model).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb

* fix(inbox): log own-company identity lookup failures instead of failing silent

Compliance swarm (SOC 2 CC7.2): the bare catch in fetchOwnCompanyIdentity
made a persistently broken lookup (RLS misconfig, DB outage) disable the
guard invisibly. Also pin the VAT-number match across prefixed and bare
digit forms in the test matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb

* fix(inbox): surface maybeSingle query errors in the own-company lookup

CodeRabbit: maybeSingle() reports RLS/query failures in error without
throwing, so the failure log added for SOC 2 CC7.2 never fired for exactly
those cases. Throw the reported error into the existing catch (still fail
open to nulls).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-31 15:13:14 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent fc54e79e08
commit 84c8e1ce59
10 changed files with 369 additions and 7 deletions
@@ -10,6 +10,7 @@ vi.mock('@/lib/supabase/server', () => ({
const extractMock = vi.fn()
vi.mock('@/extensions/general/invoice-inbox/lib/extract-invoice-fields', () => ({
extractInvoiceFields: (...args: unknown[]) => extractMock(...args),
fetchOwnCompanyIdentity: vi.fn().mockResolvedValue({ orgNumber: null, name: null }),
}))
const hasCapabilityMock = vi.fn()
@@ -1,6 +1,6 @@
import type { Extension } from '@/lib/extensions/types'
import type { SupabaseClient } from '@supabase/supabase-js'
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
import { extractInvoiceFields, fetchOwnCompanyIdentity } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
import { getAiStatus } from '@/lib/ai'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
@@ -176,6 +176,7 @@ async function extractAndPersist(
buffer,
mimeType,
fileName: (document.file_name as string) || 'document',
ownCompany: await fetchOwnCompanyIdentity(supabase, companyId),
})
// extractInvoiceFields returns an "empty" result on failure rather than
// throwing. `skipped` means no model call was made (and why); a null