UNPKG

@accounter/server

Version:
458 lines (414 loc) • 15.7 kB
import { addMonths, endOfMonth, format, startOfMonth, subMonths } from 'date-fns'; import { GraphQLError } from 'graphql'; import type { Injector } from 'graphql-modules'; import type { _DOLLAR_defs_Document } from '@accounter/green-invoice-graphql'; import type { DocumentDraft, DocumentIncomeRecord, DocumentIssueInput, DocumentLinkType, DocumentPaymentRecord, DocumentVatType, PaymentType, ResolversTypes, } from '../../../__generated__/types.js'; import { Currency, DocumentType } from '../../../shared/enums.js'; import { dateToTimelessDateString } from '../../../shared/helpers/index.js'; import type { TimelessDateString } from '../../../shared/types/index.js'; import type { AdminContext } from '../../admin-context/types.js'; import { GreenInvoiceClientProvider } from '../../app-providers/green-invoice-client.js'; import { ChargesProvider } from '../../charges/providers/charges.provider.js'; import { getProductName, getSubscriptionPlanName, normalizeProduct, normalizeSubscriptionPlan, } from '../../contracts/helpers/contracts.helper.js'; import type { IGetContractsByIdsResult } from '../../contracts/types.js'; import { FinancialAccountsProvider } from '../../financial-accounts/providers/financial-accounts.provider.js'; import { FinancialBankAccountsProvider } from '../../financial-accounts/providers/financial-bank-accounts.provider.js'; import type { IGetFinancialBankAccountsByIdsResult } from '../../financial-accounts/types.js'; import { validateClientIntegrations } from '../../financial-entities/helpers/clients.helper.js'; import { BusinessesProvider } from '../../financial-entities/providers/businesses.provider.js'; import { ClientsProvider } from '../../financial-entities/providers/clients.provider.js'; import { convertDocumentInputIntoGreenInvoiceInput, getTypeFromGreenInvoiceDocument, getVatTypeFromGreenInvoiceDocument, insertNewDocumentFromGreenInvoice, } from '../../green-invoice/helpers/green-invoice.helper.js'; import type { IGetTransactionsByChargeIdsResult } from '../../transactions/types.js'; import { IssuedDocumentsProvider } from '../providers/issued-documents.provider.js'; import { normalizeDocumentType } from '../resolvers/common.js'; import type { IGetIssuedDocumentsByIdsResult } from '../types.js'; export async function getPaymentsFromTransactions( injector: Injector, transactions: IGetTransactionsByChargeIdsResult[], ): Promise<DocumentPaymentRecord[]> { const payments = await Promise.all( transactions.map(async transaction => { // get account const account = await injector .get(FinancialAccountsProvider) .getFinancialAccountByAccountIDLoader.load(transaction.account_id); if (!account) { throw new Error(`Account with ID ${transaction.account_id} not found`); } // get account type let type: PaymentType = 'OTHER'; switch (account.type) { case 'BANK_ACCOUNT': type = 'WIRE_TRANSFER'; break; case 'CREDIT_CARD': type = 'CREDIT_CARD'; break; } let bankAccount: IGetFinancialBankAccountsByIdsResult | undefined = undefined; if (account.type === 'BANK_ACCOUNT') { bankAccount = await injector .get(FinancialBankAccountsProvider) .getFinancialBankAccountByIdLoader.load(account.id); if (!bankAccount) { throw new GraphQLError( `Bank account details not found for financial account ID "${account.id}"`, ); } } // get further fields let paymentTypeSpecificAttributes: Partial<DocumentPaymentRecord> = {}; switch (type as string) { case 'CHEQUE': paymentTypeSpecificAttributes = { // chequeNum: _____, }; break; case 'CREDIT_CARD': paymentTypeSpecificAttributes = { cardType: 'MASTERCARD', // TODO: add logic to support other card types cardNum: account.account_number, numPayments: 1, firstPayment: transaction.event_date.getTime() / 1000, // assuming first payment is the transaction date }; break; case 'WIRE_TRANSFER': paymentTypeSpecificAttributes = { bankName: bankAccount?.bank_number?.toString(), bankBranch: bankAccount?.branch_number?.toString(), bankAccount: account.account_number?.toString(), }; break; case 'PAYMENT_APP': paymentTypeSpecificAttributes = { // appType: _____, // accountId: _____, // transactionId: _____, }; break; case 'OTHER': paymentTypeSpecificAttributes = { // subType: _____, // accountId: _____, // transactionId: _____, }; break; case 'PAYPAL': paymentTypeSpecificAttributes = { // accountId: _____, // transactionId: _____, }; break; case 'CASH': case 'TAX_DEDUCTION': case 'OTHER_DEDUCTION': break; } const payment: DocumentPaymentRecord = { currency: transaction.currency as Currency, currencyRate: undefined, date: dateToTimelessDateString(transaction.debit_date ?? transaction.event_date), price: Number(transaction.amount), type, transactionId: transaction.id, accountId: account.id, ...paymentTypeSpecificAttributes, }; return payment; }), ); return payments; } export function getIncomeRecordsFromDocuments( greenInvoiceDocuments: _DOLLAR_defs_Document[], ): DocumentIncomeRecord[] { const incomes = greenInvoiceDocuments .map(greenInvoiceDocument => { return greenInvoiceDocument.income.filter(Boolean).map(originIncome => { if (!originIncome?.currency) { throw new Error('Income currency is missing'); } const income: DocumentIncomeRecord = { description: originIncome.description, quantity: originIncome.quantity, price: originIncome.price, currency: originIncome.currency as Currency, currencyRate: undefined, vatRate: originIncome.vatRate, itemId: originIncome.itemId, vatType: getVatTypeFromGreenInvoiceDocument(greenInvoiceDocument.vatType), }; return income; }); }) .flat(); return incomes; } export function getTypeFromDocumentsAndTransactions( documents: { type: DocumentType }[], transactions: IGetTransactionsByChargeIdsResult[], ): DocumentType { if (!documents.length) { if (transactions.length) { return DocumentType.InvoiceReceipt; } return DocumentType.Proforma; } const documentsTypes = new Set<DocumentType>(documents.map(doc => doc.type)); if (documentsTypes.size === 1) { switch (Array.from(documentsTypes)[0]) { case DocumentType.Invoice: return DocumentType.Receipt; case DocumentType.Proforma: return DocumentType.InvoiceReceipt; } } return DocumentType.Proforma; } export function filterAndHandleSwiftTransactions( originTransactions: IGetTransactionsByChargeIdsResult[], swiftBusinessId: string | null, ): IGetTransactionsByChargeIdsResult[] { const swiftTransactions: IGetTransactionsByChargeIdsResult[] = []; const incomeTransactions: IGetTransactionsByChargeIdsResult[] = []; for (const transaction of originTransactions) { if (swiftBusinessId && transaction.business_id === swiftBusinessId) { swiftTransactions.push(transaction); } else if (Number(transaction.amount) > 0) { incomeTransactions.push(transaction); } } if (swiftTransactions.length === 0) { return incomeTransactions; } if (swiftTransactions.length === 1 && incomeTransactions.length === 1) { const incomeTransaction = incomeTransactions[0]; return [ { ...incomeTransaction, amount: (Number(incomeTransaction.amount) - Number(swiftTransactions[0].amount)).toFixed(2), }, ]; } throw new GraphQLError( "Unable to process transactions. Couldn't match swift fees to transactions", ); } export function getLinkedDocumentsAttributes( issuedDocuments: IGetIssuedDocumentsByIdsResult[], shouldCancel: boolean = false, ): Pick<DocumentDraft, 'linkedDocumentIds' | 'linkType'> { const linkedDocumentIds = issuedDocuments.map(doc => doc.external_id); let linkType: DocumentLinkType | undefined = undefined; if (linkedDocumentIds.length) { if (shouldCancel) { linkType = 'CANCEL'; } else { linkType = 'LINK'; } } return { linkedDocumentIds, linkType, }; } export function getDocumentDateOutOfTransactions( transactions: IGetTransactionsByChargeIdsResult[], ): TimelessDateString | undefined { const debitDates = transactions.map(tx => tx.debit_date).filter(Boolean) as Date[]; // if no debit dates, use current date if (!debitDates.length) return dateToTimelessDateString(new Date()); // Sort dates and take the first one const sortedDates = [...debitDates].sort((a, b) => b.getTime() - a.getTime()); const firstDate = sortedDates[0]; // Return the date in the required format return dateToTimelessDateString(firstDate); } export async function deduceVatTypeFromBusiness( injector: Injector, locality: string, businessId?: string | null, ): Promise<DocumentVatType> { if (!businessId) return 'DEFAULT'; const business = await injector.get(BusinessesProvider).getBusinessByIdLoader.load(businessId); if (!business) return 'DEFAULT'; // Deduce VAT type based on business information if (business.country === locality && !business.exempt_dealer) { return 'MIXED'; } return 'EXEMPT'; } export function createRemarks(contract: IGetContractsByIdsResult): string { const remarks: string[] = []; if (contract.purchase_orders?.length) { const lastPo = contract.purchase_orders[contract.purchase_orders.length - 1]; if (lastPo) { remarks.push(`PO: ${lastPo}`); } } if (contract.remarks) { remarks.push(contract.remarks); } return remarks.join(', '); } export const convertContractToDraft = async ( injector: Injector, contract: IGetContractsByIdsResult, locality: AdminContext['locality'], issueMonth: TimelessDateString, ) => { const businessPromise = injector .get(BusinessesProvider) .getBusinessByIdLoader.load(contract.client_id); const clientPromise = injector.get(ClientsProvider).getClientByIdLoader.load(contract.client_id); const [business, client] = await Promise.all([businessPromise, clientPromise]); 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}"`); } const greenInvoiceId = validateClientIntegrations(client.integrations)?.greenInvoiceId; if (!greenInvoiceId) { 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 vatType = await deduceVatTypeFromBusiness(injector, locality, contract.client_id); const documentInput: ResolversTypes['DocumentDraft'] = { remarks: createRemarks(contract), description: `${getProductName(normalizeProduct(contract.product ?? '')!)} ${getSubscriptionPlanName(normalizeSubscriptionPlan(contract.plan ?? '')!)} - ${month} ${year}`, type: normalizeDocumentType(contract.document_type), date: monthStart, dueDate: monthEnd, language: 'ENGLISH', currency: contract.currency as Currency, vatType, rounding: false, signed: true, client, income: [ { description: `${getProductName(normalizeProduct(contract.product ?? '')!)} ${getSubscriptionPlanName(normalizeSubscriptionPlan(contract.plan ?? '')!)} - ${month} ${year}`, quantity: 1, price: contract.amount, currency: contract.currency as Currency, vatType: 'EXEMPT', }, ], }; return documentInput; }; export async function executeDocumentIssue( injector: Injector, adminBusinessId: string, initialInput: DocumentIssueInput, emailContent?: string, attachment = true, chargeId?: string, sendEmail = false, ) { try { const coreInput = await convertDocumentInputIntoGreenInvoiceInput(initialInput, injector); const input = { ...coreInput, client: !coreInput.client || sendEmail ? coreInput.client : { ...coreInput.client, emails: [], // if client exists, and sendEmail is false, we don't want to send emails }, emailContent, attachment, }; const document = await injector.get(GreenInvoiceClientProvider).addDocuments({ input }); if (!document) { throw new GraphQLError('Failed to issue new document'); } // validate all linked documents are closed if (input.linkedDocumentIds?.length) { await Promise.all( input.linkedDocumentIds.map(async id => { if (id) { await injector .get(GreenInvoiceClientProvider) .closeDocument(id) .catch(e => { const message = `ERROR: Failed to close Green Invoice linked document with ID ${id}`; console.error(`${message}: ${e}`); }); } }), ); } if ('id' in document && document.id) { const greenInvoiceDocument = await injector .get(GreenInvoiceClientProvider) .documentLoader.load(document.id); if (!greenInvoiceDocument) { console.error('Failed to fetch issued document from Green Invoice', document); throw new GraphQLError('Failed to issue new document'); } // Insert new issued document to DB const newDocument = await insertNewDocumentFromGreenInvoice( injector, greenInvoiceDocument, adminBusinessId, chargeId, ); if (!newDocument.charge_id) { console.error('New document does not have a charge ID', newDocument); throw new GraphQLError('Failed to issue new document'); } // Close linked documents if (coreInput.linkedDocumentIds?.length) { await Promise.all( coreInput.linkedDocumentIds.map(async id => { if (id) { await injector .get(IssuedDocumentsProvider) .updateIssuedDocumentByExternalId({ externalId: id, status: 'CLOSED' }) .catch(e => { const message = `Failed to close linked document with ID ${id}`; console.error(`${message}: ${e}`); throw new Error(message); }); } }), ); } if (getTypeFromGreenInvoiceDocument(coreInput.type) === DocumentType.CreditInvoice) { // Close origin } return injector.get(ChargesProvider).getChargeByIdLoader.load(newDocument.charge_id); } console.error('Document issue failed', document); throw new GraphQLError('Document issue failed'); } catch (error) { console.error('Error issuing document:', error); throw new GraphQLError('Error issuing document'); } }