UNPKG

@accounter/server

Version:

659 lines (606 loc) • 22.8 kB
import { endOfMonth, format, startOfMonth, subMonths } from 'date-fns'; import { GraphQLError } from 'graphql'; import { DocumentInput_Input, Currency as GreenInvoiceCurrency, } from '@accounter/green-invoice-graphql'; import { CloudinaryProvider } from '@modules/app-providers/cloudinary.js'; import { GoogleDriveProvider } from '@modules/app-providers/google-drive/google-drive.provider.js'; import { GreenInvoiceProvider } from '@modules/app-providers/green-invoice.js'; import { deleteCharges } from '@modules/charges/helpers/delete-charges.helper.js'; import { ChargesProvider } from '@modules/charges/providers/charges.provider.js'; import { BusinessesGreenInvoiceMatcherProvider } from '@modules/financial-entities/providers/businesses-green-invoice-match.provider.js'; import { BusinessesProvider } from '@modules/financial-entities/providers/businesses.provider.js'; import { EMPTY_UUID } from '@shared/constants'; import { Currency, DocumentType } from '@shared/enums'; import { Resolvers } from '@shared/gql-types'; import { dateToTimelessDateString, formatCurrency, optionalDateToTimelessDateString, } from '@shared/helpers'; import { getGreenInvoiceDocumentType, normalizeDocumentType, } from '../helpers/green-invoice.helper.js'; import { getDocumentFromFile } from '../helpers/upload.helper.js'; import { DocumentsProvider } from '../providers/documents.provider.js'; import type { DocumentsModule, IGetAllDocumentsResult, IInsertDocumentsParams, IInsertDocumentsResult, IUpdateDocumentParams, } from '../types.js'; import { commonChargeFields, commonDocumentsFields, commonFinancialDocumentsFields, } from './common.js'; export const documentsResolvers: DocumentsModule.Resolvers & Pick< Resolvers, 'UpdateDocumentResult' | 'InsertDocumentResult' | 'UploadDocumentResult' | 'Document' > = { Query: { documents: async (_, __, { injector }) => { const dbDocs = await injector.get(DocumentsProvider).getAllDocuments(); return dbDocs; }, documentsByFilters: async (_, { filters }, { injector }) => { const dbDocs = await injector.get(DocumentsProvider).getDocumentsByExtendedFilters(filters); return dbDocs; }, documentById: async (_, { documentId }, { injector }) => { const doc = await injector.get(DocumentsProvider).getDocumentsByIdLoader.load(documentId); return doc ?? null; }, }, Mutation: { uploadDocument: async (_, { file, chargeId }, context) => { const { injector } = context; try { const newDocument = await getDocumentFromFile(context, file, chargeId); const res = await injector .get(DocumentsProvider) .insertDocuments({ document: [{ ...newDocument }] }); return { document: res[0] as IGetAllDocumentsResult }; } catch (e) { const message = (e as Error)?.message ?? 'Unknown error'; return { __typename: 'CommonError', message: `Error uploading document: ${message}`, }; } }, batchUploadDocuments: async (_, { documents, isSensitive, chargeId }, context) => { const { injector, adminContext: { defaultAdminBusinessId }, } = context; if (!chargeId) { // generate new charge const [newCharge] = await injector.get(ChargesProvider).generateCharge({ ownerId: defaultAdminBusinessId, userDescription: 'New uploaded documents', }); if (!newCharge) { throw new GraphQLError(`Failed to generate new charge for new document`); } chargeId = newCharge.id; } const newDocuments: Array<IInsertDocumentsParams['document'][number]> = []; await Promise.all( documents.map(async document => { // get new document data const newDocument = await getDocumentFromFile(context, document, chargeId, isSensitive); newDocuments.push(newDocument); }), ); const res = await injector.get(DocumentsProvider).insertDocuments({ document: newDocuments }); return res.map(document => ({ document: document as IGetAllDocumentsResult })); }, batchUploadDocumentsFromGoogleDrive: async ( _, { sharedFolderUrl, chargeId, isSensitive }, context, ) => { const isValidGoogleDriveUrl = (url: string): boolean => { try { const parsedUrl = new URL(url); return ( parsedUrl.hostname === 'drive.google.com' && parsedUrl.pathname.includes('/folders/') ); } catch { return false; } }; if (!isValidGoogleDriveUrl(sharedFolderUrl)) { throw new GraphQLError('Invalid Google Drive folder URL'); } const { injector, adminContext: { defaultAdminBusinessId }, } = context; const files = await injector .get(GoogleDriveProvider) .fetchFilesFromSharedFolder(sharedFolderUrl); if (!chargeId) { // generate new charge const [newCharge] = await injector.get(ChargesProvider).generateCharge({ ownerId: defaultAdminBusinessId, userDescription: 'New uploaded documents', }); if (!newCharge) { throw new GraphQLError(`Failed to generate new charge for new document`); } chargeId = newCharge.id; } const newDocuments: Array<IInsertDocumentsParams['document'][number]> = []; await Promise.all( files.map(async file => { // get new document data try { const newDocument = await getDocumentFromFile(context, file, chargeId, isSensitive); newDocuments.push(newDocument); } catch (error) { // Skip this file and continue with other files console.error(error); } }), ); const res = await injector.get(DocumentsProvider).insertDocuments({ document: newDocuments }); return res.map(document => ({ document: document as IGetAllDocumentsResult })); }, updateDocument: async (_, { fields, documentId }, { injector }) => { let postUpdateActions = async (): Promise<void> => void 0; try { let chargeId: string | undefined = undefined; if (fields.chargeId === EMPTY_UUID) { // case unlinked from charge const document = await injector .get(DocumentsProvider) .getDocumentsByIdLoader.load(documentId); if (!document) { throw new GraphQLError(`Document ID="${documentId}" not valid`); } if (document.charge_id) { const charge = await injector .get(ChargesProvider) .getChargeByIdLoader.load(document.charge_id); if (!charge) { throw new GraphQLError( `Former document's charge ID ("${fields.chargeId}") not valid`, ); } // generate new charge const newCharge = await injector.get(ChargesProvider).generateCharge({ ownerId: charge.owner_id, userDescription: 'Document unlinked from charge', }); if (!newCharge || newCharge.length === 0) { throw new GraphQLError( `Failed to generate new charge for document ID="${documentId}"`, ); } chargeId = newCharge?.[0]?.id; if ( Number(charge.documents_count ?? 1) === 1 && Number(charge.transactions_count ?? 0) === 0 ) { postUpdateActions = async () => { try { await deleteCharges([charge.id], injector); } catch (e) { console.error(e); throw new GraphQLError( `Failed to delete the empty former charge ID="${charge.id}"`, ); } return postUpdateActions(); }; } } } else if (fields.chargeId) { // case new charge ID const charge = await injector .get(ChargesProvider) .getChargeByIdLoader.load(fields.chargeId); if (!charge) { throw new GraphQLError(`Charge ID="${fields.chargeId}" not valid`); } chargeId = charge.id; } const adjustedFields: IUpdateDocumentParams = { documentId, chargeId: chargeId ?? null, currencyCode: fields.amount?.currency ?? null, creditorId: fields.creditorId ?? null, debtorId: fields.debtorId ?? null, date: fields.date ? new Date(fields.date) : null, fileUrl: fields.file ? fields.file.toString() : null, imageUrl: fields.image ? fields.image.toString() : null, serialNumber: fields.serialNumber ?? null, totalAmount: fields.amount?.raw ?? null, type: fields.documentType ?? null, vatAmount: fields.vat?.raw ?? null, vatReportDateOverride: fields.vatReportDateOverride ? new Date(fields.vatReportDateOverride) : null, noVatAmount: fields.noVatAmount ?? null, isReviewed: true, }; const res = await injector.get(DocumentsProvider).updateDocument({ ...adjustedFields }); if (!res || res.length === 0) { throw new Error(`Document ID="${documentId}" not found`); } await postUpdateActions(); return { document: res[0], }; } catch (e) { return { __typename: 'CommonError', message: (e as Error)?.message ?? 'Unknown error', }; } }, deleteDocument: async (_, { documentId }, { injector }) => { const document = await injector .get(DocumentsProvider) .getDocumentsByIdLoader.load(documentId); if (!document) { throw new GraphQLError(`Document ID="${documentId}" not found`); } const res = await injector.get(DocumentsProvider).deleteDocument({ documentId }); if (res.length === 1) { if (document.charge_id) { const charge = await injector .get(ChargesProvider) .getChargeByIdLoader.load(document.charge_id); if (charge && !charge.documents_count && !charge.transactions_count) { await deleteCharges([charge.id], injector); } } return true; } throw new GraphQLError( res.length === 0 ? 'Document not found' : `More than one document found and deleted: ${res}`, ); }, insertDocument: async (_, { record }, { injector }) => { try { if (record.chargeId) { const charge = await injector .get(ChargesProvider) .getChargeByIdLoader.load(record.chargeId); if (!charge) { throw new Error(`Charge ID='${record.chargeId}' not found`); } } const newDocument: IInsertDocumentsParams['document']['0'] = { image: record.image ? record.image.toString() : null, file: record.file ? record.file.toString() : null, documentType: record.documentType ?? DocumentType.Unprocessed, serialNumber: record.serialNumber ?? null, date: record.date ? new Date(record.date) : null, amount: record.amount?.raw ?? null, currencyCode: record.amount?.currency ?? null, vat: record.vat?.raw ?? null, chargeId: record.chargeId ?? null, vatReportDateOverride: record.vatReportDateOverride ? new Date(record.vatReportDateOverride) : null, noVatAmount: record.noVatAmount ?? null, creditorId: record.creditorId ?? null, debtorId: record.debtorId ?? null, }; const res = await injector .get(DocumentsProvider) .insertDocuments({ document: [{ ...newDocument }] }); if (!res || res.length === 0) { throw new Error(`Failed to insert ledger record to charge ID='${record.chargeId}'`); } if (record.chargeId) { /* clear cache */ injector.get(DocumentsProvider).getDocumentsByChargeIdLoader.clear(record.chargeId); } return { document: res[0] }; } catch (e) { return { __typename: 'CommonError', message: `Error inserting new ledger record:\n ${ (e as Error)?.message ?? 'Unknown error' }`, }; } }, fetchIncomeDocuments: async ( _, { ownerId }, { injector, adminContext: { defaultAdminBusinessId } }, ) => { const data = await injector.get(GreenInvoiceProvider).searchDocuments({ input: { pageSize: 100, sort: 'creationDate' }, }); if (!data?.items) { throw new GraphQLError('Failed to fetch documents'); } if (data.items.length === 0) { return []; } const documents = await injector.get(DocumentsProvider).getAllDocuments(); const newDocuments = data.items.filter( item => item && !documents.some( doc => doc.vat_amount === item.vat && doc.total_amount === item.amount && doc.serial_number === item.number && optionalDateToTimelessDateString(doc.date) === item.documentDate, ) && item.type !== '_300', ); const addedDocs: IInsertDocumentsResult[] = []; await Promise.all( newDocuments.map(async greenInvoiceDoc => { if (!greenInvoiceDoc) { return; } const documentType = normalizeDocumentType(greenInvoiceDoc.type); const isOwnerCreditor = greenInvoiceDoc.amount > 0 && documentType !== DocumentType.CreditInvoice; try { // generate preview image via cloudinary const imagePromise = injector .get(CloudinaryProvider) .uploadInvoiceToCloudinary(greenInvoiceDoc.url.origin); // Generate parent charge const chargePromise = injector.get(ChargesProvider).generateCharge({ ownerId, userDescription: greenInvoiceDoc.description && greenInvoiceDoc.description !== '' ? greenInvoiceDoc.description : 'Green Invoice generated charge', }); // Get matching business const businessPromise = injector .get(BusinessesGreenInvoiceMatcherProvider) .getBusinessMatchByGreenInvoiceIdLoader.load(greenInvoiceDoc.client.id); const [{ imageUrl }, [charge], business] = await Promise.all([ imagePromise, chargePromise, businessPromise, ]); if (!charge) { throw new Error('Failed to generate charge'); } const counterpartyId = business?.business_id ?? null; // insert document const rawDocument: IInsertDocumentsParams['document']['0'] = { image: imageUrl, file: greenInvoiceDoc.url.origin, documentType, serialNumber: greenInvoiceDoc.number, date: greenInvoiceDoc.documentDate, amount: greenInvoiceDoc.amount, currencyCode: formatCurrency(greenInvoiceDoc.currency), vat: greenInvoiceDoc.vat, chargeId: charge.id, vatReportDateOverride: null, noVatAmount: null, creditorId: isOwnerCreditor ? defaultAdminBusinessId : counterpartyId, debtorId: isOwnerCreditor ? counterpartyId : defaultAdminBusinessId, }; const newDocumentPromise = injector .get(DocumentsProvider) .insertDocuments({ document: [rawDocument] }); const chargeDescriptionUpdate = new Promise(resolve => { const income = greenInvoiceDoc.income; if ( !income || income.length === 0 || !income[0]?.description || income[0].description === '' ) { resolve(undefined); } const userDescription = income .filter(item => item?.description) .map(item => item!.description) .join(', '); injector .get(ChargesProvider) .updateCharge({ chargeId: charge.id, userDescription, }) .then(() => resolve(undefined)); }); const [newDocument] = await Promise.all([newDocumentPromise, chargeDescriptionUpdate]); addedDocs.push(newDocument[0]); } catch (e) { throw new GraphQLError( `Error adding Green Invoice document: ${e}\n\n${JSON.stringify( greenInvoiceDoc, null, 2, )}`, ); } }), ); return addedDocs; }, generateMonthlyClientDocuments: async (_, __, { injector }) => { const jsonArray: Array<{ businessId: string; amount: number; currency: GreenInvoiceCurrency; }> = [ { businessId: '147d3415-55e3-497f-acba-352dcc37cb8d', // uri test amount: 1000, currency: Currency.Ils, }, ]; const errors: string[] = []; const proformaProtos = await Promise.all( jsonArray.map(async json => { const businessPromise = injector .get(BusinessesProvider) .getBusinessByIdLoader.load(json.businessId); const businessGreenInvoiceMatchPromise = injector .get(BusinessesGreenInvoiceMatcherProvider) .getBusinessMatchByIdLoader.load(json.businessId); const [business, businessGreenInvoiceMatch] = await Promise.all([ businessPromise, businessGreenInvoiceMatchPromise, ]); if (!business) { throw new GraphQLError(`Business ID="${json.businessId}" not found`); } if (!businessGreenInvoiceMatch) { throw new GraphQLError( `Green invoice match not found for business ID="${json.businessId}"`, ); } const today = new Date(); const monthStart = dateToTimelessDateString(startOfMonth(today)); const monthEnd = dateToTimelessDateString(endOfMonth(today)); const year = today.getFullYear() + (today.getMonth() === 0 ? -1 : 0); const month = format(subMonths(today, 1), 'MMMM'); const documentInput: DocumentInput_Input & { businessName: string } = { businessName: business.name, type: getGreenInvoiceDocumentType( businessGreenInvoiceMatch.document_type as DocumentType, ), remarks: businessGreenInvoiceMatch.remark ?? undefined, date: monthStart, dueDate: monthEnd, lang: 'en', currency: json.currency, vatType: 1, rounding: false, signed: true, attachment: true, client: { id: businessGreenInvoiceMatch.green_invoice_id, emails: [...(businessGreenInvoiceMatch.emails ?? []), 'ap@the-guild.dev'], }, income: [ { description: `GraphQL Hive Enterprise License - ${month} ${year}`, quantity: 1, price: json.amount, currency: json.currency, vatType: 1, }, ], }; return documentInput; }), ); for (const proformaProto of proformaProtos) { const { businessName, ...input } = proformaProto; await injector .get(GreenInvoiceProvider) .addDocuments({ input }) .then(res => { if (res && 'errorMessage' in res) { errors.push(`${businessName}: ${res.errorMessage}`); } }) .catch(e => { console.error(e); errors.push(`${businessName}: ${e.message}`); }); } return { success: true, errors, }; }, }, Document: { __resolveType: (documentRoot, _context, _info) => { switch (documentRoot?.type) { case DocumentType.Invoice: { return 'Invoice'; } case DocumentType.Receipt: { return 'Receipt'; } case DocumentType.InvoiceReceipt: { return 'InvoiceReceipt'; } case DocumentType.CreditInvoice: { return 'CreditInvoice'; } case DocumentType.Proforma: { return 'Proforma'; } default: { return 'Unprocessed'; } } }, }, UpdateDocumentResult: { __resolveType: (obj, _context, _info) => { if ('__typename' in obj && obj.__typename === 'CommonError') return 'CommonError'; return 'UpdateDocumentSuccessfulResult'; }, }, InsertDocumentResult: { __resolveType: (obj, _context, _info) => { if ('__typename' in obj && obj.__typename === 'CommonError') return 'CommonError'; return 'InsertDocumentSuccessfulResult'; }, }, UploadDocumentResult: { __resolveType: (obj, _context, _info) => { if ('__typename' in obj && obj.__typename === 'CommonError') return 'CommonError'; return 'UploadDocumentSuccessfulResult'; }, }, Invoice: { ...commonDocumentsFields, ...commonFinancialDocumentsFields, }, InvoiceReceipt: { ...commonDocumentsFields, ...commonFinancialDocumentsFields, }, CreditInvoice: { ...commonDocumentsFields, ...commonFinancialDocumentsFields, }, Proforma: { ...commonDocumentsFields, ...commonFinancialDocumentsFields, }, Unprocessed: { ...commonDocumentsFields, }, OtherDocument: { ...commonDocumentsFields, }, Receipt: { ...commonDocumentsFields, ...commonFinancialDocumentsFields, }, CommonCharge: commonChargeFields, FinancialCharge: commonChargeFields, ConversionCharge: commonChargeFields, SalaryCharge: commonChargeFields, InternalTransferCharge: commonChargeFields, DividendCharge: commonChargeFields, BusinessTripCharge: commonChargeFields, MonthlyVatCharge: commonChargeFields, BankDepositCharge: commonChargeFields, CreditcardBankCharge: commonChargeFields, };