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:
@@ -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
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user