UNPKG

@accounter/server

Version:

685 lines (606 loc) • 24.1 kB
import { addMonths, endOfMonth, format, startOfMonth, subMonths } from 'date-fns'; import { GraphQLError } from 'graphql'; import type { Document } from '@accounter/green-invoice-graphql'; import { GreenInvoiceClientProvider } from '@modules/app-providers/green-invoice-client.js'; import { ChargesProvider } from '@modules/charges/providers/charges.provider.js'; import { ContractsProvider } from '@modules/contracts/providers/contracts.provider.js'; import { DocumentsProvider } from '@modules/documents/providers/documents.provider.js'; import { IssuedDocumentsProvider } from '@modules/documents/providers/issued-documents.provider.js'; import { normalizeDocumentType } from '@modules/documents/resolvers/common.js'; import type { IGetAllIssuedDocumentsResult, IGetIssuedDocumentsByIdsResult, IInsertDocumentsResult, IUpdateIssuedDocumentParams, } from '@modules/documents/types.js'; import { BusinessesProvider } from '@modules/financial-entities/providers/businesses.provider.js'; import { ClientsProvider } from '@modules/financial-entities/providers/clients.provider.js'; import { convertDocumentInputIntoGreenInvoiceInput, convertGreenInvoiceDocumentToLocalDocumentInfo, getGreenInvoiceDocumentNameFromType, getGreenInvoiceDocuments, getLinkedDocuments, greenInvoiceToDocumentStatus, insertNewDocumentFromGreenInvoice, } from '@modules/green-invoice/helpers/green-invoice.helper.js'; import { TransactionsProvider } from '@modules/transactions/providers/transactions.provider.js'; import { Currency, DocumentType } from '@shared/enums'; import { NewDocumentInfo } from '@shared/gql-types'; import { dateToTimelessDateString } from '@shared/helpers'; import { deduceVatTypeFromBusiness, executeDocumentIssue, filterAndHandleSwiftTransactions, getClientFromGreenInvoiceClient, getDocumentDateOutOfTransactions, getIncomeFromDocuments, getLinkedDocumentsAttributes, getPaymentsFromTransactions, getTypeFromDocumentsAndTransactions, } from '../helpers/issue-document.helper.js'; import type { GreenInvoiceModule } from '../types.js'; export const greenInvoiceResolvers: GreenInvoiceModule.Resolvers = { Query: { newDocumentInfoDraftByCharge: async ( _, { chargeId }, { injector, adminContext: { defaultCryptoConversionFiatCurrency, financialAccounts: { swiftBusinessId }, }, }, ) => { if (!chargeId) { throw new GraphQLError('Charge ID is required to fetch document draft'); } const chargePromise = injector.get(ChargesProvider).getChargeByIdLoader.load(chargeId); const documentsPromise = injector .get(DocumentsProvider) .getDocumentsByChargeIdLoader.load(chargeId); const transactionsPromise = injector .get(TransactionsProvider) .transactionsByChargeIDLoader.load(chargeId) .then(res => filterAndHandleSwiftTransactions(res, swiftBusinessId)); const [charge, documents, transactions] = await Promise.all([ chargePromise, documentsPromise, transactionsPromise, ]); if (!charge) { throw new GraphQLError(`Charge with ID "${chargeId}" not found`); } const businessMatchPromise = charge.business_id ? injector.get(ClientsProvider).getClientByIdLoader.load(charge.business_id) : Promise.resolve(undefined); const openIssuedDocumentsPromise = injector .get(IssuedDocumentsProvider) .getIssuedDocumentsByIdLoader.loadMany(documents.map(d => d.id)) .then( res => res.filter(r => { if (!r) return false; if (r instanceof Error) { console.error('Failed to fetch issued document', r); return false; } if (r.status !== 'OPEN') { return false; } return true; }) as IGetIssuedDocumentsByIdsResult[], ); const clientPromise = charge.business_id ? getClientFromGreenInvoiceClient(injector, charge.business_id) : Promise.resolve(undefined); const paymentPromise = getPaymentsFromTransactions(injector, transactions); const vatTypePromise = deduceVatTypeFromBusiness(injector, charge.business_id); const [businessMatch, openIssuedDocuments, client, payment, vatType] = await Promise.all([ businessMatchPromise, openIssuedDocumentsPromise, clientPromise, paymentPromise, vatTypePromise, ]); const greenInvoiceDocuments = await Promise.all( openIssuedDocuments.map(doc => injector .get(GreenInvoiceClientProvider) .documentLoader.load(doc.external_id) .then(res => { if (!res) { console.error('Failed to fetch document from Green Invoice', doc.external_id); return null; } return res; }), ), ).then(res => res.filter(Boolean) as Document[]); const income = getIncomeFromDocuments( openIssuedDocuments.map(doc => ({ document: documents.find(d => d.id === doc.id)!, issuedDocument: doc, greenInvoiceDocument: greenInvoiceDocuments.find(gd => gd.id === doc.external_id)!, })), ); if (income.length === 0 && transactions.length > 0) { income.push( ...transactions.map(transaction => ({ description: transaction.source_description ?? '', quantity: 1, price: Number(transaction.amount), currency: transaction.currency as Currency, currencyRate: undefined, vatType, })), ); } const linkedDocsAttributes = getLinkedDocumentsAttributes(openIssuedDocuments); const type = getTypeFromDocumentsAndTransactions(greenInvoiceDocuments, transactions); let remarks = charge.user_description; if (greenInvoiceDocuments.length) { remarks = greenInvoiceDocuments.map(doc => doc.remarks).join(', '); switch (type) { case DocumentType.Receipt: case DocumentType.InvoiceReceipt: remarks = `${getGreenInvoiceDocumentNameFromType(type)} for ${greenInvoiceDocuments.map(doc => `${getGreenInvoiceDocumentNameFromType(doc.type)} ${doc.number}`).join(', ')}`; break; } } else if (businessMatch?.remark) { remarks = businessMatch.remark; } const documentDate = getDocumentDateOutOfTransactions(transactions); return { remarks, // description: ____, // footer: ____, type, date: documentDate, dueDate: dateToTimelessDateString(endOfMonth(new Date())), lang: 'ENGLISH', currency: (charge.transactions_currency || charge.documents_currency || defaultCryptoConversionFiatCurrency) as Currency, vatType, rounding: false, signed: true, client, income, payment, // linkedPaymentId: ____, // maxPayments: _____, // discount: _____, ...linkedDocsAttributes, }; }, newDocumentInfoDraftByDocument: async ( _, { documentId }, { injector, adminContext: { defaultCryptoConversionFiatCurrency, financialAccounts: { swiftBusinessId }, }, }, ) => { if (!documentId) { throw new GraphQLError('Document ID is required to fetch document draft'); } const document = await injector .get(DocumentsProvider) .getDocumentsByIdLoader.load(documentId); if (!document) { throw new GraphQLError(`Document with ID "${documentId}" not found`); } const chargePromise = document.charge_id ? injector.get(ChargesProvider).getChargeByIdLoader.load(document.charge_id) : Promise.resolve(undefined); const transactionsPromise = document.charge_id ? injector .get(TransactionsProvider) .transactionsByChargeIDLoader.load(document.charge_id) .then(res => filterAndHandleSwiftTransactions(res, swiftBusinessId)) : Promise.resolve(undefined); const [charge, transactions] = await Promise.all([chargePromise, transactionsPromise]); if (!charge) { throw new GraphQLError(`Charge with ID "${document.charge_id}" not found`); } const businessMatchPromise = charge.business_id ? injector.get(ClientsProvider).getClientByIdLoader.load(charge.business_id) : Promise.resolve(undefined); const openIssuedDocumentPromise = injector .get(IssuedDocumentsProvider) .getIssuedDocumentsByIdLoader.load(document.id); const vatTypePromise = deduceVatTypeFromBusiness(injector, charge.business_id); const [businessMatch, openIssuedDocument, vatType] = await Promise.all([ businessMatchPromise, openIssuedDocumentPromise, vatTypePromise, ]); if (!openIssuedDocument) { throw new GraphQLError( `Document with ID "${document.id}" doesn't seem like a document we issued`, ); } if (openIssuedDocument.status !== 'OPEN') { throw new GraphQLError(`Document with ID "${document.id}" is closed`); } const greenInvoiceDocumentPromise: Promise<Document | null> = injector .get(GreenInvoiceClientProvider) .documentLoader.load(openIssuedDocument.external_id) .then(res => { if (!res) { console.error( 'Failed to fetch document from Green Invoice', openIssuedDocument.external_id, ); return null; } return res; }); const paymentPromise = getPaymentsFromTransactions(injector, transactions ?? []); const clientPromise = charge.business_id ? getClientFromGreenInvoiceClient(injector, charge.business_id) : Promise.resolve(undefined); const [greenInvoiceDocument, payment, client] = await Promise.all([ greenInvoiceDocumentPromise, paymentPromise, clientPromise, ]); if (!greenInvoiceDocument) { throw new GraphQLError( `Document with ID "${document.id}" doesn't have a Green Invoice matching document`, ); } const income = getIncomeFromDocuments([ { document, issuedDocument: openIssuedDocument, greenInvoiceDocument, }, ]); const linkedDocsAttributes = getLinkedDocumentsAttributes([openIssuedDocument]); const type = getTypeFromDocumentsAndTransactions([greenInvoiceDocument], transactions ?? []); let remarks = greenInvoiceDocument.remarks; switch (type) { case DocumentType.Receipt: case DocumentType.InvoiceReceipt: remarks = `${getGreenInvoiceDocumentNameFromType(type)} for ${getGreenInvoiceDocumentNameFromType(greenInvoiceDocument.type)} ${greenInvoiceDocument.number}`; break; } if (!remarks && businessMatch?.remark) { remarks = businessMatch.remark; } const documentDate = getDocumentDateOutOfTransactions(transactions ?? []); return { remarks, // description: ____, // footer: ____, type, date: documentDate, dueDate: dateToTimelessDateString(endOfMonth(new Date())), lang: 'ENGLISH', currency: (charge.transactions_currency || charge.documents_currency || defaultCryptoConversionFiatCurrency) as Currency, vatType, rounding: false, signed: true, client, income, payment, // linkedPaymentId: ____, // maxPayments: _____, // discount: _____, ...linkedDocsAttributes, }; }, clientMonthlyChargesDrafts: async (_, { issueMonth }, { injector }) => { const openContracts = await injector.get(ContractsProvider).getAllOpenContracts(); const monthlyContracts = openContracts.filter( contract => contract.billing_cycle === 'monthly', ); const drafts = await Promise.all( monthlyContracts.map(async contract => { const businessPromise = injector .get(BusinessesProvider) .getBusinessByIdLoader.load(contract.client_id); const clientPromise = injector .get(ClientsProvider) .getClientByIdLoader.load(contract.client_id); const greenInvoiceClientPromise = getClientFromGreenInvoiceClient( injector, contract.client_id, ); const [business, client, greenInvoiceClient] = await Promise.all([ businessPromise, clientPromise, greenInvoiceClientPromise, ]); if (!business) { throw new GraphQLError(`Business ID="${contract.client_id}" not found`); } if (!client) { throw new GraphQLError(`Client not found for business ID="${contract.client_id}"`); } if (!greenInvoiceClient) { throw new GraphQLError( `Green invoice match not found for business ID="${contract.client_id}"`, ); } const today = issueMonth ? addMonths(new Date(issueMonth), 1) : 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: NewDocumentInfo = { remarks: `${contract.purchase_order ? `PO: ${contract.purchase_order}${contract.remarks ? ', ' : ''}` : ''}${contract.remarks ?? ''}`, description: `GraphQL Hive Enterprise License - ${month} ${year}`, type: normalizeDocumentType(contract.document_type), date: monthStart, dueDate: monthEnd, lang: 'ENGLISH', currency: contract.currency as Currency, vatType: 'EXEMPT', rounding: false, signed: true, client: { ...greenInvoiceClient, emails: [...((client.emails?.filter(Boolean) as string[]) ?? [])], }, income: [ { description: `GraphQL Hive Enterprise License - ${month} ${year}`, quantity: 1, price: contract.amount, currency: contract.currency as Currency, vatType: 'EXEMPT', }, ], }; return documentInput; }), ); return drafts; }, clientMonthlyChargeDraft: async (_, { clientId, issueMonth }, { injector }) => { const openContracts = await injector.get(ContractsProvider).getAllOpenContracts(); const contract = openContracts.find(openContract => openContract.client_id === clientId); if (!contract) { throw new GraphQLError(`Contract not found for client ID="${clientId}"`); } const businessPromise = injector .get(BusinessesProvider) .getBusinessByIdLoader.load(contract.client_id); const clientPromise = injector .get(ClientsProvider) .getClientByIdLoader.load(contract.client_id); const greenInvoiceClientPromise = getClientFromGreenInvoiceClient( injector, contract.client_id, ); const [business, client, greenInvoiceClient] = await Promise.all([ businessPromise, clientPromise, greenInvoiceClientPromise, ]); if (!business) { throw new GraphQLError(`Business ID="${contract.client_id}" not found`); } if (!client) { throw new GraphQLError(`Client not found for business ID="${contract.client_id}"`); } if (!greenInvoiceClient) { throw new GraphQLError( `Green invoice match not found for business ID="${contract.client_id}"`, ); } const today = issueMonth ? addMonths(new Date(issueMonth), 1) : 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 draft: NewDocumentInfo = { remarks: `${contract.purchase_order ? `PO: ${contract.purchase_order}${contract.remarks ? ', ' : ''}` : ''}${contract.remarks ?? ''}`, description: `GraphQL Hive Enterprise License - ${month} ${year}`, type: normalizeDocumentType(contract.document_type), date: monthStart, dueDate: monthEnd, lang: 'ENGLISH', currency: contract.currency as Currency, vatType: 'EXEMPT', rounding: false, signed: true, client: { ...greenInvoiceClient, emails: [...((client.emails?.filter(Boolean) as string[]) ?? [])], }, income: [ { description: `GraphQL Hive Enterprise License - ${month} ${year}`, quantity: 1, price: contract.amount, currency: contract.currency as Currency, vatType: 'EXEMPT', }, ], }; return draft; }, }, Mutation: { fetchIncomeDocuments: async ( _, { ownerId: inputOwnerId, singlePageLimit = true }, { injector, adminContext: { defaultAdminBusinessId } }, ) => { const ownerId = inputOwnerId ?? defaultAdminBusinessId; const greenInvoiceDocuments = await getGreenInvoiceDocuments(injector, !singlePageLimit); const issuedDocuments = await injector.get(IssuedDocumentsProvider).getAllIssuedDocuments(); // check for new or updated documents const newDocuments: Document[] = []; const documentToUpdate: { localDoc: IGetAllIssuedDocumentsResult; externalDoc: Document; }[] = []; await Promise.all( greenInvoiceDocuments.map(async item => { const existingDocs = issuedDocuments.filter(doc => doc.external_id === item.id); // Throw an error if more than one document found with the same external ID if (existingDocs.length > 1) { throw new GraphQLError( `Found multiple issued documents with the same external ID: ${item.id}`, ); } // If no existing document found, add it to the new documents list if (existingDocs.length === 0) { newDocuments.push(item); return; } // For existing document => check for updates const existingDoc = existingDocs[0]; documentToUpdate.push({ localDoc: existingDoc, externalDoc: item, }); }), ); const addedDocs: IInsertDocumentsResult[] = []; // run insertions first, to avoid broken linked documents await Promise.all( newDocuments.map(async greenInvoiceDoc => { const newDocument = await insertNewDocumentFromGreenInvoice( injector, greenInvoiceDoc, ownerId, ); if (newDocument) { addedDocs.push(newDocument); } }), ); // Check for updates await Promise.all( documentToUpdate.map(async ({ localDoc, externalDoc }) => { const docToUpdate: Partial<IUpdateIssuedDocumentParams> = {}; // check if the document status has changed const latestStatus = greenInvoiceToDocumentStatus(externalDoc.status); if (latestStatus !== localDoc.status) { docToUpdate.status = latestStatus; } // check if the linked documents have changed const linkedDocuments = await getLinkedDocuments(injector, externalDoc.id).catch(e => { console.error('Failed to fetch linked documents', e); return null; }); if ( linkedDocuments?.length && (!localDoc.linked_document_ids || localDoc.linked_document_ids.length !== linkedDocuments.length || localDoc.linked_document_ids?.some(id => !linkedDocuments.includes(id))) ) { docToUpdate.linkedDocumentIds = linkedDocuments; } // if has attributes to update, add to the update list if (Object.keys(docToUpdate).length > 0) { await injector .get(IssuedDocumentsProvider) .updateIssuedDocument({ documentId: localDoc.id, ...docToUpdate, }) .catch(e => { console.error('Failed to update issued document linked documents', e); throw new GraphQLError( `Failed to update issued document linked documents for Green Invoice ID: ${docToUpdate.documentId}`, ); }); } }), ); return addedDocs; }, issueGreenInvoiceDocuments: async ( _, { generateDocumentsInfo }, { injector, adminContext: { defaultAdminBusinessId } }, ) => { const errors: string[] = []; await Promise.all( generateDocumentsInfo.map(document => { executeDocumentIssue( injector, defaultAdminBusinessId, document, undefined, true, undefined, true, ).catch(e => { console.error(e); errors.push(`${document.client?.name ?? document.client?.id}: ${e.message}`); }); }), ); return { success: true, errors, }; }, previewGreenInvoiceDocument: async (_, { input: initialInput }, { injector }) => { const input = await convertDocumentInputIntoGreenInvoiceInput(initialInput, injector); const document = await injector.get(GreenInvoiceClientProvider).previewDocuments({ input }); if (!document) { throw new GraphQLError('Failed to generate document preview'); } if ('errorMessage' in document) { console.error('Failed to generate document preview', document); throw new GraphQLError( `Failed to generate document preview, Green Invoice returned: ${document.errorMessage}`, ); } if ('file' in document && document.file) { return document.file; } console.error('Document preview does not contain a file', document); throw new GraphQLError('Document preview does not contain a file'); }, issueGreenInvoiceDocument: async ( _, { input: initialInput, emailContent, attachment, chargeId, sendEmail = false }, { injector, adminContext: { defaultAdminBusinessId } }, ) => { return executeDocumentIssue( injector, defaultAdminBusinessId, initialInput, emailContent ?? undefined, attachment ?? undefined, chargeId ?? undefined, sendEmail ?? false, ); }, }, IssuedDocumentInfo: { originalDocument: async (info, _, { injector }) => { if (!info?.externalId) { throw new GraphQLError('External ID is required to fetch original document'); } try { const document = await injector .get(GreenInvoiceClientProvider) .documentLoader.load(info.externalId); if (!document) { throw new GraphQLError('Original document not found'); } return convertGreenInvoiceDocumentToLocalDocumentInfo(document); } catch (error) { console.error('Error fetching original document:', error); throw new GraphQLError('Error fetching original document'); } }, }, };