UNPKG

@accounter/server

Version:
350 lines (312 loc) • 13.1 kB
import type { Maybe, ResolverFn, ResolversParentTypes, ResolversTypes, } from '../../../../__generated__/types.js'; import type { Currency } from '../../../../shared/enums.js'; import type { LedgerProto, StrictLedgerProto } from '../../../../shared/types/index.js'; import { AdminContextProvider } from '../../../admin-context/providers/admin-context.provider.js'; import { ExchangeProvider } from '../../../exchange-rates/providers/exchange.provider.js'; import { TransactionsProvider } from '../../../transactions/providers/transactions.provider.js'; import { aggregateConversionSideEntries, conversionFeeCalculator, } from '../../helpers/conversion-charge-ledger.helper.js'; import { isSupplementalFeeTransaction, splitFeeTransactions, } from '../../helpers/fee-transactions.js'; import { storeInitialGeneratedRecords } from '../../helpers/ledgrer-storage.helper.js'; import { getFinancialAccountTaxCategoryId, getLedgerBalanceInfo, LedgerError, ledgerProtoToRecordsConverter, updateLedgerBalanceByEntry, validateTransactionBasicVariables, } from '../../helpers/utils.helper.js'; export const generateLedgerRecordsForConversion: ResolverFn< Maybe<ResolversTypes['GeneratedLedgerRecords']>, ResolversParentTypes['Charge'], GraphQLModules.Context, { insertLedgerRecordsIfNotExists: boolean } > = async (charge, { insertLedgerRecordsIfNotExists }, { injector }) => { const { defaultLocalCurrency, general: { taxCategories: { feeTaxCategoryId, exchangeRevaluationTaxCategoryId }, }, financialAccounts, } = await injector.get(AdminContextProvider).getVerifiedAdminContext(); const chargeId = charge.id; const errors: Set<string> = new Set(); try { // validate ledger records are balanced const ledgerBalance = new Map<string, { amount: number; entityId: string }>(); // generate ledger from transactions const mainFinancialAccountLedgerEntries: LedgerProto[] = []; const feeFinancialAccountLedgerEntries: LedgerProto[] = []; const miscLedgerEntries: LedgerProto[] = []; let baseEntry: LedgerProto | undefined = undefined; let quoteEntry: LedgerProto | undefined = undefined; // Get all transactions const transactions = await injector .get(TransactionsProvider) .transactionsByChargeIDLoader.load(chargeId); const { mainTransactions, feeTransactions } = splitFeeTransactions(transactions); if (mainTransactions.length < 2) { errors.add(`Conversion Charge must include at least two main transactions`); } // for each transaction, create a ledger record const mainTransactionsPromises = mainTransactions.map(async transaction => { try { const { currency, valueDate } = validateTransactionBasicVariables(transaction); let amount = Number(transaction.amount); let foreignAmount: number | undefined = undefined; if (currency !== defaultLocalCurrency) { // get exchange rate for currency const exchangeRate = await injector .get(ExchangeProvider) .getExchangeRates(currency, defaultLocalCurrency, valueDate); foreignAmount = amount; // calculate amounts in ILS amount = exchangeRate * amount; } const accountTaxCategoryId = await getFinancialAccountTaxCategoryId(injector, transaction); const isCreditorCounterparty = amount > 0; const ledgerEntry: LedgerProto = { id: transaction.id, invoiceDate: transaction.event_date, valueDate, currency, ...(isCreditorCounterparty ? { debitAccountID1: accountTaxCategoryId, } : { creditAccountID1: accountTaxCategoryId, }), creditAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined, localCurrencyCreditAmount1: Math.abs(amount), debitAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined, localCurrencyDebitAmount1: Math.abs(amount), description: transaction.source_description ?? undefined, reference: transaction.origin_key, isCreditorCounterparty, ownerId: charge.owner_id, currencyRate: transaction.currency_rate ? Number(transaction.currency_rate) : undefined, chargeId, }; mainFinancialAccountLedgerEntries.push(ledgerEntry); updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency); } catch (e) { if (e instanceof LedgerError) { errors.add(e.message); } else { throw e; } } }); await Promise.all(mainTransactionsPromises); // group main entries into the two conversion sides by sign const quoteEntries: LedgerProto[] = []; const baseEntries: LedgerProto[] = []; for (const entry of mainFinancialAccountLedgerEntries) { if (entry.isCreditorCounterparty) { quoteEntries.push(entry); } else { baseEntries.push(entry); } } // validate each side is single-currency and the two sides differ in currency const baseCurrencies = new Set(baseEntries.map(entry => entry.currency)); const quoteCurrencies = new Set(quoteEntries.map(entry => entry.currency)); if (baseCurrencies.size > 1) { errors.add(`Conversion Charge base transactions must all share the same currency`); } if (quoteCurrencies.size > 1) { errors.add(`Conversion Charge quote transactions must all share the same currency`); } if ( baseCurrencies.size === 1 && quoteCurrencies.size === 1 && [...baseCurrencies][0] === [...quoteCurrencies][0] ) { errors.add(`Conversion Charge base and quote must use different currencies`); } if ( baseEntries.length > 0 && quoteEntries.length > 0 && baseCurrencies.size === 1 && quoteCurrencies.size === 1 && [...baseCurrencies][0] !== [...quoteCurrencies][0] ) { // aggregate each side into a single representative entry for fee & revaluation calculations baseEntry = aggregateConversionSideEntries(baseEntries); quoteEntry = aggregateConversionSideEntries(quoteEntries); } if (!baseEntry || !quoteEntry) { // only report a missing side when it is genuinely absent; currency-validation // failures above already add specific, more accurate errors if (baseEntries.length === 0 || quoteEntries.length === 0) { errors.add(`Conversion Charge must include base and quote main transactions`); } } else { // create a ledger record for fee transactions for (const transaction of feeTransactions) { if (!transaction.is_fee) { continue; } try { const isSupplementalFee = isSupplementalFeeTransaction(transaction, financialAccounts); const { currency, valueDate, transactionBusinessId } = validateTransactionBasicVariables(transaction); let amount = Number(transaction.amount); if (amount === 0) { continue; } let foreignAmount: number | undefined = undefined; if (currency !== defaultLocalCurrency) { // get exchange rate for currency const exchangeRate = await injector .get(ExchangeProvider) .getExchangeRates(currency, defaultLocalCurrency, valueDate); foreignAmount = amount; // calculate amounts in ILS amount = exchangeRate * amount; } const isCreditorCounterparty = amount > 0; if (isSupplementalFee) { const financialAccountTaxCategoryId = await getFinancialAccountTaxCategoryId( injector, transaction, ); feeFinancialAccountLedgerEntries.push({ id: transaction.id, invoiceDate: transaction.event_date, valueDate, currency, creditAccountID1: isCreditorCounterparty ? feeTaxCategoryId : financialAccountTaxCategoryId, creditAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined, localCurrencyCreditAmount1: Math.abs(amount), debitAccountID1: isCreditorCounterparty ? financialAccountTaxCategoryId : feeTaxCategoryId, debitAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined, localCurrencyDebitAmount1: Math.abs(amount), description: transaction.source_description ?? undefined, reference: transaction.origin_key, isCreditorCounterparty, ownerId: charge.owner_id, currencyRate: transaction.currency_rate ? Number(transaction.currency_rate) : undefined, chargeId, }); } else { const businessTaxCategory = quoteEntry.debitAccountID1; if (!businessTaxCategory) { throw new LedgerError( `Quote ledger entry for charge ID=${chargeId} is missing Tax category`, ); } const ledgerEntry: StrictLedgerProto = { id: transaction.id, invoiceDate: transaction.event_date, valueDate, currency, creditAccountID1: isCreditorCounterparty ? feeTaxCategoryId : transactionBusinessId, creditAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined, localCurrencyCreditAmount1: Math.abs(amount), debitAccountID1: isCreditorCounterparty ? transactionBusinessId : feeTaxCategoryId, debitAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined, localCurrencyDebitAmount1: Math.abs(amount), description: transaction.source_description ?? undefined, reference: transaction.origin_key, isCreditorCounterparty: !isCreditorCounterparty, ownerId: charge.owner_id, currencyRate: transaction.currency_rate ? Number(transaction.currency_rate) : undefined, chargeId, }; feeFinancialAccountLedgerEntries.push(ledgerEntry); updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency); } } catch (e) { if (e instanceof LedgerError) { errors.add(e.message); } else { throw e; } } } // calculate conversion fee const [quoteRate, baseRate] = await Promise.all( [quoteEntry.currency, baseEntry.currency].map(currency => injector .get(ExchangeProvider) .getExchangeRates(currency as Currency, defaultLocalCurrency, baseEntry!.valueDate), ), ); const directRate = quoteRate / baseRate; try { const conversionFeeInLocalAmount = conversionFeeCalculator( baseEntry, quoteEntry, directRate, defaultLocalCurrency, ); if (conversionFeeInLocalAmount !== 0) { const isDebitConversion = conversionFeeInLocalAmount >= 0; const ledgerEntry: LedgerProto = { id: quoteEntry.id + '|revaluation', // NOTE: this field is dummy creditAccountID1: isDebitConversion ? exchangeRevaluationTaxCategoryId : undefined, localCurrencyCreditAmount1: Math.abs(conversionFeeInLocalAmount), debitAccountID1: isDebitConversion ? undefined : exchangeRevaluationTaxCategoryId, localCurrencyDebitAmount1: Math.abs(conversionFeeInLocalAmount), description: 'Exchange Revaluation', isCreditorCounterparty: true, invoiceDate: quoteEntry.invoiceDate, valueDate: quoteEntry.valueDate, currency: defaultLocalCurrency, reference: quoteEntry.reference, ownerId: quoteEntry.ownerId, chargeId, }; miscLedgerEntries.push(ledgerEntry); updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency); } } catch (e) { if (e instanceof LedgerError) { errors.add(e.message); } else { throw e; } } } const ledgerBalanceInfo = await getLedgerBalanceInfo(injector, ledgerBalance, errors); const records = [ ...mainFinancialAccountLedgerEntries, ...feeFinancialAccountLedgerEntries, ...miscLedgerEntries, ]; if (insertLedgerRecordsIfNotExists) { await storeInitialGeneratedRecords(charge.id, records, injector); } return { records: ledgerProtoToRecordsConverter(records), charge, balance: ledgerBalanceInfo, errors: Array.from(errors), }; } catch (e) { return { __typename: 'CommonError', message: `Failed to generate ledger records for charge ID="${chargeId}"\n${e}`, }; } };