@accounter/server
Version:
Accounter GraphQL server
405 lines • 22.4 kB
JavaScript
import { lastDayOfMonth } from 'date-fns';
import { AdminContextProvider } from '../../../admin-context/providers/admin-context.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 { EmployeesProvider } from '../../../salaries/providers/employees.provider.js';
import { FundsProvider } from '../../../salaries/providers/funds.provider.js';
import { SalariesProvider } from '../../../salaries/providers/salaries.provider.js';
import { TransactionsProvider } from '../../../transactions/providers/transactions.provider.js';
import { validateExchangeRate } from '../../helpers/exchange-ledger.helper.js';
import { getEntriesFromFeeTransaction, splitFeeTransactions, } from '../../helpers/fee-transactions.js';
import { storeInitialGeneratedRecords } from '../../helpers/ledgrer-storage.helper.js';
import { generateMiscExpensesLedger } from '../../helpers/misc-expenses-ledger.helper.js';
import { generateEntriesFromSalaryRecords } from '../../helpers/salary-charge-ledger.helper.js';
import { generatePartialLedgerEntry, getFinancialAccountTaxCategoryId, getLedgerBalanceInfo, LedgerError, ledgerProtoToRecordsConverter, updateLedgerBalanceByEntry, validateTransactionRequiredVariables, } from '../../helpers/utils.helper.js';
import { BalanceCancellationProvider } from '../../providers/balance-cancellation.provider.js';
import { UnbalancedBusinessesProvider } from '../../providers/unbalanced-businesses.provider.js';
import { generateLedgerRecordsForCommonCharge } from './common-ledger-generation.resolver.js';
export const generateLedgerRecordsForSalary = async (charge, { insertLedgerRecordsIfNotExists }, context, _info) => {
const { injector } = context;
const { allBusinessIds } = await getChargeBusinesses(charge.id, injector);
if (allBusinessIds.length === 1) {
// for one business, use the default ledger generation
return generateLedgerRecordsForCommonCharge(charge, { insertLedgerRecordsIfNotExists }, context, _info);
}
const chargeId = charge.id;
const errors = new Set();
const { defaultLocalCurrency, general: { taxCategories: { balanceCancellationTaxCategoryId, exchangeRateTaxCategoryId }, }, salaries: { batchedFundsBusinessId, batchedEmployeesBusinessId, salaryBatchedBusinessIds }, } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
try {
// validate ledger records are balanced
const ledgerBalance = new Map();
let entriesPromises = [];
const accountingLedgerEntries = [];
const foreignCurrencySalaryBusinesses = new Set();
// Get relevant data for generation
const salaryRecordsPromise = injector
.get(SalariesProvider)
.getSalaryRecordsByChargeIdLoader.load(chargeId);
const transactionsPromise = injector
.get(TransactionsProvider)
.transactionsByChargeIDLoader.load(chargeId);
const unbalancedBusinessesPromise = injector
.get(UnbalancedBusinessesProvider)
.getChargeUnbalancedBusinessesByChargeIds.load(chargeId);
const chargeBallanceCancellationsPromise = injector
.get(BalanceCancellationProvider)
.getBalanceCancellationByChargesIdLoader.load(chargeId);
const [salaryRecords, transactions, unbalancedBusinesses, balanceCancellations, adminContext] = await Promise.all([
salaryRecordsPromise,
transactionsPromise,
unbalancedBusinessesPromise,
chargeBallanceCancellationsPromise,
injector.get(AdminContextProvider).getVerifiedAdminContext(),
]);
// generate ledger from salary records
try {
const { entries, monthlyEntriesProto, month } = generateEntriesFromSalaryRecords(salaryRecords, charge, adminContext);
entries.map(ledgerEntry => {
accountingLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
});
// generate monthly expenses ledger entries
const monthlyEntriesPromises = monthlyEntriesProto
.filter(({ amount }) => amount !== 0)
.map(async ({ taxCategoryId, amount, isCredit }) => {
const taxCategory = await injector
.get(TaxCategoriesProvider)
.taxCategoryByIdLoader.load(taxCategoryId);
if (!taxCategory) {
throw new LedgerError(`Tax category "${taxCategoryId}" not found`);
}
const ledgerEntry = {
id: taxCategoryId,
invoiceDate: lastDayOfMonth(new Date(`${month}-01`)),
valueDate: lastDayOfMonth(new Date(`${month}-01`)),
currency: defaultLocalCurrency,
...(isCredit
? { creditAccountID1: taxCategoryId }
: { debitAccountID1: taxCategoryId }),
localCurrencyCreditAmount1: amount,
localCurrencyDebitAmount1: amount,
description: `${month} salary: ${taxCategory.name}`,
isCreditorCounterparty: false,
ownerId: charge.owner_id,
chargeId,
};
accountingLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
});
entriesPromises.push(...monthlyEntriesPromises);
}
catch (e) {
if (e instanceof LedgerError) {
errors.add(e.message);
}
else {
throw e;
}
}
const { mainTransactions, feeTransactions } = splitFeeTransactions(transactions);
// generate ledger from main transactions
const batchedTransactionEntriesMaterials = [];
// for each common transaction, create a ledger record
const financialAccountLedgerEntries = [];
const miscExpensesLedgerEntries = [];
const transactionEntriesPromises = mainTransactions.map(async (preValidatedTransaction) => {
try {
const transaction = validateTransactionRequiredVariables(preValidatedTransaction);
// preparations for core ledger entries
let exchangeRate = undefined;
if (transaction.currency !== defaultLocalCurrency) {
// get exchange rate for currency
exchangeRate = await injector
.get(ExchangeProvider)
.getExchangeRates(transaction.currency, defaultLocalCurrency, transaction.debit_timestamp);
}
const partialEntry = generatePartialLedgerEntry(transaction, charge.owner_id, exchangeRate);
const financialAccountTaxCategoryId = await getFinancialAccountTaxCategoryId(injector, transaction);
if (transaction.business_id && salaryBatchedBusinessIds.includes(transaction.business_id)) {
batchedTransactionEntriesMaterials.push({
transaction,
partialEntry,
taxCategoryId: financialAccountTaxCategoryId,
});
return;
}
const ledgerEntry = {
...partialEntry,
...(partialEntry.isCreditorCounterparty
? {
creditAccountID1: transaction.business_id,
debitAccountID1: financialAccountTaxCategoryId,
}
: {
creditAccountID1: financialAccountTaxCategoryId,
debitAccountID1: transaction.business_id,
}),
};
financialAccountLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
// on foreign currency salary, update matching accounting ledger entry
if (ledgerEntry.currency !== defaultLocalCurrency) {
const matchingEntry = accountingLedgerEntries.find(accountingEntry => {
return (transaction.business_id ===
(accountingEntry.isCreditorCounterparty
? accountingEntry.debitAccountID1
: accountingEntry.creditAccountID1));
});
if (matchingEntry && matchingEntry.currency !== ledgerEntry.currency) {
matchingEntry.currency = ledgerEntry.currency;
matchingEntry.currencyRate = ledgerEntry.currencyRate;
matchingEntry.creditAmount1 = ledgerEntry.creditAmount1;
matchingEntry.debitAmount1 = ledgerEntry.debitAmount1;
foreignCurrencySalaryBusinesses.add(transaction.business_id);
}
}
}
catch (e) {
if (e instanceof LedgerError) {
errors.add(e.message);
}
else {
throw e;
}
}
});
const miscExpensesEntriesPromise = generateMiscExpensesLedger(charge, injector).then(entries => {
entries.map(entry => {
entry.ownerId = charge.owner_id;
miscExpensesLedgerEntries.push(entry);
updateLedgerBalanceByEntry(entry, ledgerBalance, defaultLocalCurrency);
});
});
entriesPromises.push(...transactionEntriesPromises, miscExpensesEntriesPromise);
await Promise.all(entriesPromises);
const miscLedgerEntries = [];
// Add exchange ledger in case of foreign currency salaries
for (const entry of accountingLedgerEntries) {
const businessId = entry.creditAccountID1;
const matchingTransaction = financialAccountLedgerEntries.find(entry => businessId ===
(entry.isCreditorCounterparty ? entry.creditAccountID1 : entry.debitAccountID1));
if (!matchingTransaction || !businessId) {
continue;
}
const balance = (matchingTransaction?.localCurrencyCreditAmount1 ?? 0) *
(matchingTransaction?.isCreditorCounterparty ? 1 : -1) +
entry.localCurrencyCreditAmount1;
if (balance === 0) {
continue;
}
const datesDiff = entry.invoiceDate.getTime() !== matchingTransaction?.invoiceDate.getTime();
if (!datesDiff) {
continue;
}
const currencyDiff = entry.currency !== matchingTransaction.currency;
if (!currencyDiff && !foreignCurrencySalaryBusinesses.has(businessId)) {
continue;
}
const amount = Math.abs(balance);
const isCreditorCounterparty = balance < 0;
// validate exchange rate
const validation = validateExchangeRate(businessId, [...accountingLedgerEntries, ...financialAccountLedgerEntries], balance, defaultLocalCurrency);
if (validation === true) {
const ledgerEntry = {
id: matchingTransaction.id + '|fee', // NOTE: this field is dummy
creditAccountID1: isCreditorCounterparty ? businessId : exchangeRateTaxCategoryId,
localCurrencyCreditAmount1: amount,
debitAccountID1: isCreditorCounterparty ? exchangeRateTaxCategoryId : businessId,
localCurrencyDebitAmount1: amount,
description: 'Exchange ledger record',
isCreditorCounterparty,
invoiceDate: matchingTransaction.invoiceDate,
valueDate: entry.valueDate,
currency: defaultLocalCurrency,
ownerId: matchingTransaction.ownerId,
chargeId,
};
miscLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
}
else {
errors.add(validation);
}
}
// for each batched transaction, create a ledger record
const batchedLedgerEntries = [];
entriesPromises = [];
const batchedTransactionEntriesPromises = batchedTransactionEntriesMaterials.map(async ({ transaction, partialEntry, taxCategoryId }) => {
try {
const unbatchedBusinesses = [];
switch (transaction.business_id) {
case batchedEmployeesBusinessId: {
const employees = await injector
.get(EmployeesProvider)
.getEmployeesByEmployerLoader.load(charge.owner_id);
unbatchedBusinesses.push(...employees.map(({ business_id }) => business_id));
break;
}
case batchedFundsBusinessId: {
const funds = await injector.get(FundsProvider).getAllFunds();
unbatchedBusinesses.push(...funds.map(({ id }) => id));
break;
}
default: {
throw new LedgerError(`Business ID="${transaction.business_id}" is not supported as batched ID`);
}
}
const batchedEntries = [...accountingLedgerEntries, ...miscExpensesLedgerEntries]
.filter(accountingLedgerEntry => accountingLedgerEntry.creditAccountID1
? unbatchedBusinesses.includes(accountingLedgerEntry.creditAccountID1)
: false)
.map(accountingLedgerEntry => {
const externalPayments = financialAccountLedgerEntries
.filter(financialAccountEntry => !!financialAccountEntry.debitAccountID1 &&
accountingLedgerEntry.creditAccountID1 ===
financialAccountEntry.debitAccountID1)
.map(entry => entry.localCurrencyCreditAmount1)
.reduce((a, b) => a + b, 0);
if (externalPayments === 0) {
return accountingLedgerEntry;
}
if (externalPayments < accountingLedgerEntry.localCurrencyCreditAmount1) {
return {
...accountingLedgerEntry,
localCurrencyCreditAmount1: accountingLedgerEntry.localCurrencyCreditAmount1 - externalPayments,
localCurrencyDebitAmount1: accountingLedgerEntry.localCurrencyDebitAmount1 - externalPayments,
};
}
return {
...accountingLedgerEntry,
localCurrencyCreditAmount1: externalPayments - accountingLedgerEntry.localCurrencyCreditAmount1,
creditAccountID1: accountingLedgerEntry.debitAccountID1,
localCurrencyDebitAmount1: externalPayments - accountingLedgerEntry.localCurrencyDebitAmount1,
debitAccountID1: accountingLedgerEntry.creditAccountID1,
};
});
const balance = batchedEntries
.map(entry => entry.localCurrencyCreditAmount1)
.reduce((a, b) => a + b, 0);
if (balance !== partialEntry.localCurrencyCreditAmount1) {
throw new LedgerError(`Batched business ID="${transaction.business_id}" cannot be unbatched as it is not balanced`);
}
for (const batchedEntry of batchedEntries) {
const ledgerEntry = {
...partialEntry,
...(partialEntry.isCreditorCounterparty
? {
creditAccountID1: batchedEntry.creditAccountID1,
debitAccountID1: taxCategoryId,
}
: {
creditAccountID1: taxCategoryId,
debitAccountID1: batchedEntry.creditAccountID1,
}),
creditAmount1: batchedEntry.creditAmount1,
localCurrencyCreditAmount1: batchedEntry.localCurrencyCreditAmount1,
debitAmount1: batchedEntry.creditAmount1,
localCurrencyDebitAmount1: batchedEntry.localCurrencyCreditAmount1,
};
batchedLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
}
}
catch (e) {
if (e instanceof LedgerError) {
errors.add(e.message);
}
else {
throw e;
}
}
});
entriesPromises.push(...batchedTransactionEntriesPromises);
// create a ledger record for fee transactions
const feeFinancialAccountLedgerEntries = [];
const feeFinancialAccountLedgerEntriesPromises = feeTransactions.map(async (transaction) => {
await getEntriesFromFeeTransaction(transaction, charge, injector).then(ledgerEntries => {
feeFinancialAccountLedgerEntries.push(...ledgerEntries);
ledgerEntries.map(ledgerEntry => {
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
});
});
});
entriesPromises.push(...feeFinancialAccountLedgerEntriesPromises);
await Promise.all(entriesPromises);
// generate ledger from balance cancellation
for (const balanceCancellation of balanceCancellations) {
try {
const entityBalance = ledgerBalance.get(balanceCancellation.business_id);
if (!entityBalance) {
throw new LedgerError(`Balance cancellation for business ${balanceCancellation.business_id} redundant - already balanced`);
}
const { amount, entityId } = entityBalance;
const financialAccountEntry = financialAccountLedgerEntries.find(entry => [
entry.creditAccountID1,
entry.creditAccountID2,
entry.debitAccountID1,
entry.debitAccountID2,
].includes(balanceCancellation.business_id));
if (!financialAccountEntry) {
throw new LedgerError(`Balance cancellation for business ${balanceCancellation.business_id} failed - no financial account entry found`);
}
let foreignAmount = undefined;
if (financialAccountEntry.currency !== defaultLocalCurrency &&
financialAccountEntry.currencyRate) {
foreignAmount = financialAccountEntry.currencyRate * amount;
}
const isCreditorCounterparty = amount > 0;
const ledgerEntry = {
id: balanceCancellation.charge_id,
invoiceDate: financialAccountEntry.invoiceDate,
valueDate: financialAccountEntry.valueDate,
currency: financialAccountEntry.currency,
creditAccountID1: isCreditorCounterparty ? balanceCancellationTaxCategoryId : entityId,
creditAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined,
localCurrencyCreditAmount1: Math.abs(amount),
debitAccountID1: isCreditorCounterparty ? entityId : balanceCancellationTaxCategoryId,
debitAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined,
localCurrencyDebitAmount1: Math.abs(amount),
description: balanceCancellation.description ?? 'Balance Cancellation',
isCreditorCounterparty,
ownerId: charge.owner_id,
currencyRate: financialAccountEntry.currencyRate,
chargeId,
};
miscLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
}
catch (e) {
if (e instanceof LedgerError) {
errors.add(e.message);
}
else {
throw e;
}
}
}
const allowedUnbalancedBusinesses = new Set(unbalancedBusinesses.map(({ business_id }) => business_id));
const ledgerBalanceInfo = await getLedgerBalanceInfo(injector, ledgerBalance, errors, allowedUnbalancedBusinesses);
const records = [
...accountingLedgerEntries,
...financialAccountLedgerEntries,
...batchedLedgerEntries,
...feeFinancialAccountLedgerEntries,
...miscExpensesLedgerEntries,
...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}`,
};
}
};
//# sourceMappingURL=salary-ledger-generation.resolver.js.map