@accounter/server
Version:
Accounter GraphQL server
324 lines (287 loc) • 11.8 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 { ExchangeProvider } from '../../../exchange-rates/providers/exchange.provider.js';
import { TransactionsProvider } from '../../../transactions/providers/transactions.provider.js';
import { conversionFeeCalculator } from '../../helpers/conversion-charge-ledger.helper.js';
import {
isSupplementalFeeTransaction,
splitFeeTransactions,
} from '../../helpers/fee-transactions.js';
import { storeInitialGeneratedRecords } from '../../helpers/ledgrer-storage.helper.js';
import {
getFinancialAccountTaxCategoryId,
getLedgerBalanceInfo,
isTransactionsOppositeSign,
LedgerError,
ledgerProtoToRecordsConverter,
updateLedgerBalanceByEntry,
validateTransactionBasicVariables,
} from '../../helpers/utils.helper.js';
export const generateLedgerRecordsForConversion: ResolverFn<
Maybe<ResolversTypes['GeneratedLedgerRecords']>,
ResolversParentTypes['Charge'],
GraphQLModules.Context,
{ insertLedgerRecordsIfNotExists: boolean }
> = async (charge, { insertLedgerRecordsIfNotExists }, { injector }) => {
const {
defaultLocalCurrency,
general: {
taxCategories: { feeTaxCategoryId, exchangeRevaluationTaxCategoryId },
},
financialAccounts,
} = await injector.get(AdminContextProvider).getVerifiedAdminContext();
const chargeId = charge.id;
const errors: Set<string> = new Set();
try {
// validate ledger records are balanced
const ledgerBalance = new Map<string, { amount: number; entityId: string }>();
// generate ledger from transactions
const mainFinancialAccountLedgerEntries: LedgerProto[] = [];
const feeFinancialAccountLedgerEntries: LedgerProto[] = [];
const miscLedgerEntries: LedgerProto[] = [];
let baseEntry: LedgerProto | undefined = undefined;
let quoteEntry: LedgerProto | undefined = undefined;
// Get all transactions
const transactions = await injector
.get(TransactionsProvider)
.transactionsByChargeIDLoader.load(chargeId);
const { mainTransactions, feeTransactions } = splitFeeTransactions(transactions);
if (mainTransactions.length !== 2) {
errors.add(`Conversion Charge must include two main transactions`);
}
try {
if (!isTransactionsOppositeSign(mainTransactions)) {
errors.add(`Conversion Charge must include two main transactions with opposite sign`);
}
} catch (e) {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
throw e;
}
}
// for each transaction, create a ledger record
const mainTransactionsPromises = mainTransactions.map(async transaction => {
try {
const { currency, valueDate } = validateTransactionBasicVariables(transaction);
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: LedgerProto = {
id: transaction.id,
invoiceDate: transaction.event_date,
valueDate,
currency,
...(isCreditorCounterparty
? {
debitAccountID1: accountTaxCategoryId,
}
: {
creditAccountID1: accountTaxCategoryId,
}),
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,
reference: transaction.origin_key,
isCreditorCounterparty,
ownerId: charge.owner_id,
currencyRate: transaction.currency_rate ? Number(transaction.currency_rate) : undefined,
chargeId,
};
mainFinancialAccountLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
} catch (e) {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
throw e;
}
}
});
await Promise.all(mainTransactionsPromises);
for (const entry of mainFinancialAccountLedgerEntries) {
if (entry.isCreditorCounterparty) {
quoteEntry = entry;
} else {
baseEntry = entry;
}
}
if (!baseEntry || !quoteEntry) {
errors.add(`Conversion Charge must include two main transactions`);
} else {
// create a ledger record for fee transactions
for (const transaction of feeTransactions) {
if (!transaction.is_fee) {
continue;
}
try {
const isSupplementalFee = isSupplementalFeeTransaction(transaction, financialAccounts);
const { currency, valueDate, transactionBusinessId } =
validateTransactionBasicVariables(transaction);
let amount = Number(transaction.amount);
if (amount === 0) {
continue;
}
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 isCreditorCounterparty = amount > 0;
if (isSupplementalFee) {
const financialAccountTaxCategoryId = await getFinancialAccountTaxCategoryId(
injector,
transaction,
);
feeFinancialAccountLedgerEntries.push({
id: transaction.id,
invoiceDate: transaction.event_date,
valueDate,
currency,
creditAccountID1: isCreditorCounterparty
? feeTaxCategoryId
: financialAccountTaxCategoryId,
creditAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined,
localCurrencyCreditAmount1: Math.abs(amount),
debitAccountID1: isCreditorCounterparty
? financialAccountTaxCategoryId
: feeTaxCategoryId,
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,
});
} else {
const businessTaxCategory = quoteEntry.debitAccountID1;
if (!businessTaxCategory) {
throw new LedgerError(
`Quote ledger entry for charge ID=${chargeId} is missing Tax category`,
);
}
const ledgerEntry: StrictLedgerProto = {
id: transaction.id,
invoiceDate: transaction.event_date,
valueDate,
currency,
creditAccountID1: isCreditorCounterparty ? feeTaxCategoryId : transactionBusinessId,
creditAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined,
localCurrencyCreditAmount1: Math.abs(amount),
debitAccountID1: isCreditorCounterparty ? transactionBusinessId : feeTaxCategoryId,
debitAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined,
localCurrencyDebitAmount1: Math.abs(amount),
description: transaction.source_description ?? undefined,
reference: transaction.origin_key,
isCreditorCounterparty: !isCreditorCounterparty,
ownerId: charge.owner_id,
currencyRate: transaction.currency_rate
? Number(transaction.currency_rate)
: undefined,
chargeId,
};
feeFinancialAccountLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
}
} catch (e) {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
throw e;
}
}
}
// calculate conversion fee
const [quoteRate, baseRate] = await Promise.all(
[quoteEntry.currency, baseEntry.currency].map(currency =>
injector
.get(ExchangeProvider)
.getExchangeRates(currency as Currency, defaultLocalCurrency, baseEntry!.valueDate),
),
);
const directRate = quoteRate / baseRate;
try {
const conversionFeeInLocalAmount = conversionFeeCalculator(
baseEntry,
quoteEntry,
directRate,
defaultLocalCurrency,
);
if (conversionFeeInLocalAmount !== 0) {
const isDebitConversion = conversionFeeInLocalAmount >= 0;
const ledgerEntry: LedgerProto = {
id: quoteEntry.id + '|revaluation', // NOTE: this field is dummy
creditAccountID1: isDebitConversion ? exchangeRevaluationTaxCategoryId : undefined,
localCurrencyCreditAmount1: Math.abs(conversionFeeInLocalAmount),
debitAccountID1: isDebitConversion ? undefined : exchangeRevaluationTaxCategoryId,
localCurrencyDebitAmount1: Math.abs(conversionFeeInLocalAmount),
description: 'Exchange Revaluation',
isCreditorCounterparty: true,
invoiceDate: quoteEntry.invoiceDate,
valueDate: quoteEntry.valueDate,
currency: defaultLocalCurrency,
reference: quoteEntry.reference,
ownerId: quoteEntry.ownerId,
chargeId,
};
miscLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
}
} catch (e) {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
throw e;
}
}
}
const ledgerBalanceInfo = await getLedgerBalanceInfo(injector, ledgerBalance, errors);
const records = [
...mainFinancialAccountLedgerEntries,
...feeFinancialAccountLedgerEntries,
...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}`,
};
}
};