diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml index b3b201d0..422b9713 100644 --- a/.compliance/ropa.yaml +++ b/.compliance/ropa.yaml @@ -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 diff --git a/DECISIONS.md b/DECISIONS.md index 8ed0734b..9f09c5f0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1384,5 +1384,6 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/extensions/general/document-extraction/__tests__/handler.test.ts b/extensions/general/document-extraction/__tests__/handler.test.ts index 102eb7ae..6be332b1 100644 --- a/extensions/general/document-extraction/__tests__/handler.test.ts +++ b/extensions/general/document-extraction/__tests__/handler.test.ts @@ -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() diff --git a/extensions/general/document-extraction/index.ts b/extensions/general/document-extraction/index.ts index 1c70a279..09117afb 100644 --- a/extensions/general/document-extraction/index.ts +++ b/extensions/general/document-extraction/index.ts @@ -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 diff --git a/extensions/general/invoice-inbox/__tests__/extract-invoice-fields.test.ts b/extensions/general/invoice-inbox/__tests__/extract-invoice-fields.test.ts index 2f6b872e..0ed4e333 100644 --- a/extensions/general/invoice-inbox/__tests__/extract-invoice-fields.test.ts +++ b/extensions/general/invoice-inbox/__tests__/extract-invoice-fields.test.ts @@ -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['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 (SE01)', () => { + // 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' }]) + }) +}) diff --git a/extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts b/extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts index 923dc0ff..172e7b42 100644 --- a/extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts +++ b/extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts @@ -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', () => ({ diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index 91779665..d93f120a 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -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, { diff --git a/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts b/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts index 4eb4e7aa..36850873 100644 --- a/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts +++ b/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts @@ -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 (SE01), 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 { + 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, } diff --git a/extensions/general/invoice-inbox/lib/upload-and-extract.ts b/extensions/general/invoice-inbox/lib/upload-and-extract.ts index 62533743..589fb77b 100644 --- a/extensions/general/invoice-inbox/lib/upload-and-extract.ts +++ b/extensions/general/invoice-inbox/lib/upload-and-extract.ts @@ -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 diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 3bc9394f..d01b2cef 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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;