@accounter/server
Version:
332 lines (297 loc) • 13.4 kB
text/typescript
import { GraphQLError } from 'graphql';
import { DividendsProvider } from '@modules/dividends/providers/dividends.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 type { IGetAllTaxCategoriesResult } from '@modules/financial-entities/types';
import { storeInitialGeneratedRecords } from '@modules/ledger/helpers/ledgrer-storage.helper.js';
import { TransactionsProvider } from '@modules/transactions/providers/transactions.provider.js';
import {
DEFAULT_LOCAL_CURRENCY,
DIVIDEND_TAX_CATEGORY_ID,
DIVIDEND_WITHHOLDING_TAX_BUSINESS_ID,
DIVIDEND_WITHHOLDING_TAX_PERCENTAGE,
} from '@shared/constants';
import { Maybe, ResolverFn, ResolversParentTypes, ResolversTypes } from '@shared/gql-types';
import type { LedgerProto } from '@shared/types';
import { splitDividendTransactions } from '../../helpers/dividend-ledger.helper.js';
import { getEntriesFromFeeTransaction } from '../../helpers/fee-transactions.js';
import {
generatePartialLedgerEntry,
getLedgerBalanceInfo,
getTaxCategoryNameByAccountCurrency,
ledgerProtoToRecordsConverter,
updateLedgerBalanceByEntry,
validateTransactionRequiredVariables,
} from '../../helpers/utils.helper.js';
export const generateLedgerRecordsForDividend: 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 }>();
// generate ledger from transactions
const paymentsLedgerEntries: LedgerProto[] = [];
const withholdingTaxLedgerEntries: LedgerProto[] = [];
let mainAccountId: string | undefined = undefined;
// Get all transactions
const transactions = await injector
.get(TransactionsProvider)
.getTransactionsByChargeIDLoader.load(chargeId);
const { withholdingTaxTransactions, paymentsTransactions, feeTransactions } =
splitDividendTransactions(transactions);
// create a ledger record for tax deduction origin
for (const preValidatedTransaction of withholdingTaxTransactions) {
const transaction = validateTransactionRequiredVariables(preValidatedTransaction);
if (transaction.currency !== DEFAULT_LOCAL_CURRENCY) {
throw new GraphQLError(
`Withholding tax currency supposed to be local, got ${transaction.currency}`,
);
}
// 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`);
}
// set main account for dividend
mainAccountId ||= taxCategory.id;
if (mainAccountId !== taxCategory.id) {
throw new GraphQLError(`Tax category is not consistent`);
}
const partialEntry = generatePartialLedgerEntry(transaction, charge.owner_id, undefined);
const ledgerEntry: LedgerProto = {
...partialEntry,
creditAccountID1: partialEntry.isCreditorCounterparty
? transaction.business_id
: taxCategory.id,
debitAccountID1: partialEntry.isCreditorCounterparty
? taxCategory.id
: transaction.business_id,
};
withholdingTaxLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance);
}
const dividendRecords = await injector
.get(DividendsProvider)
.getDividendsByChargeIdLoader.load(chargeId);
const totalDividendSumMap = new Map<number, number>();
const allowedUnbalancedBusinesses = new Set<string>();
// create a ledger record for dividend payments
for (const preValidatedTransaction of paymentsTransactions) {
const dividendRecord = dividendRecords.find(
record => record.transaction_id === preValidatedTransaction.id,
);
// run validations
if (!dividendRecord) {
throw new GraphQLError(
`Transaction ID="${preValidatedTransaction.id}" is missing matching dividend record`,
);
}
const transaction = validateTransactionRequiredVariables(preValidatedTransaction);
if (Number(transaction.amount) >= 0) {
throw new GraphQLError(
`Dividend transaction amount cannot be positive (ID: ${transaction.id})`,
);
}
if (Number(dividendRecord.amount) <= 0) {
throw new GraphQLError(`Dividend amount is not positive (ID: ${dividendRecord.id})`);
}
if (
charge.owner_id !== dividendRecord.owner_id ||
transaction.debit_date.getTime() !== dividendRecord.date.getTime()
) {
throw new GraphQLError(
`Transaction ID="${transaction.id}" is not matching dividend record ID="${dividendRecord.id}"`,
);
}
const withholdingTaxPercentage = dividendRecord.withholding_tax_percentage_override
? Number(dividendRecord.withholding_tax_percentage_override)
: DIVIDEND_WITHHOLDING_TAX_PERCENTAGE;
// generate closing ledger entry out of the dividend record
const dividendRecordAbsAmount = Math.abs(Number(dividendRecord.amount));
const closingEntry: LedgerProto = {
id: dividendRecord.id,
invoiceDate: dividendRecord.date,
valueDate: dividendRecord.date,
currency: DEFAULT_LOCAL_CURRENCY,
isCreditorCounterparty: true,
ownerId: dividendRecord.owner_id,
creditAccountID1: dividendRecord.business_id,
localCurrencyCreditAmount1: dividendRecordAbsAmount,
localCurrencyDebitAmount1: dividendRecordAbsAmount,
chargeId,
};
paymentsLedgerEntries.push(closingEntry);
updateLedgerBalanceByEntry(closingEntry, ledgerBalance);
totalDividendSumMap.set(
dividendRecord.date.getTime(),
(totalDividendSumMap.get(dividendRecord.date.getTime()) ?? 0) + dividendRecordAbsAmount,
);
// 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 isForeignCurrency = partialEntry.currency !== DEFAULT_LOCAL_CURRENCY;
const amountDiff =
partialEntry.localCurrencyCreditAmount1 -
Number(dividendRecord.amount) * (1 - withholdingTaxPercentage);
if (Math.abs(amountDiff) > 0.005) {
if (isForeignCurrency) {
allowedUnbalancedBusinesses.add(transaction.business_id);
} else {
throw new GraphQLError(
`Transaction ID="${transaction.id}" and dividend record ID="${dividendRecord.id}" amounts mismatch`,
);
}
}
// generate core ledger entries
let foreignAccountTaxCategory: IGetAllTaxCategoriesResult | undefined = undefined;
if (isForeignCurrency) {
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);
foreignAccountTaxCategory = await injector
.get(TaxCategoriesProvider)
.taxCategoryByNamesLoader.load(taxCategoryName);
if (!foreignAccountTaxCategory) {
throw new GraphQLError(`Account ID="${account.id}" is missing tax category`);
}
const coreLedgerEntry: LedgerProto = {
id: dividendRecord.id,
invoiceDate: dividendRecord.date,
valueDate: dividendRecord.date,
currency: DEFAULT_LOCAL_CURRENCY,
description: 'נ20',
isCreditorCounterparty: false,
ownerId: dividendRecord.owner_id,
debitAccountID1: dividendRecord.business_id,
localCurrencyDebitAmount1: dividendRecordAbsAmount,
creditAccountID1: mainAccountId,
localCurrencyCreditAmount1: dividendRecordAbsAmount * (1 - withholdingTaxPercentage),
creditAccountID2: DIVIDEND_WITHHOLDING_TAX_BUSINESS_ID,
localCurrencyCreditAmount2: dividendRecordAbsAmount * withholdingTaxPercentage,
chargeId,
};
paymentsLedgerEntries.push(coreLedgerEntry);
updateLedgerBalanceByEntry(coreLedgerEntry, ledgerBalance);
// create conversion ledger entries
const conversionEntry1: LedgerProto = {
...partialEntry,
isCreditorCounterparty: false,
creditAccountID1: foreignAccountTaxCategory.id,
debitAccountID1: transaction.business_id,
};
paymentsLedgerEntries.push(conversionEntry1);
updateLedgerBalanceByEntry(conversionEntry1, ledgerBalance);
const conversionEntry2: LedgerProto = {
id: dividendRecord.id,
invoiceDate: dividendRecord.date,
valueDate: dividendRecord.date,
description: 'Conversion entry',
isCreditorCounterparty: true,
ownerId: dividendRecord.owner_id,
currency: DEFAULT_LOCAL_CURRENCY,
creditAccountID1: dividendRecord.business_id,
localCurrencyCreditAmount1: dividendRecordAbsAmount * (1 - withholdingTaxPercentage),
debitAccountID1: mainAccountId,
localCurrencyDebitAmount1: dividendRecordAbsAmount * (1 - withholdingTaxPercentage),
chargeId,
};
paymentsLedgerEntries.push(conversionEntry2);
updateLedgerBalanceByEntry(conversionEntry2, ledgerBalance);
} else {
const coreLedgerEntry: LedgerProto = {
...partialEntry,
isCreditorCounterparty: false,
description: 'נ20',
debitAccountID1: transaction.business_id,
localCurrencyDebitAmount1: dividendRecordAbsAmount,
creditAccountID1: mainAccountId,
localCurrencyCreditAmount1: dividendRecordAbsAmount * (1 - withholdingTaxPercentage),
creditAccountID2: DIVIDEND_WITHHOLDING_TAX_BUSINESS_ID,
localCurrencyCreditAmount2: dividendRecordAbsAmount * withholdingTaxPercentage,
};
paymentsLedgerEntries.push(coreLedgerEntry);
updateLedgerBalanceByEntry(coreLedgerEntry, ledgerBalance);
}
}
// create a ledger record for fee transactions
const entriesPromises: Array<Promise<void>> = [];
const feeFinancialAccountLedgerEntries: LedgerProto[] = [];
const feeTransactionsPromises = feeTransactions.map(async transaction => {
await getEntriesFromFeeTransaction(transaction, charge, context).then(ledgerEntries => {
feeFinancialAccountLedgerEntries.push(...ledgerEntries);
ledgerEntries.map(ledgerEntry => {
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance);
});
});
});
entriesPromises.push(...feeTransactionsPromises);
await Promise.all(entriesPromises);
// create ledger entry for summary dividend tax category
for (const [date, sum] of totalDividendSumMap.entries()) {
const ledgerEntry: LedgerProto = {
id: chargeId,
invoiceDate: new Date(date),
valueDate: new Date(date),
isCreditorCounterparty: false,
ownerId: charge.owner_id,
currency: DEFAULT_LOCAL_CURRENCY,
localCurrencyCreditAmount1: sum,
debitAccountID1: DIVIDEND_TAX_CATEGORY_ID,
localCurrencyDebitAmount1: sum,
chargeId,
};
paymentsLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance);
}
const ledgerBalanceInfo = await getLedgerBalanceInfo(
injector,
ledgerBalance,
allowedUnbalancedBusinesses,
);
const records = [
...withholdingTaxLedgerEntries,
...paymentsLedgerEntries,
...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}`,
};
}
};