@accounter/server
Version:
Accounter GraphQL server
539 lines (488 loc) • 21.2 kB
text/typescript
import { lastDayOfMonth } from 'date-fns';
import type {
Maybe,
ResolverFn,
ResolversParentTypes,
ResolversTypes,
} from '../../../../__generated__/types.js';
import type { LedgerProto, StrictLedgerProto } from '../../../../shared/types/index.js';
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,
ValidateTransaction,
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: ResolverFn<
Maybe<ResolversTypes['GeneratedLedgerRecords']>,
ResolversParentTypes['Charge'],
GraphQLModules.Context,
{ insertLedgerRecordsIfNotExists: boolean }
> = 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: Set<string> = 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<string, { amount: number; entityId: string }>();
let entriesPromises: Array<Promise<void>> = [];
const accountingLedgerEntries: LedgerProto[] = [];
const foreignCurrencySalaryBusinesses = new Set<string>();
// 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: LedgerProto = {
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: {
transaction: ValidateTransaction;
partialEntry: Omit<StrictLedgerProto, 'debitAccountID1' | 'creditAccountID1'>;
taxCategoryId: string;
}[] = [];
// for each common transaction, create a ledger record
const financialAccountLedgerEntries: LedgerProto[] = [];
const miscExpensesLedgerEntries: LedgerProto[] = [];
const transactionEntriesPromises = mainTransactions.map(async preValidatedTransaction => {
try {
const transaction = validateTransactionRequiredVariables(preValidatedTransaction);
// preparations for core ledger entries
let exchangeRate: number | undefined = 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: StrictLedgerProto = {
...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: LedgerProto[] = [];
// 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: StrictLedgerProto = {
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: LedgerProto[] = [];
entriesPromises = [];
const batchedTransactionEntriesPromises = batchedTransactionEntriesMaterials.map(
async ({ transaction, partialEntry, taxCategoryId }) => {
try {
const unbatchedBusinesses: Array<string> = [];
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: StrictLedgerProto = {
...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: LedgerProto[] = [];
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: number | undefined = undefined;
if (
financialAccountEntry.currency !== defaultLocalCurrency &&
financialAccountEntry.currencyRate
) {
foreignAmount = financialAccountEntry.currencyRate * amount;
}
const isCreditorCounterparty = amount > 0;
const ledgerEntry: LedgerProto = {
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}`,
};
}
};