@accounter/server
Version:
Accounter GraphQL server
205 lines • 8.2 kB
JavaScript
import { 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 { suggestionDataSchema } from '../../financial-entities/helpers/business-suggestion-data-schema.helper.js';
import { BusinessesProvider } from '../../financial-entities/providers/businesses.provider.js';
const toBase64 = async (file) => {
const base64string = Buffer.from(await file.arrayBuffer()).toString('base64');
return 'data:' + file.type + ';base64,' + base64string;
};
export const uploadToCloudinary = async (injector, file) => {
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 });
}
};
async function fetchOwnerForMatching(injector) {
try {
const { ownerId, locality } = await injector
.get(AdminContextProvider)
.getVerifiedAdminContext();
return { id: ownerId, locality: locality ?? null };
}
catch {
return undefined;
}
}
async function fetchBusinessesForMatching(injector) {
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 {
return [];
}
}
export async function getOcrData(injector, file, isSensitive = true) {
const validateNumber = (value) => {
return typeof value === 'number' && !Number.isNaN(value) ? value : undefined;
};
const validateDate = (value) => {
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] = 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) {
return hashStringToInt(await file.text());
}
function figureOutSides(documentType, ownerId, isOwnerIssuer, counterPartyId) {
if (documentType === DocumentType.Unprocessed || documentType === DocumentType.Other) {
return {};
}
let res = {
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 function getDocumentFromUrlsAndOcrData(fileUrl, imageUrl, ocrData, adminBusinessId, chargeId, fileHash) {
const sides = figureOutSides(ocrData.documentType, adminBusinessId, ocrData.isOwnerIssuer, ocrData.counterpartyId);
const newDocument = {
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;
}
function resolveOwnerSideFromUuids(ocrData, ownerId) {
const { suggestedIssuer, suggestedRecipient } = ocrData;
if (suggestedIssuer == null && suggestedRecipient == null)
return;
if (suggestedIssuer === ownerId) {
ocrData.isOwnerIssuer = true;
if (suggestedRecipient) {
ocrData.counterpartyId ??= suggestedRecipient;
}
}
else if (suggestedRecipient === ownerId) {
ocrData.isOwnerIssuer = false;
if (suggestedIssuer) {
ocrData.counterpartyId ??= 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) {
ocrData.counterpartyId ??= suggestedRecipient;
}
else if (ocrData.isOwnerIssuer === false) {
ocrData.counterpartyId ??= suggestedIssuer;
}
}
else if (suggestedIssuer != null) {
// Only issuer matched to a non-owner business → owner must be the recipient side
ocrData.isOwnerIssuer = false;
ocrData.counterpartyId ??= suggestedIssuer;
}
else if (suggestedRecipient != null) {
// Only recipient matched to a non-owner business → owner must be the issuer side
ocrData.isOwnerIssuer = true;
ocrData.counterpartyId ??= suggestedRecipient;
}
}
export async function getDocumentFromFile(injector, file, chargeId, isSensitive, counterPartyId, hash) {
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(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 });
}
}
//# sourceMappingURL=upload.helper.js.map