UNPKG

@accounter/server

Version:
354 lines (319 loc) 12.7 kB
import type { Injector } from 'graphql-modules'; import { Currency, DocumentType } from '../../../shared/enums.js'; import { hashStringToInt } from '../../../shared/helpers/index.js'; import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js'; import { AnthropicProvider } from '../../app-providers/anthropic.js'; import { CloudinaryProvider } from '../../app-providers/cloudinary.js'; import type { BusinessMatchData, OwnerMatchInfo, } from '../../app-providers/helpers/business-matcher.helper.js'; import { suggestionDataSchema } from '../../financial-entities/helpers/business-suggestion-data-schema.helper.js'; import { BusinessesProvider } from '../../financial-entities/providers/businesses.provider.js'; import type { IInsertDocumentsParams } from '../types.js'; const toBase64 = async (file: File | Blob): Promise<string> => { const base64string = Buffer.from(await file.arrayBuffer()).toString('base64'); return 'data:' + file.type + ';base64,' + base64string; }; export const uploadToCloudinary = async (injector: Injector, file: File | Blob) => { const base64string = await toBase64(file).catch(err => { throw new Error(`Failed to convert file to base64: ${err.message}`); }); try { return injector.get(CloudinaryProvider).uploadInvoiceToCloudinary(base64string); } catch (e) { const message = 'Error on uploading file to cloudinary'; console.error(`${message}: ${e}`); throw new Error(message, { cause: e }); } }; export type OcrData = { isOwnerIssuer?: boolean; counterpartyId?: string; documentType: DocumentType; serial?: string; date?: Date; amount?: number; currency?: Currency; vat?: number; allocationNumber?: string; description?: string; remarks?: string; suggestedIssuer?: string; suggestedRecipient?: string; }; async function fetchOwnerForMatching(injector: Injector): Promise<OwnerMatchInfo | undefined> { try { const { ownerId, locality } = await injector .get(AdminContextProvider) .getVerifiedAdminContext(); return { id: ownerId, locality: locality ?? null }; } catch (e) { // Non-fatal: OCR still runs, it just loses the owner locality used for the // foreign-counterparty VAT-0 default. Logged because a silent failure here // silently degrades recognition. console.error('Failed to load owner context for business matching:', e); return undefined; } } async function fetchBusinessesForMatching(injector: Injector): Promise<BusinessMatchData[]> { try { const rawBusinesses = await injector.get(BusinessesProvider).getAllBusinesses(); return rawBusinesses.map(b => ({ id: b.id, name: b.name ?? null, hebrew_name: b.hebrew_name ?? null, vat_number: b.vat_number ?? null, suggestion_data: suggestionDataSchema.safeParse(b.suggestion_data).data ?? null, locality: b.country ?? null, })); } catch (e) { // Non-fatal, but an empty list disables business matching entirely // (`matchBusiness` bails on an empty list and the LLM match fallback is // skipped), so it must not be silent. console.error('Failed to load businesses for business matching:', e); return []; } } /** * Pre-resolved inputs for the OCR business matching, for callers that cannot use * the auth-coupled `BusinessesProvider` / `AdminContextProvider` loaders — namely * the gateway email-ingestion path, which runs under a control-plane context * where their `TenantAwareDBClient` throws "Missing businessId in AuthContext". */ export type BusinessMatchContext = { businesses: BusinessMatchData[]; owner?: OwnerMatchInfo; }; export async function getOcrData( injector: Injector, file: File | Blob, isSensitive: boolean | null = true, // When provided, the businesses/owner fed to the matcher come from here instead // of the auth-coupled loaders. Absent (the default), the loaders are used, as before. matchContext?: BusinessMatchContext, ): Promise<OcrData> { const validateNumber = (value: unknown): number | undefined => { return typeof value === 'number' && !Number.isNaN(value) ? value : undefined; }; const validateDate = (value?: string): Date | undefined => { if (!value) return undefined; const date = new Date(value); return Number.isNaN(date.getTime()) ? undefined : date; }; if (isSensitive) { return { documentType: DocumentType.Unprocessed, }; } const [businesses, owner] = matchContext ? [matchContext.businesses, matchContext.owner] : await Promise.all([fetchBusinessesForMatching(injector), fetchOwnerForMatching(injector)]); const draft = await injector .get(AnthropicProvider) .extractInvoiceDetails(file, businesses, owner); if (!draft) { throw new Error('No data returned from Anthropic OCR'); } return { suggestedIssuer: draft.suggestedIssuer ?? undefined, suggestedRecipient: draft.suggestedRecipient ?? undefined, documentType: draft.type ?? DocumentType.Unprocessed, serial: draft.referenceCode ?? undefined, date: validateDate(draft.date ?? undefined), amount: validateNumber(draft.fullAmount), currency: draft.currency, vat: validateNumber(draft.vatAmount), allocationNumber: draft.allocationNumber ?? undefined, description: draft.description ?? undefined, remarks: draft.issuer, }; } async function getHashFromFile(file: File | Blob): Promise<number> { return hashStringToInt(await file.text()); } function figureOutSides( documentType: DocumentType, ownerId: string, isOwnerIssuer?: boolean, counterPartyId?: string, ): { creditorId?: string; debtorId?: string } { if (documentType === DocumentType.Unprocessed || documentType === DocumentType.Other) { return {}; } let res: { creditorId: string | undefined; debtorId: string | undefined; } = { creditorId: counterPartyId, debtorId: ownerId, }; if (isOwnerIssuer === true) { res = { creditorId: res.debtorId, debtorId: res.creditorId, }; } if (documentType === DocumentType.CreditInvoice) { res = { creditorId: res.debtorId, debtorId: res.creditorId, }; } return res; } export async function getDocumentFromUrlsAndOcrData( injector: Injector, fileUrl: string, imageUrl: string, ocrData: OcrData, adminBusinessId: string, chargeId?: string | null, fileHash?: number, // When provided, the counterparty country + admin locality used for the // foreign-counterparty VAT-0 fallback are taken from here instead of the // auth-coupled `BusinessesProvider` / `AdminContextProvider` loaders. The // gateway email-ingestion path passes this because it runs under a control-plane // context with no auth session, where those loaders' `TenantAwareDBClient` throws // "Missing businessId in AuthContext". Absent (the default), the auth-coupled // loaders are used, as before. vatFallbackContext?: { counterpartyCountry: string | null; adminLocality: string | null }, ): Promise<IInsertDocumentsParams['documents'][number]> { const sides = figureOutSides( ocrData.documentType, adminBusinessId, ocrData.isOwnerIssuer, ocrData.counterpartyId, ); // in case of missing VAT, use 0 for foreign counterparties if (ocrData.counterpartyId && ocrData.vat == null) { if (vatFallbackContext) { const { counterpartyCountry, adminLocality } = vatFallbackContext; if ( adminLocality != null && counterpartyCountry != null && counterpartyCountry !== adminLocality ) { ocrData.vat = 0; } } else { const [business, adminContext] = await Promise.all([ injector.get(BusinessesProvider).getBusinessByIdLoader.load(ocrData.counterpartyId), injector.get(AdminContextProvider).adminContextByOwnerIdLoader.load(adminBusinessId), ]); if (business && adminContext && business.country !== adminContext.locality) { ocrData.vat = 0; } } } const newDocument: IInsertDocumentsParams['documents'][number] = { ownerId: adminBusinessId, image: imageUrl ?? null, file: fileUrl ?? null, documentType: ocrData.documentType, serialNumber: ocrData.serial ?? null, date: ocrData.date ?? null, amount: ocrData.amount ?? null, currencyCode: ocrData.currency ?? null, vat: ocrData.vat ?? null, chargeId: chargeId ?? null, vatReportDateOverride: null, noVatAmount: null, allocationNumber: ocrData.allocationNumber ?? null, exchangeRateOverride: null, fileHash: fileHash?.toString() ?? null, description: ocrData.description ?? null, remarks: ocrData.remarks ?? null, creditorId: sides.creditorId ?? null, debtorId: sides.debtorId ?? null, }; return newDocument; } /** * Turn the OCR business-match UUIDs (`suggestedIssuer` / `suggestedRecipient`) * into `isOwnerIssuer` + `counterpartyId`. `counterpartyId` is only filled when * unset (`??=`), so a caller that already resolved the counterparty from a * higher-confidence source (e.g. the email-ingestion grant) keeps it. */ export function resolveOwnerSideFromUuids(ocrData: OcrData, ownerId: string): void { const { suggestedIssuer, suggestedRecipient } = ocrData; if (suggestedIssuer == null && suggestedRecipient == null) return; // The owner on *both* sides is a contradiction (a business cannot transact with // itself), so the match carries no usable information about which side the owner // is on. Bail rather than guess: committing to `isOwnerIssuer` here would flip an // already-resolved counterparty (e.g. the email-ingestion grant's business) onto // the wrong side of the document. if (suggestedIssuer === ownerId && suggestedRecipient === ownerId) return; // The counterparty is by definition not the owner. Enforced here rather than at // each assignment so no branch can produce an "owner is its own counterparty" // document, whose sides both collapse onto the owner in `figureOutSides`. const setCounterparty = (id: string | null | undefined): void => { if (id && id !== ownerId) { ocrData.counterpartyId ??= id; } }; if (suggestedIssuer === ownerId) { ocrData.isOwnerIssuer = true; setCounterparty(suggestedRecipient); } else if (suggestedRecipient === ownerId) { ocrData.isOwnerIssuer = false; setCounterparty(suggestedIssuer); } else if (suggestedIssuer != null && suggestedRecipient != null) { // Both sides matched to non-owner businesses — ambiguous which side the owner is. // Preserve the OCR-derived isOwnerIssuer and use it only to pick counterpartyId. if (ocrData.isOwnerIssuer === true) { setCounterparty(suggestedRecipient); } else if (ocrData.isOwnerIssuer === false) { setCounterparty(suggestedIssuer); } } else if (suggestedIssuer != null) { // Only issuer matched to a non-owner business → owner must be the recipient side ocrData.isOwnerIssuer = false; setCounterparty(suggestedIssuer); } else if (suggestedRecipient != null) { // Only recipient matched to a non-owner business → owner must be the issuer side ocrData.isOwnerIssuer = true; setCounterparty(suggestedRecipient); } } export async function getDocumentFromFile( injector: Injector, file: File | Blob, chargeId?: string | null, isSensitive?: boolean | null, counterPartyId?: string, hash?: number, ): Promise<IInsertDocumentsParams['documents'][number]> { try { const { ownerId } = await injector.get(AdminContextProvider).getVerifiedAdminContext(); // Buffer the file to allow multiple reads from a stream const buffer = await file.arrayBuffer(); const multiReadableFile = new Blob([buffer], { type: file.type }); const [{ fileUrl, imageUrl }, ocrData, fileHash] = await Promise.all([ uploadToCloudinary(injector, multiReadableFile), getOcrData(injector, multiReadableFile, isSensitive), hash ? Promise.resolve(hash) : getHashFromFile(multiReadableFile), ]); if (!ocrData) { throw new Error('No data returned from Green Invoice'); } if (counterPartyId) { ocrData.counterpartyId = counterPartyId; } // Resolve isOwnerIssuer and counterpartyId from UUID matches (primary path). resolveOwnerSideFromUuids(ocrData, ownerId); return getDocumentFromUrlsAndOcrData( injector, fileUrl, imageUrl, ocrData, ownerId, chargeId, fileHash, ); } catch (e) { const message = 'Error extracting document data from file'; console.error(`${message}: ${e}`); throw new Error(message, { cause: e }); } }