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
parent fc54e79e08
commit 84c8e1ce59
10 changed files with 369 additions and 7 deletions
+38
View File
@@ -856,3 +856,41 @@ processing_activities:
- claim_based_once_per_day_dedup
- company_name_header_injection_sanitized
- cron_secret_authenticated_trigger
- id: documents.own_company_supplier_guard
name: Egenbolagskontroll vid dokumentextraktion
purpose: >-
Jämföra AI-extraherade leverantörsfält mot det egna bolagets namn och
organisationsnummer (för enskild firma ett personnummer) så att
användarens eget bolag aldrig föreslås som leverantör när modellen läst
dokumentets kundblock. Jämförelsen sker i applikationskoden efter
extraktionen; identiteten skickas aldrig till AI-modellen och skrivs
inte till någon ny lagringsyta.
lawful_basis: art_6_1_f
special_category_basis: null
controller: gnubok-tenant
processor: supabase
data_subjects:
- business_owner
data_categories:
- user.name # companies.name
- user.government_id # companies.org_number, personnummer för enskild firma
recipients:
- name: Supabase
country: EU
role: processor
international_transfers:
applicable: false
mechanism: null
note: >-
Transient jämförelse i applikationsprocessen; datat lämnar aldrig
befintlig lagring (companies-tabellen) och skickas inte till Bedrock.
retention:
duration: none
basis: transient_in_memory_comparison
stored_in: []
security_measures:
- rls_company_scoped
- not_sent_to_model
- transient_comparison_no_new_storage
- fail_open_to_nulls_on_lookup_error
+1
View File
@@ -1384,5 +1384,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-31] Login/register methods come from GoTrue (/auth/v1/settings + admin customProviders) instead of app-side flags; NEXT_PUBLIC_GOOGLE_AUTH_ENABLED removed (PR #1869): the Supabase dashboard becomes the single switch, an allowlist of auth-js provider ids filters non-login entries like anonymous_users, and hosted rendering is unchanged because Google is enabled in prod GoTrue. The Vercel env var stays set for old-build rollback safety; delete it after a few deploys.
[2026-08-31] Single prominent amount is PROMOTED into editable totals.total (totalSource='prominent') instead of living in a read-only Belopp row: Emil's call, an uncorrectable load-bearing value violated the prefill-override-editors rule. Provenance keeps matching fallback-grade (discount, date guard, hunt exclusion); a user edit of TOTALT clears the stamp. Multi-amount docs keep the Belopp row: promoting one of several figures would invent a total.
[2026-08-31] Image-scan red fixed by bumping the node:22-alpine digest (alpine 3.23 to 3.24.1), not by widening the gate: the Dockerfile's apk-upgrade layer is frozen by the GHCR buildx layer cache, so a fix published after the last cache-busting change (libssl3 3.5.8-r0 for CVE-2026-14456) never reaches the published image until the FROM digest moves; the red scheduled scan is the designed alarm for exactly this bump. cron.Dockerfile gained the same apk upgrade (it had none).
[2026-08-31] Own-company-as-supplier guard nulls the supplier block instead of flagging or substituting the issuer: an empty LEVERANTOR is always safe, a guessed issuer is not; BYO/agent-supplied extraction paths are deliberately exempt (explicit input, not a model misread).
[2026-08-31] gnubok-home-ok cache cookie is user-scoped (userId~host) instead of cleared on sign-out: sign-out happens client-side via supabase.auth.signOut so no server surface reliably sees it, while a value bound to the session's user makes any inherited verdict miss the cache by construction. Separator ~ because it is unreserved under encodeURIComponent AND a legal raw cookie octet, so the value round-trips identically whether or not the cookie layer percent-encodes. Old host-only cookies never match and self-heal; found via the amnas account-switch repro (two logins 9 s apart shared the verdict).
[2026-08-31] Bookkeeping digest email is per-user per-COMPANY per-day (not one aggregated mail across companies): notification_log.company_id anchors the claim, subject lines stay unambiguous, and most users have one company; consultants can opt in and get one short mail per client. Window is a fixed last-24h (cron cadence) rather than tracking last-sent state. Settings toggle stays hardcoded Swedish like the rest of the push-notifications extension UI (no next-intl wiring in extension components); revisit if that surface is ever translated.
@@ -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
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'
import {
extractInvoiceFields,
extractJsonObject,
stripOwnCompanyAsSupplier,
emptyResult,
} from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
// Mock the Bedrock SDK so tests drive the JSON parser without
@@ -610,6 +612,75 @@ describe('extractInvoiceFields', () => {
expect(content[0].source.media_type).toBe('image/jpeg')
})
// ── Own-company-as-supplier guard (2026-08) ──────────
it('strips the supplier when the model extracted the receiving company itself', async () => {
// A bank agreement's Kunduppgifter block: the model read the customer
// (the user's own company) as the issuer.
mockCreate.mockReturnValueOnce(
aiResponse({
...VALID_RESULT,
documentKind: 'other',
supplier: {
name: 'Testbrand AB',
orgNumber: '5566778899',
vatNumber: null,
address: 'Provgatan 1, 111 11 Teststad',
bankgiro: null,
plusgiro: null,
},
})
)
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'affarsavtal.pdf',
ownCompany: { orgNumber: '556677-8899', name: 'Testbrand AB' },
})
expect(data.supplier).toEqual({
name: null,
orgNumber: null,
vatNumber: null,
address: null,
bankgiro: null,
plusgiro: null,
})
// Only the supplier block is affected.
expect(data.totals.total).toBe(6.25)
})
it('still strips the own company on the image-normalization path (photographed documents)', async () => {
// normalizeImageForExtraction rebuilds the input for HEIC/oversized
// photos; ownCompany must survive that rebuild or the guard is dead for
// exactly the phone-photo documents the fix targets.
sharpMock.mockImplementationOnce(() => workingSharpChain(Buffer.from('converted-jpeg')))
mockCreate.mockReturnValueOnce(
aiResponse({
...VALID_RESULT,
supplier: { ...VALID_RESULT.supplier, name: 'Testbrand AB', orgNumber: '5566778899' },
})
)
const { data } = await extractInvoiceFields({
buffer: Buffer.alloc(5 * 1024 * 1024, 1),
mimeType: 'image/jpeg',
fileName: 'photo-of-avtal.jpg',
ownCompany: { orgNumber: '556677-8899', name: 'Testbrand AB' },
})
expect(data.supplier.name).toBeNull()
expect(data.supplier.orgNumber).toBeNull()
})
it('leaves a genuine supplier untouched when ownCompany is passed', async () => {
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'invoice.pdf',
ownCompany: { orgNumber: '556677-8899', name: 'Testbrand AB' },
})
expect(data.supplier.name).toBe('Anthropic, PBC')
})
it('does not invoke sharp for normal-sized supported images', async () => {
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
await extractInvoiceFields({
@@ -628,3 +699,128 @@ describe('extractInvoiceFields', () => {
else delete process.env.AWS_SECRET_ACCESS_KEY
})
})
describe('stripOwnCompanyAsSupplier', () => {
function withSupplier(supplier: Partial<ReturnType<typeof emptyResult>['supplier']>) {
const base = emptyResult()
return { ...base, supplier: { ...base.supplier, ...supplier } }
}
const strippedSupplier = {
name: null,
orgNumber: null,
vatNumber: null,
address: null,
bankgiro: null,
plusgiro: null,
}
const own = { orgNumber: '556677-8899', name: 'Testbrand AB' }
it('matches the org number across hyphen and 12-digit variants', () => {
for (const extracted of ['5566778899', '556677-8899', '165566778899', '16556677-8899']) {
const result = stripOwnCompanyAsSupplier(
withSupplier({ name: 'Något AB', orgNumber: extracted }),
own
)
expect(result.supplier, `orgNumber ${extracted}`).toEqual(strippedSupplier)
}
})
it('matches the derived Swedish VAT number (SE<orgnr>01)', () => {
// Both the full prefixed form and a bare digits form denote the same
// registration: digitsOf strips the SE prefix before comparing.
for (const vat of ['SE556677889901', '556677889901', 'SE 556677-8899 01']) {
const result = stripOwnCompanyAsSupplier(
withSupplier({ name: 'Något AB', vatNumber: vat }),
own
)
expect(result.supplier, `vatNumber ${vat}`).toEqual(strippedSupplier)
}
})
it('matches the exact company name case-insensitively', () => {
const result = stripOwnCompanyAsSupplier(withSupplier({ name: ' testbrand ab ' }), own)
expect(result.supplier).toEqual(strippedSupplier)
})
it('matches a personnummer-form own org number (enskild firma)', () => {
// companies.org_number for enskild firma is the owner's personnummer,
// often stored in 12-digit century form.
const result = stripOwnCompanyAsSupplier(
withSupplier({ name: 'Firma X', orgNumber: '550505-5566' }),
{ orgNumber: '195505055566', name: 'Firma X Enskild' }
)
expect(result.supplier).toEqual(strippedSupplier)
})
it('lets a provably different org number outvote a name coincidence', () => {
// A same-named but distinct entity (foreign registry, generic name) with
// its own org number on the document stays a valid supplier.
const supplier = {
name: 'Testbrand AB',
orgNumber: '5029032081',
vatNumber: null,
address: null,
bankgiro: null,
plusgiro: null,
}
expect(stripOwnCompanyAsSupplier(withSupplier(supplier), own).supplier).toEqual(supplier)
})
it('leaves a different supplier alone', () => {
const supplier = {
name: 'SEB',
orgNumber: '5029032081',
vatNumber: null,
address: null,
bankgiro: null,
plusgiro: null,
}
expect(stripOwnCompanyAsSupplier(withSupplier(supplier), own).supplier).toEqual(supplier)
})
it('never matches on an empty own identity (junk cannot match junk)', () => {
const result = stripOwnCompanyAsSupplier(
withSupplier({ name: 'Något AB', orgNumber: null }),
{ orgNumber: null, name: null }
)
expect(result.supplier.name).toBe('Något AB')
})
it('fetchOwnCompanyIdentity degrades to nulls on a reported query error (fail open, logged)', async () => {
const { fetchOwnCompanyIdentity } = await import(
'@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
)
const supabase = {
from: () => ({
select: () => ({
eq: () => ({
maybeSingle: () =>
Promise.resolve({ data: null, error: new Error('permission denied') }),
}),
}),
}),
}
await expect(
fetchOwnCompanyIdentity(supabase as never, 'company-1')
).resolves.toEqual({ orgNumber: null, name: null })
})
it('is a no-op without ownCompany', () => {
const data = withSupplier({ name: 'Testbrand AB', orgNumber: '5566778899' })
expect(stripOwnCompanyAsSupplier(data, undefined)).toBe(data)
})
it('preserves every non-supplier field when stripping', () => {
const base = withSupplier({ name: 'Testbrand AB' })
const data = {
...base,
documentKind: 'other' as const,
totals: { ...base.totals, total: 2500 },
prominentAmounts: [{ amount: 2500, label: 'Engångspris' }],
}
const result = stripOwnCompanyAsSupplier(data, own)
expect(result.documentKind).toBe('other')
expect(result.totals.total).toBe(2500)
expect(result.prominentAmounts).toEqual([{ amount: 2500, label: 'Engångspris' }])
})
})
@@ -9,6 +9,7 @@ import type { ExtensionContext } from '@/lib/extensions/types'
vi.mock('@/extensions/general/invoice-inbox/lib/extract-invoice-fields', () => ({
extractInvoiceFields: vi.fn(),
fetchOwnCompanyIdentity: vi.fn().mockResolvedValue({ orgNumber: null, name: null }),
}))
vi.mock('@/lib/rate-limits/inbox', () => ({
+3 -1
View File
@@ -12,7 +12,7 @@ import { validateBody } from '@/lib/api/validate'
import { hasErrorEntry } from '@/lib/errors/structured-errors'
import { dbError } from '@/lib/errors/db-error'
import { matchSupplierId } from '@/lib/suppliers/match-supplier'
import { extractInvoiceFields, ExtractionSchema, emptyResult } from './lib/extract-invoice-fields'
import { extractInvoiceFields, ExtractionSchema, emptyResult, fetchOwnCompanyIdentity } from './lib/extract-invoice-fields'
import { mirrorExtractionToDocument } from './lib/mirror-extraction'
import {
uploadAndExtract,
@@ -1081,6 +1081,7 @@ export const invoiceInboxExtension: Extension = {
buffer: Buffer.from(slicedBuffer ?? buffer),
mimeType: file.type,
fileName: file.name,
ownCompany: await fetchOwnCompanyIdentity(ctx.supabase, ctx.companyId),
})
const { data: extracted } = extraction
if (!skipExtraction && slicedBuffer != null && pageCount != null) {
@@ -1448,6 +1449,7 @@ export const invoiceInboxExtension: Extension = {
buffer,
mimeType: doc.mime_type,
fileName: doc.file_name,
ownCompany: await fetchOwnCompanyIdentity(ctx.supabase, ctx.companyId),
})
const { data: extracted } = extraction
await mirrorExtractionToDocument(item.document_id, {
@@ -14,6 +14,7 @@
import { createHash } from 'node:crypto'
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { InvoiceExtractionResult } from '@/types'
import { getAiService, readAiConfig, extractJsonObject } from '@/lib/ai'
import type { AiDocumentInput, AiImageMediaType, ExtractionSkipReason } from '@/lib/ai'
@@ -47,6 +48,19 @@ export interface ExtractionInput {
buffer: Buffer
mimeType: string
fileName: string
/**
* The receiving company's own identity. When set, an extracted supplier
* that turns out to BE this company (the model read the Kund/Kunduppgifter
* block instead of the issuer) is stripped to all-null before the result is
* returned, so the own company is never supplier-matched or offered for
* creation. Optional: callers without company context lose only this guard.
*/
ownCompany?: OwnCompanyIdentity
}
export interface OwnCompanyIdentity {
orgNumber: string | null
name: string | null
}
export type ExtractionSkipped = ExtractionSkipReason | 'unsupported_media'
@@ -218,6 +232,101 @@ export function promoteSingleProminentAmount(
}
}
/** Digits only; the comparable core of an org/VAT number. */
const digitsOf = (value: string | null | undefined): string => (value ?? '').replace(/\D/g, '')
/**
* Canonical 10-digit form of a Swedish organisation number, or '' when the
* input is not one. The 12-digit century-prefixed forms denote the same
* identity: "16" for organisations, "19"/"20" for personnummer-form numbers
* (enskild firma stores the owner's personnummer as org number). Junk that is
* not 10 digits after trimming never matches anything.
*/
function toOrg10(digits: string): string {
const trimmed =
digits.length === 12 && /^(16|19|20)/.test(digits) ? digits.slice(2) : digits
return trimmed.length === 10 ? trimmed : ''
}
/**
* Never present the receiving company as its own supplier.
*
* Documents like bank agreements and bankintyg print the CUSTOMER's company
* in a prominent Kund/Kunduppgifter block while the issuer (the bank) sits in
* a logo. The model sometimes extracts that block as the supplier, and the
* inbox then offers to create the user's own company as a leverantör. The
* prompt tells the model not to; this guard makes it deterministic: when the
* extracted supplier's org number, VAT number (SE<orgnr>01), or exact name
* equals the receiving company's own, the whole supplier block is nulled.
* A false positive costs an empty LEVERANTÖR field the user fills in by
* hand; wrong data is never written.
*/
export function stripOwnCompanyAsSupplier(
data: InvoiceExtractionResult,
own: OwnCompanyIdentity | undefined
): InvoiceExtractionResult {
if (!own) return data
const ownOrg10 = toOrg10(digitsOf(own.orgNumber))
const extractedOrg10 = toOrg10(digitsOf(data.supplier.orgNumber))
const extractedVat = digitsOf(data.supplier.vatNumber)
const orgHit = ownOrg10 !== '' && extractedOrg10 !== '' && extractedOrg10 === ownOrg10
const vatHit = ownOrg10 !== '' && extractedVat === `${ownOrg10}01`
// A name coincidence must not outvote a real, provably different org
// number: a same-named foreign or unrelated entity stays a valid supplier.
const orgProvenDifferent =
ownOrg10 !== '' && extractedOrg10 !== '' && extractedOrg10 !== ownOrg10
const ownName = own.name?.trim().toLowerCase() ?? ''
const nameHit =
!orgProvenDifferent &&
ownName !== '' &&
data.supplier.name?.trim().toLowerCase() === ownName
if (!orgHit && !vatHit && !nameHit) return data
return {
...data,
supplier: {
name: null,
orgNumber: null,
vatNumber: null,
address: null,
bankgiro: null,
plusgiro: null,
},
}
}
/**
* Best-effort lookup of the company's own name and org number for the
* ownCompany guard above. Never throws: on any failure the guard simply does
* not fire, which is the pre-guard behavior.
*/
export async function fetchOwnCompanyIdentity(
supabase: SupabaseClient,
companyId: string
): Promise<OwnCompanyIdentity> {
try {
const { data, error } = await supabase
.from('companies')
.select('name, org_number')
.eq('id', companyId)
.maybeSingle()
// maybeSingle() reports query/RLS failures in `error` without throwing;
// route them through the catch so they are logged, not silently nulled.
if (error) throw error
return {
orgNumber: (data?.org_number as string | null) ?? null,
name: (data?.name as string | null) ?? null,
}
} catch (err) {
// Fail open, but visibly: a persistent lookup failure (RLS misconfig,
// DB outage) silently disables the guard, and only this log reveals it.
log.warn('own_company_identity_lookup_failed', {
company_id: companyId,
error: err instanceof Error ? err.message : String(err),
})
return { orgNumber: null, name: null }
}
}
// Agent-supplied extraction: accountSuggestion is preserved instead of forced
// to null. Agents (unlike AI extractors) can reliably assign a BAS expense
// account; the regex enforces the class-4-7 range required for cost accounts.
@@ -290,6 +399,7 @@ VAT rate convention: BOTH lineItems[].vatRate AND vatBreakdown[].rate use the sa
Rules:
- Output JSON only. The first character must be '{' and the last must be '}'.
- documentKind: "receipt" = point-of-sale proof of a COMPLETED payment (kassakvitto, kortkvitto, taxi/parking slip, webshop order confirmation marked paid). "supplier_invoice" = a request for payment (has due date, OCR/payment reference, bankgiro, "Att betala senast"). "government_letter" = correspondence from a myndighet (Skatteverket, Bolagsverket, Försäkringskassan...). "other" = contracts, statements, reports. null only when truly indeterminate.
- supplier: ALWAYS the party that ISSUED the document and charges or receives the money (the seller, the bank, the myndighet). NEVER the customer or recipient: blocks labeled "Kund", "Kunduppgifter", "Fakturamottagare", "Mottagare", "Kundens ex", "Er referens" or a delivery/billing address describe the RECEIVING company, and none of their fields (name, org number, address) may be used for supplier. On bank documents (avtal, bankintyg, kontoutdrag) the bank is the supplier even when the customer's company details are printed more prominently than the bank's. If only the customer's identity is readable, leave every supplier field null.
- merchantCategory: judge from the merchant name and line items (a receipt from "Prinsen" listing food and wine is "restaurant" even without the word). Use "other" when unsure. null for non-receipts.
- legibility: "good" = all key amounts and the merchant are readable. "partial" = some key fields are cut off, blurry, or unreadable. "unreadable" = the document is mostly illegible (too blurry/dark/small). Judge the IMAGE quality, not whether fields exist on the document.
- payment: only for documents that show how payment was made. "card" for kort/VISA/Mastercard; cardLast4 only when a masked card number like ****1234 is printed. "invoice" means the document says it will be billed separately.
@@ -377,7 +487,9 @@ async function normalizeImageForExtraction(
})
.jpeg({ quality: 80 })
.toBuffer()
return { buffer: converted, mimeType: 'image/jpeg', fileName: input.fileName }
// Spread first: normalization must not shed fields like ownCompany, or
// the own-company supplier guard silently dies for photographed documents.
return { ...input, buffer: converted, mimeType: 'image/jpeg' }
} catch (err) {
// HEIC without libheif lands here → caller hits the unsupported-type
// guard, same net behavior as before this step existed. For oversized
@@ -674,7 +786,10 @@ export async function extractInvoiceFields(
return {
// accountSuggestion is null at this point, enforced by the schema's
// .transform, so no post-validation coercion is needed.
data: promoteSingleProminentAmount({ ...validated, confidence: 1 }),
data: stripOwnCompanyAsSupplier(
promoteSingleProminentAmount({ ...validated, confidence: 1 }),
input.ownCompany
),
rawText,
model,
}
@@ -1,6 +1,6 @@
import { after } from 'next/server'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { extractInvoiceFields, emptyResult } from './extract-invoice-fields'
import { extractInvoiceFields, emptyResult, fetchOwnCompanyIdentity } from './extract-invoice-fields'
import { mirrorExtractionToDocument } from './mirror-extraction'
import { getAiStatus } from '@/lib/ai'
import { hasCapability } from '@/lib/entitlements/has-capability'
@@ -520,6 +520,7 @@ export async function processArchivedDocument(
buffer: Buffer.from(slicedBuffer ?? file.buffer),
mimeType: file.type,
fileName: file.name,
ownCompany: await fetchOwnCompanyIdentity(supabase, companyId),
})
const { data: extracted, rawText } = extraction
if (!skipExtraction && slicedBuffer != null && pageCount != null) {
@@ -660,6 +661,7 @@ function scheduleDeferredExtraction(job: DeferredExtractionJob): void {
buffer: Buffer.from(slicedBuffer ?? job.file.buffer),
mimeType: job.file.type,
fileName: job.file.name,
ownCompany: await fetchOwnCompanyIdentity(supabase, job.companyId),
})
extracted = result.data
rawText = result.rawText
+7 -2
View File
@@ -266,7 +266,7 @@ import {
} from '@/lib/core/documents/document-service'
import { toSameOriginStorageUrl } from '@/lib/core/documents/storage-proxy'
import { createHash } from 'node:crypto'
import { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema, AgentExtractionSchema } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
import { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema, AgentExtractionSchema, fetchOwnCompanyIdentity } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
import { mirrorExtractionToDocument } from '@/extensions/general/invoice-inbox/lib/mirror-extraction'
// Skatteverket filing tools (PR5). Cross-extension lib import, same sanctioned
// pattern as invoice-inbox above: the CI guard only checks lib/, app/api/,
@@ -602,7 +602,12 @@ async function createDocumentInboxItem(
if (existing) return existing
}
const extraction = await extractInvoiceFields({ buffer, mimeType, fileName })
const extraction = await extractInvoiceFields({
buffer,
mimeType,
fileName,
ownCompany: await fetchOwnCompanyIdentity(supabase, companyId),
})
const { data: extracted } = extraction
// uploadDocument()/completePendingDocumentUpload() were told this inbox
// item owns extraction, so the document-extraction extension yielded;