@accounter/server
Version:
541 lines (475 loc) • 22 kB
text/typescript
import { GraphQLError } from 'graphql';
import { DocumentsProvider } from '@modules/documents/providers/documents.provider.js';
import { getRateForCurrency } from '@modules/exchange-rates/helpers/exchange.helper.js';
import { ExchangeProvider } from '@modules/exchange-rates/providers/exchange.provider.js';
import { FiatExchangeProvider } from '@modules/exchange-rates/providers/fiat-exchange.provider.js';
import { FinancialAccountsProvider } from '@modules/financial-accounts/providers/financial-accounts.provider.js';
import { BusinessesProvider } from '@modules/financial-entities/providers/businesses.provider.js';
import { TaxCategoriesProvider } from '@modules/financial-entities/providers/tax-categories.provider.js';
import { handleCrossYearLedgerEntries } from '@modules/ledger/helpers/cross-year-ledger.helper.js';
import { TransactionsProvider } from '@modules/transactions/providers/transactions.provider.js';
import type { currency } from '@modules/transactions/types.js';
import {
BALANCE_CANCELLATION_TAX_CATEGORY_ID,
DEFAULT_LOCAL_CURRENCY,
INPUT_VAT_TAX_CATEGORY_ID,
INTERNAL_WALLETS_IDS,
OUTPUT_VAT_TAX_CATEGORY_ID,
} from '@shared/constants';
import type { Maybe, ResolverFn, ResolversParentTypes, ResolversTypes } from '@shared/gql-types';
import { formatCurrency } from '@shared/helpers';
import type { LedgerProto, StrictLedgerProto } from '@shared/types';
import {
getEntriesFromFeeTransaction,
splitFeeTransactions,
} from '../../helpers/fee-transactions.js';
import { storeInitialGeneratedRecords } from '../../helpers/ledgrer-storage.helper.js';
import {
getLedgerBalanceInfo,
getTaxCategoryNameByAccountCurrency,
ledgerProtoToRecordsConverter,
updateLedgerBalanceByEntry,
validateTransactionBasicVariables,
} from '../../helpers/utils.helper.js';
import { BalanceCancellationProvider } from '../../providers/balance-cancellation.provider.js';
import { UnbalancedBusinessesProvider } from '../../providers/unbalanced-businesses.provider.js';
export const generateLedgerRecordsForCommonCharge: ResolverFn<
Maybe<ResolversTypes['GeneratedLedgerRecords']>,
ResolversParentTypes['Charge'],
GraphQLModules.Context,
object
> = async (charge, _, context) => {
const chargeId = charge.id;
const { injector } = context;
try {
// validate ledger records are balanced
const ledgerBalance = new Map<string, { amount: number; entityId: string }>();
const dates = new Set<number>();
const currencies = new Set<currency>();
const shouldFetchDocuments =
Number(charge.invoices_count ?? '0') + Number(charge.receipts_count ?? 0) > 0;
const shouldFetchTransactions = !!charge.transactions_count;
const documentsTaxCategoryIdPromise = shouldFetchDocuments
? charge.tax_category_id
? Promise.resolve(charge.tax_category_id)
: injector
.get(TaxCategoriesProvider)
.taxCategoryByChargeIDsLoader.load(charge.id)
.then(res => res?.id)
: undefined;
const documentsPromise = shouldFetchDocuments
? injector.get(DocumentsProvider).getDocumentsByChargeIdLoader.load(chargeId)
: [];
const transactionsPromise = shouldFetchTransactions
? injector.get(TransactionsProvider).getTransactionsByChargeIDLoader.load(chargeId)
: [];
const unbalancedBusinessesPromise = injector
.get(UnbalancedBusinessesProvider)
.getChargeUnbalancedBusinessesByChargeIds.load(chargeId);
const chargeBallanceCancellationsPromise = injector
.get(BalanceCancellationProvider)
.getBalanceCancellationByChargesIdLoader.load(chargeId);
const [
documentsTaxCategoryId,
documents,
transactions,
unbalancedBusinesses,
balanceCancellations,
] = await Promise.all([
documentsTaxCategoryIdPromise,
documentsPromise,
transactionsPromise,
unbalancedBusinessesPromise,
chargeBallanceCancellationsPromise,
]);
const entriesPromises: Array<Promise<void>> = [];
const accountingLedgerEntries: LedgerProto[] = [];
const financialAccountLedgerEntries: StrictLedgerProto[] = [];
const feeFinancialAccountLedgerEntries: LedgerProto[] = [];
// generate ledger from documents
if (shouldFetchDocuments) {
if (!documentsTaxCategoryId) {
throw new GraphQLError(`Tax category not found for charge ID="${charge.id}"`);
}
// Get all relevant documents for charge
const relevantDocuments = documents.filter(d =>
['INVOICE', 'INVOICE_RECEIPT'].includes(d.type),
);
// if found invoices, looke for & add credit invoices
if (relevantDocuments.length >= 1) {
relevantDocuments.push(...documents.filter(d => d.type === 'CREDIT_INVOICE'));
}
// if no relevant documents found and business can settle with receipts, look for receipts
if (!relevantDocuments.length && charge.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 (!document.date) {
throw new GraphQLError(`Document ID="${document.id}" is missing the date`);
}
if (!document.debtor_id) {
throw new GraphQLError(`Document ID="${document.id}" is missing the debtor`);
}
if (!document.creditor_id) {
throw new GraphQLError(`Document ID="${document.id}" is missing the creditor`);
}
if (!document.total_amount) {
throw new GraphQLError(`Document ID="${document.id}" is missing amount`);
}
let totalAmount = document.total_amount;
const isCreditorCounterparty = document.debtor_id === charge.owner_id;
const counterpartyId = isCreditorCounterparty ? document.creditor_id : document.debtor_id;
if (totalAmount < 0) {
totalAmount = Math.abs(totalAmount);
}
const debitAccountID1 = isCreditorCounterparty ? documentsTaxCategoryId : counterpartyId;
const creditAccountID1 = isCreditorCounterparty ? counterpartyId : documentsTaxCategoryId;
let creditAccountID2: string | null = null;
let debitAccountID2: string | null = null;
if (!document.currency_code) {
throw new GraphQLError(`Document ID="${document.id}" is missing currency code`);
}
const currency = formatCurrency(document.currency_code);
let foreignTotalAmount: number | null = null;
let amountWithoutVat = totalAmount;
let foreignAmountWithoutVat: number | null = null;
let vatAmount = document.vat_amount == null ? null : Math.abs(document.vat_amount);
let foreignVatAmount: number | null = null;
let vatTaxCategory: string | null = null;
if (vatAmount && vatAmount > 0) {
amountWithoutVat = amountWithoutVat - vatAmount;
vatTaxCategory = isCreditorCounterparty
? OUTPUT_VAT_TAX_CATEGORY_ID
: INPUT_VAT_TAX_CATEGORY_ID;
}
// handle non-local currencies
if (document.currency_code !== DEFAULT_LOCAL_CURRENCY) {
// get exchange rate for currency
const exchangeRates = await injector
.get(FiatExchangeProvider)
.getExchangeRatesByDatesLoader.load(document.date);
const exchangeRate = getRateForCurrency(document.currency_code, exchangeRates);
// Set foreign amounts
foreignTotalAmount = totalAmount;
foreignAmountWithoutVat = amountWithoutVat;
// calculate amounts in ILS
totalAmount = exchangeRate * totalAmount;
amountWithoutVat = exchangeRate * amountWithoutVat;
if (vatAmount && vatAmount > 0) {
foreignVatAmount = vatAmount;
vatAmount = exchangeRate * vatAmount;
}
}
let creditAmount1: number | null = null;
let localCurrencyCreditAmount1 = 0;
let debitAmount1: number | null = null;
let localCurrencyDebitAmount1 = 0;
let creditAmount2: number | null = null;
let localCurrencyCreditAmount2: number | null = null;
let debitAmount2: number | null = null;
let localCurrencyDebitAmount2: number | null = null;
if (isCreditorCounterparty) {
localCurrencyCreditAmount1 = totalAmount;
creditAmount1 = foreignTotalAmount;
localCurrencyDebitAmount1 = amountWithoutVat;
debitAmount1 = foreignAmountWithoutVat;
if (vatAmount && vatAmount > 0) {
// add vat to debtor2
debitAmount2 = foreignVatAmount;
localCurrencyDebitAmount2 = vatAmount;
debitAccountID2 = vatTaxCategory;
}
} else {
localCurrencyDebitAmount1 = totalAmount;
debitAmount1 = foreignTotalAmount;
localCurrencyCreditAmount1 = amountWithoutVat;
creditAmount1 = foreignAmountWithoutVat;
if (vatAmount && vatAmount > 0) {
// add vat to creditor2
creditAmount2 = foreignVatAmount;
localCurrencyCreditAmount2 = vatAmount;
creditAccountID2 = vatTaxCategory;
}
}
const ledgerEntry: StrictLedgerProto = {
id: document.id,
invoiceDate: document.date,
valueDate: document.date,
currency,
creditAccountID1,
creditAmount1: creditAmount1 ?? undefined,
localCurrencyCreditAmount1,
debitAccountID1,
debitAmount1: debitAmount1 ?? undefined,
localCurrencyDebitAmount1,
creditAccountID2: creditAccountID2 ?? undefined,
creditAmount2: creditAmount2 ?? undefined,
localCurrencyCreditAmount2: localCurrencyCreditAmount2 ?? undefined,
debitAccountID2: debitAccountID2 ?? undefined,
debitAmount2: debitAmount2 ?? undefined,
localCurrencyDebitAmount2: localCurrencyDebitAmount2 ?? undefined,
description: document.description ?? undefined,
reference1: document.serial_number ?? undefined,
isCreditorCounterparty,
ownerId: charge.owner_id,
chargeId,
};
accountingLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance);
dates.add(document.date.getTime());
currencies.add(document.currency_code);
});
entriesPromises.push(...documentsEntriesPromises);
}
// generate ledger from transactions
if (shouldFetchTransactions) {
const { mainTransactions, feeTransactions } = splitFeeTransactions(transactions);
// for each transaction, create a ledger record
const mainTransactionsPromises = mainTransactions.map(async transaction => {
const { currency, valueDate, transactionBusinessId } =
validateTransactionBasicVariables(transaction);
let mainAccountId: string = transactionBusinessId;
if (
!shouldFetchDocuments &&
transaction.source_reference &&
charge.business_id &&
INTERNAL_WALLETS_IDS.includes(charge.business_id)
) {
const account = await injector
.get(FinancialAccountsProvider)
.getFinancialAccountByAccountNumberLoader.load(transaction.source_reference);
if (!account) {
throw new GraphQLError(`Transaction ID="${transaction.id}" is missing account`);
}
const taxCategoryName = getTaxCategoryNameByAccountCurrency(account, currency);
const taxCategory = await injector
.get(TaxCategoriesProvider)
.taxCategoryByNamesLoader.load(taxCategoryName);
if (!taxCategory) {
throw new GraphQLError(`Account ID="${account.id}" is missing tax category`);
}
mainAccountId = taxCategory.id;
}
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 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 taxCategory = await injector
.get(TaxCategoriesProvider)
.taxCategoryByNamesLoader.load(taxCategoryName);
if (!taxCategory) {
throw new GraphQLError(`Account ID="${account.id}" is missing tax category`);
}
const isCreditorCounterparty = amount > 0;
const ledgerEntry: StrictLedgerProto = {
id: transaction.id,
invoiceDate: transaction.event_date,
valueDate,
currency,
creditAccountID1: isCreditorCounterparty ? mainAccountId : taxCategory.id,
creditAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined,
localCurrencyCreditAmount1: Math.abs(amount),
debitAccountID1: isCreditorCounterparty ? taxCategory.id : mainAccountId,
debitAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined,
localCurrencyDebitAmount1: Math.abs(amount),
description: transaction.source_description ?? undefined,
reference1: transaction.source_id,
isCreditorCounterparty,
ownerId: charge.owner_id,
currencyRate: transaction.currency_rate ? Number(transaction.currency_rate) : undefined,
chargeId,
};
financialAccountLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance);
dates.add(valueDate.getTime());
currencies.add(currency);
});
// create a ledger record for fee transactions
const feeTransactionsPromises = feeTransactions.map(async transaction => {
await getEntriesFromFeeTransaction(transaction, charge, context).then(ledgerEntries => {
feeFinancialAccountLedgerEntries.push(...ledgerEntries);
ledgerEntries.map(ledgerEntry => {
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance);
dates.add(ledgerEntry.valueDate.getTime());
currencies.add(ledgerEntry.currency);
});
});
});
entriesPromises.push(...mainTransactionsPromises, ...feeTransactionsPromises);
}
await Promise.all(entriesPromises);
const miscLedgerEntries: LedgerProto[] = [];
// generate ledger from balance cancellation
for (const balanceCancellation of balanceCancellations) {
const entityBalance = ledgerBalance.get(balanceCancellation.business_id);
if (!entityBalance) {
console.log(
`Balance cancellation for business ${balanceCancellation.business_id} redundant - already balanced`,
);
continue;
}
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 GraphQLError(
`Balance cancellation for business ${balanceCancellation.business_id} failed - no financial account entry found`,
);
}
let foreignAmount: number | undefined = undefined;
if (
financialAccountEntry.currency !== DEFAULT_LOCAL_CURRENCY &&
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 ? BALANCE_CANCELLATION_TAX_CATEGORY_ID : entityId,
creditAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined,
localCurrencyCreditAmount1: Math.abs(amount),
debitAccountID1: isCreditorCounterparty ? entityId : BALANCE_CANCELLATION_TAX_CATEGORY_ID,
debitAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined,
localCurrencyDebitAmount1: Math.abs(amount),
description: balanceCancellation.description ?? undefined,
reference1: financialAccountEntry.reference1,
isCreditorCounterparty,
ownerId: charge.owner_id,
currencyRate: financialAccountEntry.currencyRate,
chargeId,
};
miscLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance);
}
const allowedUnbalancedBusinesses = new Set(
unbalancedBusinesses.map(({ business_id }) => business_id),
);
// Add ledger completion entries
const { balanceSum, isBalanced, unbalancedEntities, financialEntities } =
await getLedgerBalanceInfo(injector, ledgerBalance, allowedUnbalancedBusinesses);
if (Math.abs(balanceSum) > 0.005) {
throw new GraphQLError(
`Failed to balance: ${balanceSum} diff; ${unbalancedEntities.join(', ')} are unbalanced`,
);
} else if (!isBalanced) {
// check if business doesn't require documents
if (!accountingLedgerEntries.length && charge.business_id) {
const business = await injector
.get(BusinessesProvider)
.getBusinessByIdLoader.load(charge.business_id);
if (business?.no_invoices_required) {
const records = [...financialAccountLedgerEntries, ...feeFinancialAccountLedgerEntries];
await storeInitialGeneratedRecords(charge, records, injector);
return {
records: ledgerProtoToRecordsConverter(records),
charge,
balance: { balanceSum, isBalanced, unbalancedEntities },
};
}
}
// check if exchange rate record is needed
const hasMultipleDates = dates.size > 1;
const foreignCurrencyCount =
currencies.size - (currencies.has(DEFAULT_LOCAL_CURRENCY) ? 1 : 0);
const mightRequireExchangeRateRecord =
(hasMultipleDates && foreignCurrencyCount) || foreignCurrencyCount >= 2;
const unbalancedBusinesses = unbalancedEntities.filter(({ entityId }) =>
financialEntities.some(fe => fe.id === entityId && fe.type === 'business'),
);
if (mightRequireExchangeRateRecord && unbalancedBusinesses.length === 1) {
const transactionEntry = financialAccountLedgerEntries[0];
const documentEntry = accountingLedgerEntries[0];
const { entityId, balance } = unbalancedBusinesses[0];
const amount = Math.abs(balance.raw);
const isCreditorCounterparty = balance.raw < 0;
const exchangeRateTaxCategory = [
...financialAccountLedgerEntries,
...accountingLedgerEntries,
].find(entry =>
isCreditorCounterparty
? entry.creditAccountID1 === entityId
: entry.debitAccountID1 === entityId,
)?.[isCreditorCounterparty ? 'debitAccountID1' : 'creditAccountID1'];
if (!exchangeRateTaxCategory) {
throw new GraphQLError(
`Failed to locate tax category for exchange rate for business ID="${entityId}"`,
);
}
const ledgerEntry: StrictLedgerProto = {
id: transactionEntry.id + '|fee', // NOTE: this field is dummy
creditAccountID1: isCreditorCounterparty ? entityId : exchangeRateTaxCategory,
localCurrencyCreditAmount1: amount,
debitAccountID1: isCreditorCounterparty ? exchangeRateTaxCategory : entityId,
localCurrencyDebitAmount1: amount,
description: 'Exchange ledger record',
isCreditorCounterparty,
invoiceDate: documentEntry.invoiceDate,
valueDate: transactionEntry.valueDate,
currency: transactionEntry.currency, // NOTE: this field is dummy
ownerId: transactionEntry.ownerId,
chargeId,
};
miscLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance);
} else {
throw new GraphQLError(
`Failed to balance: ${
hasMultipleDates ? 'Dates are different' : 'Dates are consistent'
} and ${foreignCurrencyCount ? 'currencies are foreign' : 'currencies are local'}`,
);
}
}
const crossYearLedgerEntries = handleCrossYearLedgerEntries(
charge,
accountingLedgerEntries,
financialAccountLedgerEntries,
);
const records = [
...(crossYearLedgerEntries ?? accountingLedgerEntries),
...financialAccountLedgerEntries,
...feeFinancialAccountLedgerEntries,
...miscLedgerEntries,
];
await storeInitialGeneratedRecords(charge, records, injector);
const ledgerBalanceInfo = await getLedgerBalanceInfo(
injector,
ledgerBalance,
allowedUnbalancedBusinesses,
);
return {
records: ledgerProtoToRecordsConverter(records),
charge,
balance: ledgerBalanceInfo,
};
} catch (e) {
return {
__typename: 'CommonError',
message: `Failed to generate ledger records for charge ID="${chargeId}"\n${e}`,
};
}
};