@accounter/server
Version:
Accounter GraphQL server
637 lines (572 loc) • 23.4 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 { validateTransactionAgainstBusinessTrips } from '../../../business-trips/helpers/business-trips-expenses.helper.js';
import { BusinessTripAttendeesProvider } from '../../../business-trips/providers/business-trips-attendees.provider.js';
import { BusinessTripEmployeePaymentsProvider } from '../../../business-trips/providers/business-trips-employee-payments.provider.js';
import { BusinessTripExpensesProvider } from '../../../business-trips/providers/business-trips-expenses.provider.js';
import { BusinessTripsProvider } from '../../../business-trips/providers/business-trips.provider.js';
import {
calculateTotalAmount,
getChargeBusinesses,
getChargeDocumentsMeta,
getChargeTaxCategoryId,
} from '../../../charges/helpers/common.helper.js';
import { currency } from '../../../charges/types.js';
import { DocumentsProvider } from '../../../documents/providers/documents.provider.js';
import { ExchangeProvider } from '../../../exchange-rates/providers/exchange.provider.js';
import { BusinessesProvider } from '../../../financial-entities/providers/businesses.provider.js';
import { TransactionsProvider } from '../../../transactions/providers/transactions.provider.js';
import {
getExchangeDates,
ledgerEntryFromDocument,
} from '../../helpers/common-charge-ledger.helper.js';
import { handleCrossYearLedgerEntries } from '../../helpers/cross-year-ledger.helper.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 {
generatePartialLedgerEntry,
getFinancialAccountTaxCategoryId,
getLedgerBalanceInfo,
LedgerError,
ledgerProtoToRecordsConverter,
multipleForeignCurrenciesBalanceEntries,
updateLedgerBalanceByEntry,
validateTransactionRequiredVariables,
} from '../../helpers/utils.helper.js';
import { UnbalancedBusinessesProvider } from '../../providers/unbalanced-businesses.provider.js';
const AMOUNT_REQUIRING_DOCUMENT = 1000;
const AMOUNT_FOR_SELF_CLOSING = 300;
export const generateLedgerRecordsForBusinessTrip: ResolverFn<
Maybe<ResolversTypes['GeneratedLedgerRecords']>,
ResolversParentTypes['Charge'],
GraphQLModules.Context,
{ insertLedgerRecordsIfNotExists: boolean }
> = async (charge, { insertLedgerRecordsIfNotExists }, { injector }) => {
const chargeId = charge.id;
const {
defaultLocalCurrency,
defaultTaxCategoryId,
general: {
taxCategories: { incomeExchangeRateTaxCategoryId },
},
} = await injector.get(AdminContextProvider).getVerifiedAdminContext();
const errors: Set<string> = new Set();
try {
const tripTaxCategory = await getChargeTaxCategoryId(charge.id, injector);
if (!tripTaxCategory) {
errors.add(`Business trip is missing tax category`);
}
// validate ledger records are balanced
const ledgerBalance = new Map<
string,
{
amount: number;
entityId: string;
foreignAmounts?: Partial<Record<Currency, { local: number; foreign: number }>>;
}
>();
const [{ invoiceCount, receiptCount }, formattedChargeAmount] = await Promise.all([
getChargeDocumentsMeta(chargeId, injector),
calculateTotalAmount(chargeId, injector, defaultLocalCurrency),
]);
const gotRelevantDocuments = invoiceCount + receiptCount > 0;
// Get all transactions and business trip transactions
const transactionsPromise = injector
.get(TransactionsProvider)
.transactionsByChargeIDLoader.load(chargeId);
const documentsPromise = injector
.get(DocumentsProvider)
.getDocumentsByChargeIdLoader.load(chargeId);
const documentsTaxCategoryIdPromise = async () => {
if (charge.tax_category_id) {
return charge.tax_category_id;
}
const taxCategoryId = await getChargeTaxCategoryId(chargeId, injector);
if (taxCategoryId) {
return taxCategoryId;
}
if (!gotRelevantDocuments) {
return undefined;
}
errors.add('Tax category not found');
return defaultTaxCategoryId;
};
const businessTripExpensesPromise = injector
.get(BusinessTripsProvider)
.getBusinessTripsByChargeIdLoader.load(chargeId)
.then(businessTrip => {
if (businessTrip) {
return injector
.get(BusinessTripExpensesProvider)
.getBusinessTripsExpensesByBusinessTripIdLoader.load(businessTrip.id);
}
return [];
});
const businessTripsEmployeePaymentsPromise = injector
.get(BusinessTripEmployeePaymentsProvider)
.getBusinessTripEmployeePaymentsByChargeIdLoader.load(chargeId);
const businessTripAttendeesPromise = injector
.get(BusinessTripAttendeesProvider)
.getBusinessTripsAttendeesByChargeIdLoader.load(chargeId);
const unbalancedBusinessesPromise = injector
.get(UnbalancedBusinessesProvider)
.getChargeUnbalancedBusinessesByChargeIds.load(chargeId);
const [
transactions,
documents,
documentsTaxCategoryId,
businessTripExpenses,
businessTripsEmployeePayments,
businessTripAttendees,
chargeUnbalancedBusinesses,
{ mainBusinessId },
] = await Promise.all([
transactionsPromise,
documentsPromise,
documentsTaxCategoryIdPromise(),
businessTripExpensesPromise,
businessTripsEmployeePaymentsPromise,
businessTripAttendeesPromise,
unbalancedBusinessesPromise,
getChargeBusinesses(chargeId, injector),
]);
// generate ledger from transactions
const entriesPromises: Array<Promise<void>> = [];
const financialAccountLedgerEntries: StrictLedgerProto[] = [];
const feeFinancialAccountLedgerEntries: LedgerProto[] = [];
const accountingLedgerEntries: LedgerProto[] = [];
const dates = new Set<number>();
const currencies = new Set<currency>();
// generate ledger from transactions
const { mainTransactions, feeTransactions } = splitFeeTransactions(transactions);
let isSelfClosingLedger = mainTransactions.length === 1;
let transactionsTotalLocalAmount = 0;
// create ledger records for misc expenses
const miscExpensesLedgerPromise = generateMiscExpensesLedger(charge, injector).then(entries => {
entries.map(entry => {
entry.ownerId = charge.owner_id;
feeFinancialAccountLedgerEntries.push(entry);
updateLedgerBalanceByEntry(entry, ledgerBalance, defaultLocalCurrency);
dates.add(entry.valueDate.getTime());
currencies.add(entry.currency);
});
});
// for each transaction, create a ledger record
const mainTransactionsPromises = mainTransactions.map(async preValidatedTransaction => {
try {
const transaction = validateTransactionRequiredVariables(preValidatedTransaction, {
skipBusinessId: isSelfClosingLedger,
});
if (transaction.business_id) {
isSelfClosingLedger = false;
}
const validateTransactionAgainstBusinessTripsPromise =
validateTransactionAgainstBusinessTrips(injector, transaction).catch(e => {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
throw e;
}
});
const ledgerEntryPromise = async (): Promise<StrictLedgerProto> => {
// get tax category
const accountTaxCategoryId = await getFinancialAccountTaxCategoryId(
injector,
transaction,
);
// 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,
);
transactionsTotalLocalAmount +=
(partialEntry.isCreditorCounterparty ? 1 : -1) *
partialEntry.localCurrencyCreditAmount1;
isSelfClosingLedger &&= partialEntry.localCurrencyCreditAmount1 < AMOUNT_FOR_SELF_CLOSING;
const entryBusinessId =
(isSelfClosingLedger ? tripTaxCategory : null) ?? transaction.business_id;
if (!entryBusinessId) {
throw new LedgerError(
`Transaction reference "${transaction.source_reference}" is missing business ID`,
);
}
const ledgerEntry: StrictLedgerProto = {
...partialEntry,
creditAccountID1: partialEntry.isCreditorCounterparty
? entryBusinessId
: accountTaxCategoryId,
debitAccountID1: partialEntry.isCreditorCounterparty
? accountTaxCategoryId
: entryBusinessId,
};
return ledgerEntry;
};
const [ledgerEntry] = await Promise.all([
ledgerEntryPromise(),
validateTransactionAgainstBusinessTripsPromise,
]);
financialAccountLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
dates.add(ledgerEntry.valueDate.getTime());
currencies.add(ledgerEntry.currency);
} catch (e) {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
throw e;
}
}
});
// create a ledger record for fee transactions
const feeTransactionsPromises = feeTransactions.map(async transaction => {
const ledgerEntries = await getEntriesFromFeeTransaction(transaction, charge, injector).catch(
e => {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
throw e;
}
},
);
if (!ledgerEntries) {
return;
}
feeFinancialAccountLedgerEntries.push(...ledgerEntries);
ledgerEntries.map(ledgerEntry => {
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
dates.add(ledgerEntry.valueDate.getTime());
currencies.add(ledgerEntry.currency);
});
});
// generate ledger from documents
if (gotRelevantDocuments) {
// Get all relevant documents for charge
const relevantDocuments = documents.filter(d =>
['INVOICE', 'INVOICE_RECEIPT', 'CREDIT_INVOICE'].includes(d.type),
);
// if no relevant documents found and business can settle with receipts, look for receipts
if (!relevantDocuments.length && mainBusinessId) {
const business = await injector
.get(BusinessesProvider)
.getBusinessByIdLoader.load(mainBusinessId);
if (business?.can_settle_with_receipt) {
relevantDocuments.push(...documents.filter(d => d.type === 'RECEIPT'));
}
}
// for each invoice - generate accounting ledger entry
const documentsEntriesPromises = relevantDocuments.map(async document => {
if (!documentsTaxCategoryId) {
return;
}
return ledgerEntryFromDocument(
injector,
document,
charge,
charge.owner_id,
documentsTaxCategoryId!,
)
.then(ledgerEntry => {
accountingLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
dates.add(ledgerEntry.valueDate.getTime());
currencies.add(ledgerEntry.currency);
})
.catch(e => {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
throw e;
}
});
});
entriesPromises.push(...documentsEntriesPromises);
}
entriesPromises.push(
...mainTransactionsPromises,
...feeTransactionsPromises,
miscExpensesLedgerPromise,
);
await Promise.all(entriesPromises);
if (
Math.abs(transactionsTotalLocalAmount) > AMOUNT_REQUIRING_DOCUMENT &&
accountingLedgerEntries.length === 0 &&
!charge.documents_optional_flag
) {
errors.add(
`Charge of more than ${AMOUNT_REQUIRING_DOCUMENT} ${defaultLocalCurrency} requires a document`,
);
}
// generate ledger from business trip transactions
if (!isSelfClosingLedger && !accountingLedgerEntries.length) {
if (businessTripsEmployeePayments.length) {
const businessTripsEmployeePaymentsPromises = businessTripsEmployeePayments.map(
async businessTripsEmployeePayment => {
if (!tripTaxCategory) {
return;
}
if (
!businessTripsEmployeePayment.employee_business_id ||
!businessTripsEmployeePayment.date ||
!businessTripsEmployeePayment.amount ||
!businessTripsEmployeePayment.currency
) {
errors.add(
`Business trip flight transaction ID="${businessTripsEmployeePayment.id}" is missing required fields`,
);
return;
}
// preparations for core ledger entries
let exchangeRate: number | undefined = undefined;
if (businessTripsEmployeePayment.currency !== defaultLocalCurrency) {
// get exchange rate for currency
exchangeRate = await injector
.get(ExchangeProvider)
.getExchangeRates(
businessTripsEmployeePayment.currency as Currency,
defaultLocalCurrency,
businessTripsEmployeePayment.date,
);
}
// set amounts
let amount = Number(businessTripsEmployeePayment.amount);
let foreignAmount: number | undefined = undefined;
if (exchangeRate) {
foreignAmount = amount;
// calculate amounts in ILS
amount = exchangeRate * amount;
}
const absAmount = Math.abs(amount);
const absForeignAmount = foreignAmount ? Math.abs(foreignAmount) : undefined;
const isCreditorCounterparty = amount > 0;
const ledgerEntry: StrictLedgerProto = {
id: businessTripsEmployeePayment.id!,
invoiceDate: businessTripsEmployeePayment.date,
valueDate: businessTripsEmployeePayment.date,
currency: businessTripsEmployeePayment.currency as Currency,
creditAccountID1: isCreditorCounterparty
? businessTripsEmployeePayment.employee_business_id
: tripTaxCategory,
creditAmount1: absForeignAmount,
localCurrencyCreditAmount1: absAmount,
debitAccountID1: isCreditorCounterparty
? tripTaxCategory
: businessTripsEmployeePayment.employee_business_id,
debitAmount1: absForeignAmount,
localCurrencyDebitAmount1: absAmount,
reference: businessTripsEmployeePayment.id!,
isCreditorCounterparty,
ownerId: charge.owner_id,
currencyRate: exchangeRate,
chargeId: charge.id,
};
accountingLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
dates.add(ledgerEntry.valueDate.getTime());
currencies.add(ledgerEntry.currency);
},
);
await Promise.all(businessTripsEmployeePaymentsPromises);
} else {
const businessTripExpensesPromises = businessTripExpenses.map(async businessTripExpense => {
if (!tripTaxCategory) {
return;
}
const matchingEntry = financialAccountLedgerEntries.find(entry =>
businessTripExpense.transaction_ids?.includes(entry.id),
);
if (!matchingEntry) {
return;
}
const isCreditorCounterparty = !matchingEntry.isCreditorCounterparty;
const businessId = isCreditorCounterparty
? matchingEntry.debitAccountID1
: matchingEntry.creditAccountID1;
const ledgerEntry: StrictLedgerProto = {
...matchingEntry,
id: businessTripExpense.id!,
isCreditorCounterparty,
creditAccountID1: isCreditorCounterparty ? businessId : tripTaxCategory,
debitAccountID1: isCreditorCounterparty ? tripTaxCategory : businessId,
};
accountingLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
});
await Promise.all(businessTripExpensesPromises);
}
}
const allowedUnbalancedBusinesses = new Set([
...businessTripAttendees.map(attendee => attendee.id),
...chargeUnbalancedBusinesses.map(({ business_id }) => business_id),
]);
const ledgerBalanceInfo = await getLedgerBalanceInfo(
injector,
ledgerBalance,
undefined,
allowedUnbalancedBusinesses,
);
const miscLedgerEntries: LedgerProto[] = [];
// multiple currencies balance
const businessBalance = ledgerBalance.get(mainBusinessId ?? '');
if (
mainBusinessId &&
Object.keys(businessBalance?.foreignAmounts ?? {}).length >
(charge.invoice_payment_currency_diff ? 0 : 1)
) {
const transactionEntries = financialAccountLedgerEntries.filter(entry =>
[entry.creditAccountID1, entry.debitAccountID1].includes(mainBusinessId),
);
const documentEntries = accountingLedgerEntries.filter(entry =>
[entry.creditAccountID1, entry.debitAccountID1].includes(mainBusinessId),
);
try {
const entries = await multipleForeignCurrenciesBalanceEntries(
injector,
defaultLocalCurrency,
documentEntries,
transactionEntries,
charge,
businessBalance!.foreignAmounts!,
charge.invoice_payment_currency_diff ?? false,
);
for (const ledgerEntry of entries) {
miscLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
}
} catch (e) {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
throw e;
}
}
}
// check if exchange rate record is needed
const hasMultipleDates = dates.size > 1;
const foreignCurrencyCount = currencies.size - (currencies.has(defaultLocalCurrency) ? 1 : 0);
const mightRequireExchangeRateRecord =
(hasMultipleDates && !!foreignCurrencyCount) || foreignCurrencyCount >= 2;
const unbalancedBusinesses = ledgerBalanceInfo.unbalancedEntities.filter(
({ entityId }) =>
ledgerBalanceInfo.financialEntities.some(
fe => fe.id === entityId && fe.type === 'business',
) && !chargeUnbalancedBusinesses.map(b => b.business_id).includes(entityId),
);
if (mightRequireExchangeRateRecord && unbalancedBusinesses.length === 1) {
const entryDate = getExchangeDates(
financialAccountLedgerEntries.length
? financialAccountLedgerEntries
: accountingLedgerEntries,
);
const { entityId, balance } = unbalancedBusinesses[0];
const amount = Math.abs(balance.raw);
const isCreditorCounterparty = balance.raw < 0;
const exchangeRateEntry = accountingLedgerEntries.find(entry =>
[entry.creditAccountID1, entry.debitAccountID1].includes(entityId),
);
let exchangeRateTaxCategory: string | undefined = undefined;
if (exchangeRateEntry) {
exchangeRateTaxCategory =
exchangeRateEntry.debitAccountID1 === entityId
? exchangeRateEntry.creditAccountID1
: exchangeRateEntry.debitAccountID1;
const isIncomeCharge = !!formattedChargeAmount && formattedChargeAmount.raw > 0;
if (isIncomeCharge) {
exchangeRateTaxCategory = incomeExchangeRateTaxCategoryId;
}
}
// validate exchange rate
const validation = validateExchangeRate(
entityId,
[
...financialAccountLedgerEntries,
...feeFinancialAccountLedgerEntries,
...accountingLedgerEntries,
...miscLedgerEntries,
],
balance.raw,
defaultLocalCurrency,
);
if (validation === true) {
if (exchangeRateTaxCategory) {
const ledgerEntry: StrictLedgerProto = {
id: 'exchange-ledger', // NOTE: this field is dummy
creditAccountID1: isCreditorCounterparty ? entityId : exchangeRateTaxCategory,
localCurrencyCreditAmount1: amount,
debitAccountID1: isCreditorCounterparty ? exchangeRateTaxCategory : entityId,
localCurrencyDebitAmount1: amount,
description: 'Exchange ledger record',
isCreditorCounterparty,
invoiceDate: entryDate,
valueDate: entryDate,
currency: defaultLocalCurrency,
ownerId: charge.owner_id,
chargeId,
};
miscLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
} else {
errors.add(
`Failed to locate tax category for exchange rate for business ID="${entityId}"`,
);
}
} else {
errors.add(validation);
}
}
const crossYearLedgerEntries = await handleCrossYearLedgerEntries(
charge,
injector,
accountingLedgerEntries,
);
const records = [
...(crossYearLedgerEntries ?? accountingLedgerEntries),
...feeFinancialAccountLedgerEntries,
...financialAccountLedgerEntries,
...miscLedgerEntries,
];
const updatedLedgerBalanceInfo = await getLedgerBalanceInfo(
injector,
ledgerBalance,
errors,
allowedUnbalancedBusinesses,
);
if (insertLedgerRecordsIfNotExists) {
await storeInitialGeneratedRecords(charge.id, records, injector);
}
return {
records: ledgerProtoToRecordsConverter(records),
charge,
balance: updatedLedgerBalanceInfo,
errors: Array.from(errors),
};
} catch (e) {
return {
__typename: 'CommonError',
message: `Failed to generate ledger records for charge ID="${chargeId}"\n${e}`,
};
}
};