@accounter/server
Version:
Accounter GraphQL server
460 lines (406 loc) • 17.7 kB
text/typescript
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 { BankDepositChargesProvider } from '../../../bank-deposits/providers/bank-deposit-charges.provider.js';
import { getChargeBusinesses } from '../../../charges/helpers/common.helper.js';
import { ExchangeProvider } from '../../../exchange-rates/providers/exchange.provider.js';
import { TaxCategoriesProvider } from '../../../financial-entities/providers/tax-categories.provider.js';
import { TransactionsProvider } from '../../../transactions/providers/transactions.provider.js';
import type { IGetTransactionsByChargeIdsResult } from '../../../transactions/types.js';
import { identifyInterestTransactionIds } from '../../helpers/bank-deposit-ledger-generation.helper.js';
import { ledgerEntryFromMainTransaction } from '../../helpers/common-charge-ledger.helper.js';
import { calculateExchangeRate } from '../../helpers/exchange-ledger.helper.js';
import {
convertLedgerRecordToProto,
storeInitialGeneratedRecords,
} from '../../helpers/ledgrer-storage.helper.js';
import { generateMiscExpensesLedger } from '../../helpers/misc-expenses-ledger.helper.js';
import {
getFinancialAccountTaxCategoryId,
getLedgerBalanceInfo,
LedgerError,
ledgerProtoToRecordsConverter,
updateLedgerBalanceByEntry,
validateTransactionBasicVariables,
} from '../../helpers/utils.helper.js';
import { LedgerProvider } from '../../providers/ledger.provider.js';
export const generateLedgerRecordsForBankDeposit: ResolverFn<
Maybe<ResolversTypes['GeneratedLedgerRecords']>,
ResolversParentTypes['Charge'],
GraphQLModules.Context,
{ insertLedgerRecordsIfNotExists: boolean }
> = async (charge, { insertLedgerRecordsIfNotExists }, { injector }) => {
const chargeId = charge.id;
const {
ownerId,
defaultLocalCurrency,
general: {
taxCategories: { exchangeRateTaxCategoryId },
},
} = await injector.get(AdminContextProvider).getVerifiedAdminContext();
const errors: Set<string> = new Set();
try {
// validate ledger records are balanced
const ledgerBalance = new Map<
string,
{
amount: number;
entityId: string;
foreignAmounts?: Partial<Record<Currency, { local: number; foreign: number }>>;
}
>();
const transactionsPromise = injector
.get(TransactionsProvider)
.transactionsByChargeIDLoader.load(chargeId);
const bankDepositTransactionsPromise = injector
.get(BankDepositChargesProvider)
.getDepositTransactionsByChargeId(chargeId);
const [transactions, bankDepositTransactions, { mainBusinessId }] = await Promise.all([
transactionsPromise,
bankDepositTransactionsPromise,
getChargeBusinesses(chargeId, injector),
]);
const entriesPromises: Array<Promise<void>> = [];
const financialAccountLedgerEntries: StrictLedgerProto[] = [];
const interestLedgerEntries: StrictLedgerProto[] = [];
const feeFinancialAccountLedgerEntries: LedgerProto[] = [];
let isWithdrawal = false;
let isBreathingDeposit = false;
// generate ledger from transactions
const [mainTransaction, interestTransactions] = transactions.reduce(
(
[mainTransaction, interestTransactions]: [
IGetTransactionsByChargeIdsResult,
IGetTransactionsByChargeIdsResult[],
],
current,
) => {
if (mainTransaction.id === current.id) {
return [mainTransaction, interestTransactions];
}
return Number(current.amount) > Number(mainTransaction.amount)
? [current, [mainTransaction, ...interestTransactions]]
: [mainTransaction, [current, ...interestTransactions]];
},
[transactions[0], []],
);
if (Number(mainTransaction.amount) > 0) {
isWithdrawal = true;
}
const mainTransactionPromise = async () =>
ledgerEntryFromMainTransaction(
mainTransaction,
injector,
chargeId,
charge.owner_id,
mainBusinessId ?? undefined,
)
.then(ledgerEntry => {
financialAccountLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
})
.catch(e => {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
throw e;
}
});
// create a ledger record for fee transactions
const interestTransactionsPromises = interestTransactions.map(async transaction => {
// for each transaction, create a ledger record
const { currency, valueDate, transactionBusinessId } =
validateTransactionBasicVariables(transaction);
const businessTaxCategory = await injector
.get(TaxCategoriesProvider)
.taxCategoryByBusinessIDsLoader.load(transactionBusinessId);
if (!businessTaxCategory) {
errors.add(`Business ID="${transactionBusinessId}" is missing tax category`);
return;
}
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: StrictLedgerProto = {
id: transaction.id,
invoiceDate: transaction.event_date,
valueDate,
currency,
creditAccountID1: isCreditorCounterparty ? businessTaxCategory.id : accountTaxCategoryId,
creditAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined,
localCurrencyCreditAmount1: Math.abs(amount),
debitAccountID1: isCreditorCounterparty ? accountTaxCategoryId : businessTaxCategory.id,
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,
};
interestLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
});
// generate ledger from misc expenses
const expensesLedgerPromise = generateMiscExpensesLedger(charge, injector).then(entries => {
entries.map(entry => {
entry.ownerId = charge.owner_id;
feeFinancialAccountLedgerEntries.push(entry);
updateLedgerBalanceByEntry(entry, ledgerBalance, defaultLocalCurrency);
});
});
entriesPromises.push(
mainTransactionPromise(),
...interestTransactionsPromises,
expensesLedgerPromise,
);
await Promise.all(entriesPromises);
const miscLedgerEntries: StrictLedgerProto[] = [];
const miscLedgerEntriesPromise = async () => {
const mainLedgerEntry = financialAccountLedgerEntries[0];
if (!mainLedgerEntry) {
throw new LedgerError('Main ledger entry not found');
}
const depositTransactions = bankDepositTransactions.filter(t => Number(t.amount) < 0);
if (depositTransactions.length === 0 && isWithdrawal) {
throw new LedgerError('Deposit transaction not found');
}
if (depositTransactions.length > 1) {
isBreathingDeposit = true;
// Validate consistent currency
const depositCurrency = depositTransactions[0].currency as Currency;
for (const tx of depositTransactions) {
if (tx.currency !== depositCurrency) {
throw new LedgerError(
`Deposit has inconsistent currencies: found ${tx.currency} and ${depositCurrency}`,
);
}
}
// If currency is local, no revaluation needed
if (depositCurrency === defaultLocalCurrency) {
return;
}
// Filter out interest transactions via shared helper
const interestTransactionIds = identifyInterestTransactionIds(depositTransactions, {
getId: tx => tx.id,
getChargeId: tx => tx.charge_id,
getAmount: tx => Number(tx.amount),
});
const nonInterestTransactions = depositTransactions.filter(
tx => !interestTransactionIds.has(tx.id),
);
// Find latest deposit/withdrawal transaction prior to current
const mainTxDate = mainTransaction.debit_date ?? mainTransaction.event_date;
const prevTransactions = nonInterestTransactions.filter(tx => {
const txDate = tx.debit_date ?? tx.event_date;
if (txDate < mainTxDate) {
return true;
}
if (txDate.getTime() === mainTxDate.getTime()) {
// Same date: use ID ordering
return tx.id < mainTransaction.id;
}
return false;
});
if (prevTransactions.length === 0) {
throw new LedgerError('No previous deposit transaction found for revaluation');
}
// Sort by date desc, then by id desc to get the latest
prevTransactions.sort((a, b) => {
const dateA = a.debit_date ?? a.event_date;
const dateB = b.debit_date ?? b.event_date;
if (dateB.getTime() !== dateA.getTime()) {
return dateB.getTime() - dateA.getTime();
}
return b.id.localeCompare(a.id);
});
const prevAction = prevTransactions[0];
const prevActionDate = prevAction.debit_date ?? prevAction.event_date;
// Calculate origin balance at prev action (sum of all non-interest up to and including prev action)
let originBalance = 0;
for (const tx of nonInterestTransactions) {
const txDate = tx.debit_date ?? tx.event_date;
if (
txDate < prevActionDate ||
(txDate.getTime() === prevActionDate.getTime() && tx.id <= prevAction.id)
) {
originBalance += Number(tx.amount);
}
}
// Get exchange rate for prev action and calculate prev action local balance
const prevExchangeRate = await injector
.get(ExchangeProvider)
.getExchangeRates(depositCurrency, defaultLocalCurrency, prevActionDate);
const prevLocalBalance = originBalance * prevExchangeRate;
// Get current exchange rate
const currentExchangeRate = await injector
.get(ExchangeProvider)
.getExchangeRates(depositCurrency, defaultLocalCurrency, mainLedgerEntry.valueDate);
// Calculate revaluation: (origin balance * current rate) - prev local balance
const rawRevaluationAmount = originBalance * currentExchangeRate - prevLocalBalance;
const revaluationAmount = Math.abs(rawRevaluationAmount);
if (revaluationAmount > 0) {
const isCreditorCounterparty = rawRevaluationAmount > 0;
const mainAccountId = mainLedgerEntry.creditAccountID1;
const revaluationLedgerEntry: StrictLedgerProto = {
id: mainTransaction.id + '|revaluation',
creditAccountID1: isCreditorCounterparty ? mainAccountId : exchangeRateTaxCategoryId,
localCurrencyCreditAmount1: revaluationAmount,
debitAccountID1: isCreditorCounterparty ? exchangeRateTaxCategoryId : mainAccountId,
localCurrencyDebitAmount1: revaluationAmount,
description: 'Revaluation ledger record',
isCreditorCounterparty,
invoiceDate: mainLedgerEntry.valueDate,
valueDate: mainLedgerEntry.valueDate,
currency: defaultLocalCurrency,
ownerId: mainLedgerEntry.ownerId,
chargeId,
};
miscLedgerEntries.push(revaluationLedgerEntry);
updateLedgerBalanceByEntry(revaluationLedgerEntry, ledgerBalance, defaultLocalCurrency);
}
} else if (isWithdrawal) {
const mainBusinessBalance = mainTransaction.business_id
? ledgerBalance.get(mainTransaction.business_id)
: undefined;
const mainBusinessBalanceByCurrency =
mainTransaction.currency === defaultLocalCurrency
? mainBusinessBalance?.amount
: mainBusinessBalance?.foreignAmounts?.[mainTransaction.currency]?.foreign;
const mainBusinessAbsBalance = mainBusinessBalanceByCurrency
? Math.abs(mainBusinessBalanceByCurrency)
: undefined;
const initialDepositTransaction = depositTransactions[0];
const depositTransactionAmount = Math.abs(Number(initialDepositTransaction.amount));
if (
mainBusinessAbsBalance !== depositTransactionAmount ||
mainTransaction.currency !== initialDepositTransaction.currency
) {
throw new LedgerError('Deposit transaction does not match the withdrawal transaction');
}
const depositLedgerRecords = await injector
.get(LedgerProvider)
.getLedgerRecordsByChargesIdLoader.load(initialDepositTransaction.charge_id);
if (depositLedgerRecords.length !== 1) {
if (depositLedgerRecords.length === 0) {
throw new LedgerError('Deposit ledger record not found');
}
throw new LedgerError('Multiple deposit ledger records found');
}
const depositLedgerRecord = depositLedgerRecords[0];
if (Number.isNaN(depositLedgerRecord.credit_local_amount1)) {
throw new LedgerError('Deposit ledger record has invalid local amount');
}
if (depositLedgerRecord.debit_entity1) {
updateLedgerBalanceByEntry(
convertLedgerRecordToProto(depositLedgerRecord, ownerId),
ledgerBalance,
defaultLocalCurrency,
);
}
if (mainTransaction.currency !== defaultLocalCurrency) {
const rawAmount =
Number(depositLedgerRecord.credit_local_amount1) -
mainLedgerEntry.localCurrencyCreditAmount1;
const amount = Math.abs(rawAmount);
const isCreditorCounterparty = rawAmount > 0;
const mainAccountId = mainLedgerEntry.creditAccountID1;
// validate exchange rate
try {
const exchangeAmount = calculateExchangeRate(
mainLedgerEntry.creditAccountID1,
[
...financialAccountLedgerEntries,
convertLedgerRecordToProto(depositLedgerRecord, ownerId),
],
defaultLocalCurrency,
);
if (
(!exchangeAmount && !!amount) ||
(exchangeAmount && Math.abs(exchangeAmount) !== amount)
) {
throw new LedgerError('Exchange rate error');
}
} catch (e) {
errors.add((e as Error).message);
}
const exchangeLedgerEntry: StrictLedgerProto = {
id: mainTransaction.id + '|fee', // NOTE: this field is dummy
creditAccountID1: isCreditorCounterparty ? mainAccountId : exchangeRateTaxCategoryId,
localCurrencyCreditAmount1: amount,
debitAccountID1: isCreditorCounterparty ? exchangeRateTaxCategoryId : mainAccountId,
localCurrencyDebitAmount1: amount,
description: 'Exchange ledger record',
isCreditorCounterparty,
invoiceDate: mainLedgerEntry.valueDate,
valueDate: mainLedgerEntry.valueDate,
currency: defaultLocalCurrency,
ownerId: mainLedgerEntry.ownerId,
chargeId,
};
miscLedgerEntries.push(exchangeLedgerEntry);
updateLedgerBalanceByEntry(exchangeLedgerEntry, ledgerBalance, defaultLocalCurrency);
}
}
return;
};
await miscLedgerEntriesPromise().catch(e => {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
throw e;
}
});
const records = [
...financialAccountLedgerEntries,
...interestLedgerEntries,
...feeFinancialAccountLedgerEntries,
...miscLedgerEntries,
];
if (insertLedgerRecordsIfNotExists) {
await storeInitialGeneratedRecords(charge.id, records, injector);
}
const allowedUnbalancedBusinesses = new Set<string>();
const mainLedgerEntry = financialAccountLedgerEntries[0];
if (!mainLedgerEntry.isCreditorCounterparty) {
allowedUnbalancedBusinesses.add(mainLedgerEntry.debitAccountID1);
}
if (mainBusinessId && isBreathingDeposit) {
allowedUnbalancedBusinesses.add(mainBusinessId);
}
const ledgerBalanceInfo = await getLedgerBalanceInfo(
injector,
ledgerBalance,
errors,
allowedUnbalancedBusinesses,
);
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}`,
};
}
};