@accounter/server
Version:
332 lines (293 loc) • 13.4 kB
text/typescript
import { GraphQLError } from 'graphql';
import { Injector } from 'graphql-modules';
import { BusinessTripAttendeesProvider } from '@modules/business-trips/providers/business-trips-attendees.provider.js';
import { BusinessTripTransactionsProvider } from '@modules/business-trips/providers/business-trips-transactions.provider.js';
import { ExchangeProvider } from '@modules/exchange-rates/providers/exchange.provider.js';
import { FinancialAccountsProvider } from '@modules/financial-accounts/providers/financial-accounts.provider.js';
import { TaxCategoriesProvider } from '@modules/financial-entities/providers/tax-categories.provider.js';
import { storeInitialGeneratedRecords } from '@modules/ledger/helpers/ledgrer-storage.helper.js';
import { TransactionsProvider } from '@modules/transactions/providers/transactions.provider.js';
import { DEFAULT_LOCAL_CURRENCY, FEE_TAX_CATEGORY_ID } from '@shared/constants';
import {
Currency,
Maybe,
ResolverFn,
ResolversParentTypes,
ResolversTypes,
} from '@shared/gql-types';
import type { LedgerProto, StrictLedgerProto } from '@shared/types';
import {
isSupplementalFeeTransaction,
splitFeeTransactions,
} from '../../helpers/fee-transactions.js';
import {
generatePartialLedgerEntry,
getLedgerBalanceInfo,
getTaxCategoryNameByAccountCurrency,
ledgerProtoToRecordsConverter,
updateLedgerBalanceByEntry,
validateTransactionBasicVariables,
validateTransactionRequiredVariables,
} from '../../helpers/utils.helper.js';
export const generateLedgerRecordsForBusinessTrip: ResolverFn<
Maybe<ResolversTypes['GeneratedLedgerRecords']>,
ResolversParentTypes['Charge'],
{ injector: Injector },
object
> = async (charge, _, { injector }) => {
const chargeId = charge.id;
if (!charge.tax_category_id) {
throw new GraphQLError(`Business trip charge ID="${charge.id}" is missing tax category`);
}
const tripTaxCategory = charge.tax_category_id;
try {
// validate ledger records are balanced
const ledgerBalance = new Map<string, { amount: number; entityId: string }>();
// Get all transactions and business trip transactions
const transactionsPromise = injector
.get(TransactionsProvider)
.getTransactionsByChargeIDLoader.load(chargeId);
const businessTripTransactionsPromise = injector
.get(BusinessTripTransactionsProvider)
.getBusinessTripsTransactionsByChargeIdLoader.load(chargeId);
const businessTripAttendeesPromise = injector
.get(BusinessTripAttendeesProvider)
.getBusinessTripsAttendeesByChargeIdLoader.load(chargeId);
const [transactions, businessTripTransactions, businessTripAttendees] = await Promise.all([
transactionsPromise,
businessTripTransactionsPromise,
businessTripAttendeesPromise,
]);
// generate ledger from transactions
let entriesPromises: Array<Promise<void>> = [];
const financialAccountLedgerEntries: StrictLedgerProto[] = [];
const feeFinancialAccountLedgerEntries: LedgerProto[] = [];
// generate ledger from transactions
const { mainTransactions, feeTransactions } = splitFeeTransactions(transactions);
// for each transaction, create a ledger record
const mainTransactionsPromises = mainTransactions.map(async preValidatedTransaction => {
const transaction = validateTransactionRequiredVariables(preValidatedTransaction);
// get tax category
const account = await injector
.get(FinancialAccountsProvider)
.getFinancialAccountByAccountIDLoader.load(transaction.account_id);
if (!account) {
throw new GraphQLError(`Transaction ID="${transaction.id}" is missing account`);
}
const taxCategoryName = getTaxCategoryNameByAccountCurrency(account, transaction.currency);
const taxCategory = await injector
.get(TaxCategoriesProvider)
.taxCategoryByNamesLoader.load(taxCategoryName);
if (!taxCategory) {
throw new GraphQLError(`Account ID="${account.id}" is missing tax category`);
}
// preparations for core ledger entries
let exchangeRate: number | undefined = undefined;
if (transaction.currency !== DEFAULT_LOCAL_CURRENCY) {
// get exchange rate for currency
exchangeRate = await injector
.get(ExchangeProvider)
.getExchangeRates(
transaction.currency,
DEFAULT_LOCAL_CURRENCY,
transaction.debit_timestamp,
);
}
const partialEntry = generatePartialLedgerEntry(transaction, charge.owner_id, exchangeRate);
const ledgerEntry: StrictLedgerProto = {
...partialEntry,
creditAccountID1: partialEntry.isCreditorCounterparty
? transaction.business_id
: taxCategory.id,
debitAccountID1: partialEntry.isCreditorCounterparty
? taxCategory.id
: transaction.business_id,
};
financialAccountLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance);
});
// create a ledger record for fee transactions
const feeTransactionsPromises = feeTransactions.map(async transaction => {
if (!transaction.is_fee) {
throw new GraphQLError(
`Who did a non-fee transaction marked as fee? (Transaction ID="${transaction.id}")`,
);
}
const isSupplementalFee = isSupplementalFeeTransaction(transaction);
const { currency, valueDate, transactionBusinessId } =
validateTransactionBasicVariables(transaction);
let amount = Number(transaction.amount);
let foreignAmount: number | undefined = undefined;
if (currency !== DEFAULT_LOCAL_CURRENCY) {
// get exchange rate for currency
const exchangeRate = await injector
.get(ExchangeProvider)
.getExchangeRates(currency, DEFAULT_LOCAL_CURRENCY, valueDate);
foreignAmount = amount;
// calculate amounts in ILS
amount = exchangeRate * amount;
}
const isCreditorCounterparty = amount > 0;
let mainAccount = transactionBusinessId;
const partialLedgerEntry: Omit<StrictLedgerProto, 'creditAccountID1' | 'debitAccountID1'> = {
id: transaction.id,
invoiceDate: transaction.event_date,
valueDate,
currency,
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,
reference1: transaction.source_id,
isCreditorCounterparty: isSupplementalFee
? isCreditorCounterparty
: !isCreditorCounterparty,
ownerId: charge.owner_id,
currencyRate: transaction.currency_rate ? Number(transaction.currency_rate) : undefined,
chargeId: transaction.charge_id,
};
if (isSupplementalFee) {
const account = await injector
.get(FinancialAccountsProvider)
.getFinancialAccountByAccountIDLoader.load(transaction.account_id);
if (!account) {
throw new GraphQLError(`Transaction ID="${transaction.id}" is missing account`);
}
const taxCategoryName = getTaxCategoryNameByAccountCurrency(account, currency);
const businessTaxCategory = await injector
.get(TaxCategoriesProvider)
.taxCategoryByNamesLoader.load(taxCategoryName);
if (!businessTaxCategory) {
throw new GraphQLError(`Account ID="${account.id}" is missing tax category`);
}
mainAccount = businessTaxCategory.id;
} else {
const mainBusiness = charge.business_id ?? undefined;
const ledgerEntry: LedgerProto = {
...partialLedgerEntry,
creditAccountID1: isCreditorCounterparty ? mainAccount : mainBusiness,
debitAccountID1: isCreditorCounterparty ? mainBusiness : mainAccount,
};
feeFinancialAccountLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance);
}
const ledgerEntry: StrictLedgerProto = {
...partialLedgerEntry,
creditAccountID1: isCreditorCounterparty ? FEE_TAX_CATEGORY_ID : mainAccount,
debitAccountID1: isCreditorCounterparty ? mainAccount : FEE_TAX_CATEGORY_ID,
};
feeFinancialAccountLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance);
});
entriesPromises.push(...mainTransactionsPromises, ...feeTransactionsPromises);
await Promise.all(entriesPromises);
// generate ledger from business trip transactions
entriesPromises = [];
const businessTripTransactionsPromises = businessTripTransactions.map(
async businessTripTransaction => {
const isTransactionBased = !!businessTripTransaction.transaction_id;
if (isTransactionBased) {
const matchingEntry = financialAccountLedgerEntries.find(
entry => entry.id === businessTripTransaction.transaction_id,
);
if (!matchingEntry) {
throw new GraphQLError(
`Flight transaction ID="${businessTripTransaction.transaction_id}" is missing from transactions`,
);
}
const isCreditorCounterparty = !matchingEntry.isCreditorCounterparty;
const businessId = isCreditorCounterparty
? matchingEntry.debitAccountID1
: matchingEntry.creditAccountID1;
const ledgerEntry: StrictLedgerProto = {
...matchingEntry,
id: businessTripTransaction.id,
isCreditorCounterparty,
creditAccountID1: isCreditorCounterparty ? businessId : tripTaxCategory,
debitAccountID1: isCreditorCounterparty ? tripTaxCategory : businessId,
};
financialAccountLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance);
} else {
if (
!businessTripTransaction.employee_business_id ||
!businessTripTransaction.date ||
!businessTripTransaction.amount ||
!businessTripTransaction.currency
) {
throw new GraphQLError(
`Business trip flight transaction ID="${businessTripTransaction.id}" is missing required fields`,
);
}
// preparations for core ledger entries
let exchangeRate: number | undefined = undefined;
if (businessTripTransaction.currency !== DEFAULT_LOCAL_CURRENCY) {
// get exchange rate for currency
exchangeRate = await injector
.get(ExchangeProvider)
.getExchangeRates(
businessTripTransaction.currency as Currency,
DEFAULT_LOCAL_CURRENCY,
businessTripTransaction.date,
);
}
// set amounts
let amount = Number(businessTripTransaction.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: businessTripTransaction.id,
invoiceDate: businessTripTransaction.date,
valueDate: businessTripTransaction.date,
currency: businessTripTransaction.currency as Currency,
creditAccountID1: isCreditorCounterparty
? businessTripTransaction.employee_business_id
: tripTaxCategory,
creditAmount1: absForeignAmount,
localCurrencyCreditAmount1: absAmount,
debitAccountID1: isCreditorCounterparty
? tripTaxCategory
: businessTripTransaction.employee_business_id,
debitAmount1: absForeignAmount,
localCurrencyDebitAmount1: absAmount,
reference1: businessTripTransaction.id,
isCreditorCounterparty,
ownerId: charge.owner_id,
currencyRate: exchangeRate,
chargeId: charge.id,
};
financialAccountLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance);
}
},
);
entriesPromises = businessTripTransactionsPromises;
await Promise.all(entriesPromises);
const allowedUnbalancedBusinesses = new Set(businessTripAttendees.map(attendee => attendee.id));
const ledgerBalanceInfo = await getLedgerBalanceInfo(
injector,
ledgerBalance,
allowedUnbalancedBusinesses,
);
const records = [...financialAccountLedgerEntries, ...feeFinancialAccountLedgerEntries];
await storeInitialGeneratedRecords(charge, records, injector);
return {
records: ledgerProtoToRecordsConverter(records),
charge,
balance: ledgerBalanceInfo,
};
} catch (e) {
return {
__typename: 'CommonError',
message: `Failed to generate ledger records for charge ID="${chargeId}"\n${e}`,
};
}
};