@accounter/server
Version:
Accounter GraphQL server
383 lines (339 loc) • 14.4 kB
text/typescript
import type {
Maybe,
ResolverFn,
ResolversParentTypes,
ResolversTypes,
} from '../../../../__generated__/types.js';
import { DIVIDEND_WITHHOLDING_TAX_PERCENTAGE } from '../../../../shared/constants.js';
import type { LedgerProto, StrictLedgerProto } from '../../../../shared/types/index.js';
import { AdminContextProvider } from '../../../admin-context/providers/admin-context.provider.js';
import { DividendsProvider } from '../../../dividends/providers/dividends.provider.js';
import { ExchangeProvider } from '../../../exchange-rates/providers/exchange.provider.js';
import { TransactionsProvider } from '../../../transactions/providers/transactions.provider.js';
import { ledgerEntryFromBalanceCancellation } from '../../helpers/common-charge-ledger.helper.js';
import { splitDividendTransactions } from '../../helpers/dividend-ledger.helper.js';
import { getEntriesFromFeeTransaction } 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,
updateLedgerBalanceByEntry,
validateTransactionRequiredVariables,
} from '../../helpers/utils.helper.js';
import { BalanceCancellationProvider } from '../../providers/balance-cancellation.provider.js';
export const generateLedgerRecordsForDividend: ResolverFn<
Maybe<ResolversTypes['GeneratedLedgerRecords']>,
ResolversParentTypes['Charge'],
GraphQLModules.Context,
{ insertLedgerRecordsIfNotExists: boolean }
> = async (charge, { insertLedgerRecordsIfNotExists }, { injector }) => {
const { defaultLocalCurrency, dividends } = await injector
.get(AdminContextProvider)
.getVerifiedAdminContext();
const { dividendWithholdingTaxBusinessId, dividendTaxCategoryId } = dividends;
if (!dividendWithholdingTaxBusinessId) {
return {
__typename: 'CommonError',
message: `Dividend withholding tax business ID is not defined`,
};
}
if (!dividendTaxCategoryId) {
return {
__typename: 'CommonError',
message: `Dividend tax category ID is not defined`,
};
}
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 coreLedgerEntries: StrictLedgerProto[] = [];
const withholdingTaxLedgerEntries: LedgerProto[] = [];
const paymentLedgerEntries: LedgerProto[] = [];
const miscLedgerEntries: LedgerProto[] = [];
let mainAccountId: string | undefined = undefined;
// Get all transactions
const transactionsPromise = injector
.get(TransactionsProvider)
.transactionsByChargeIDLoader.load(chargeId);
const chargeBallanceCancellationsPromise = injector
.get(BalanceCancellationProvider)
.getBalanceCancellationByChargesIdLoader.load(chargeId);
const [transactions, balanceCancellations] = await Promise.all([
transactionsPromise,
chargeBallanceCancellationsPromise,
]);
const {
withholdingTaxTransactions,
paymentsTransactions,
feeTransactions,
errors: splitErrors,
} = splitDividendTransactions(transactions, dividends);
splitErrors.map(errors.add);
// create a ledger record for tax deduction origin
const withholdingTaxTransactionsPromises = withholdingTaxTransactions.map(
async preValidatedTransaction => {
try {
const transaction = validateTransactionRequiredVariables(preValidatedTransaction);
if (transaction.currency !== defaultLocalCurrency) {
errors.add(
`Withholding tax currency supposed to be local, got ${transaction.currency}`,
);
}
// get tax category
const financialAccountTaxCategoryId = await getFinancialAccountTaxCategoryId(
injector,
transaction,
);
// set main account for dividend
mainAccountId ||= financialAccountTaxCategoryId;
if (mainAccountId !== financialAccountTaxCategoryId) {
errors.add(`Tax category is not consistent`);
}
const partialEntry = generatePartialLedgerEntry(transaction, charge.owner_id, undefined);
const ledgerEntry: LedgerProto = {
...partialEntry,
creditAccountID1: partialEntry.isCreditorCounterparty
? transaction.business_id
: financialAccountTaxCategoryId,
debitAccountID1: partialEntry.isCreditorCounterparty
? financialAccountTaxCategoryId
: transaction.business_id,
};
withholdingTaxLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
} catch (e) {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
throw e;
}
}
},
);
const dividendRecordsPromise = injector
.get(DividendsProvider)
.getDividendsByChargeIdLoader.load(chargeId);
const [dividendRecords] = await Promise.all([
dividendRecordsPromise,
...withholdingTaxTransactionsPromises,
]);
const allowedUnbalancedBusinesses = new Set<string>();
// create a ledger record for dividend payments
const coreLedgerEntriesPromises = paymentsTransactions.map(async preValidatedTransaction => {
try {
const dividendRecord = dividendRecords.find(
record => record.transaction_id === preValidatedTransaction.id,
);
// run validations
if (!dividendRecord) {
throw new LedgerError(
`Transaction reference "${preValidatedTransaction.source_reference}" is missing matching dividend record`,
);
}
const transaction = validateTransactionRequiredVariables(preValidatedTransaction);
if (Number(transaction.amount) >= 0) {
throw new LedgerError(
`Dividend transaction amount cannot be positive (reference: ${transaction.source_reference})`,
);
}
if (Number(dividendRecord.amount) <= 0) {
throw new LedgerError(`Dividend amount is not positive (ID: ${dividendRecord.id})`);
}
if (charge.owner_id !== dividendRecord.owner_id) {
throw new LedgerError(
`Transaction reference "${transaction.source_reference}" 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;
const dividendRecordAbsAmount = Math.abs(Number(dividendRecord.amount));
// generate core ledger entries
const coreLedgerEntry: StrictLedgerProto = {
currency: defaultLocalCurrency,
ownerId: dividendRecord.owner_id,
id: dividendRecord.id,
chargeId,
invoiceDate: dividendRecord.date,
valueDate: dividendRecord.date,
isCreditorCounterparty: false,
description: 'Main dividend record',
debitAccountID1: dividendTaxCategoryId,
localCurrencyDebitAmount1: dividendRecordAbsAmount,
creditAccountID1: dividendRecord.business_id,
localCurrencyCreditAmount1: dividendRecordAbsAmount * (1 - withholdingTaxPercentage),
creditAccountID2: dividendWithholdingTaxBusinessId,
localCurrencyCreditAmount2: dividendRecordAbsAmount * withholdingTaxPercentage,
};
coreLedgerEntries.push(coreLedgerEntry);
updateLedgerBalanceByEntry(coreLedgerEntry, ledgerBalance, defaultLocalCurrency);
// preparations for payment 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, dividendRecord.date);
}
const partialEntry = generatePartialLedgerEntry(transaction, charge.owner_id, exchangeRate);
const isForeignCurrency = partialEntry.currency !== defaultLocalCurrency;
const amountDiff =
partialEntry.localCurrencyCreditAmount1 -
Number(dividendRecord.amount) * (1 - withholdingTaxPercentage);
if (Math.abs(amountDiff) > 0.005 && !isForeignCurrency) {
throw new LedgerError(
`Transaction reference "${transaction.source_reference}" and dividend record ID="${dividendRecord.id}" amounts mismatch`,
);
}
const foreignAccountTaxCategoryId = await getFinancialAccountTaxCategoryId(
injector,
transaction,
);
// create payment ledger entries
const paymentEntry: LedgerProto = {
...partialEntry,
isCreditorCounterparty: false,
creditAccountID1: foreignAccountTaxCategoryId,
debitAccountID1: dividendRecord.business_id,
};
paymentLedgerEntries.push(paymentEntry);
updateLedgerBalanceByEntry(paymentEntry, ledgerBalance, defaultLocalCurrency);
} catch (e) {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
throw e;
}
}
});
// create a ledger record for fee transactions
const feeFinancialAccountLedgerEntries: LedgerProto[] = [];
const feeTransactionsPromises = feeTransactions.map(async transaction => {
await getEntriesFromFeeTransaction(transaction, charge, injector)
.then(ledgerEntries => {
feeFinancialAccountLedgerEntries.push(...ledgerEntries);
ledgerEntries.map(ledgerEntry => {
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
});
})
.catch(e => {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
throw e;
}
});
});
const entriesPromises = [...feeTransactionsPromises, ...coreLedgerEntriesPromises];
// generate ledger from misc expenses
const expensesLedgerPromise = generateMiscExpensesLedger(charge, injector)
.then(entries => {
entries.map(entry => {
entry.ownerId = charge.owner_id;
miscLedgerEntries.push(entry);
updateLedgerBalanceByEntry(entry, ledgerBalance, defaultLocalCurrency);
});
})
.catch(e => {
if (e instanceof LedgerError) {
errors.add(e.message);
} else {
errors.add(`Failed to generate misc expenses ledger: ${e.message}`);
}
});
entriesPromises.push(expensesLedgerPromise);
await Promise.all(entriesPromises);
// create foreign currency balance ledger
for (const entry of paymentLedgerEntries) {
if (entry.currency !== defaultLocalCurrency) {
const currencyBalanceEntry1: LedgerProto = {
id: entry.id + `|${entry.currency}-balance`, // NOTE: this field is dummy
creditAccountID1: entry.debitAccountID1,
localCurrencyCreditAmount1: entry.localCurrencyCreditAmount1,
localCurrencyDebitAmount1: entry.localCurrencyCreditAmount1,
creditAmount1: entry.creditAmount1,
description: `Foreign currency balance (${entry.currency})`,
isCreditorCounterparty: true,
invoiceDate: entry.invoiceDate,
valueDate: entry.valueDate,
currency: entry.currency,
ownerId: entry.ownerId,
chargeId: charge.id,
};
miscLedgerEntries.push(currencyBalanceEntry1);
updateLedgerBalanceByEntry(currencyBalanceEntry1, ledgerBalance, defaultLocalCurrency);
const currencyBalanceEntry2: LedgerProto = {
id: entry.id + `|${defaultLocalCurrency}-balance`, // NOTE: this field is dummy
debitAccountID1: entry.debitAccountID1,
localCurrencyCreditAmount1: entry.localCurrencyCreditAmount1,
localCurrencyDebitAmount1: entry.localCurrencyCreditAmount1,
description: `Foreign currency balance (${defaultLocalCurrency})`,
isCreditorCounterparty: false,
invoiceDate: entry.invoiceDate,
valueDate: entry.valueDate,
currency: defaultLocalCurrency,
ownerId: entry.ownerId,
chargeId: charge.id,
};
miscLedgerEntries.push(currencyBalanceEntry2);
updateLedgerBalanceByEntry(currencyBalanceEntry2, ledgerBalance, defaultLocalCurrency);
}
}
// generate ledger from balance cancellation
for (const balanceCancellation of balanceCancellations) {
try {
const ledgerEntry = await ledgerEntryFromBalanceCancellation(
balanceCancellation,
ledgerBalance,
coreLedgerEntries,
chargeId,
charge.owner_id,
injector,
);
miscLedgerEntries.push(ledgerEntry);
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
} catch (e) {
if (e instanceof LedgerError) {
errors.add(e.message);
continue;
} else {
throw e;
}
}
}
const ledgerBalanceInfo = await getLedgerBalanceInfo(
injector,
ledgerBalance,
errors,
allowedUnbalancedBusinesses,
);
const records = [
...withholdingTaxLedgerEntries,
...coreLedgerEntries,
...feeFinancialAccountLedgerEntries,
...paymentLedgerEntries,
...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}`,
};
}
};