@accounter/server
Version:
Accounter GraphQL server
492 lines (448 loc) • 16.5 kB
text/typescript
import { Injector } from 'graphql-modules';
import type { FinancialAmount } from '../../../__generated__/types.js';
import { EMPTY_UUID } from '../../../shared/constants.js';
import { Currency } from '../../../shared/enums.js';
import {
formatCurrency,
formatFinancialAmount,
getCurrencySymbol,
} from '../../../shared/helpers/index.js';
import type {
LedgerBalanceInfoType,
LedgerProto,
StrictLedgerProto,
} from '../../../shared/types/index.js';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import { getChargeBusinesses } from '../../charges/helpers/common.helper.js';
import type { IGetChargesByIdsResult } from '../../charges/types.js';
import { FinancialEntitiesProvider } from '../../financial-entities/providers/financial-entities.provider.js';
import { TaxCategoriesProvider } from '../../financial-entities/providers/tax-categories.provider.js';
import type { IGetFinancialEntitiesByIdsResult } from '../../financial-entities/types.js';
import type { IGetTransactionsByChargeIdsResult } from '../../transactions/types.js';
import type { IGetLedgerRecordsByChargesIdsResult } from '../types.js';
export class LedgerError extends Error {
constructor(message: string) {
super(message);
}
}
export function isTransactionsOppositeSign([first, second]: IGetTransactionsByChargeIdsResult[]) {
if (!first || !second) {
throw new LedgerError('Transactions are missing');
}
const firstAmount = Number(first.amount);
const secondAmount = Number(second.amount);
if (Number.isNaN(firstAmount) || Number.isNaN(secondAmount)) {
throw new LedgerError('Transaction amount is not a number');
}
return Number(first.amount) > 0 !== Number(second.amount) > 0;
}
export function validateTransactionBasicVariables(transaction: IGetTransactionsByChargeIdsResult) {
const currency = formatCurrency(transaction.currency);
const debitDate = transaction.debit_date_override ?? transaction.debit_date;
if (!debitDate) {
throw new LedgerError(
`Transaction reference "${transaction.source_reference}" is missing debit date for currency ${currency}`,
);
}
const valueDate = transaction.debit_timestamp ?? debitDate;
if (!transaction.business_id) {
throw new LedgerError(
`Transaction reference "${transaction.source_reference}" is missing business_id`,
);
}
const transactionBusinessId = transaction.business_id;
return {
currency,
valueDate,
transactionBusinessId,
};
}
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: NonNullable<T[P]> };
export type ValidateTransaction = Omit<
WithRequired<IGetTransactionsByChargeIdsResult, 'debit_date' | 'business_id' | 'debit_timestamp'>,
'currency'
> & { currency: Currency };
type ValidationOptions = {
skipBusinessId?: boolean;
};
export function validateTransactionRequiredVariables(
transaction: IGetTransactionsByChargeIdsResult,
validationOptions: ValidationOptions = {},
): ValidateTransaction {
if (!transaction.debit_date && !transaction.debit_date_override) {
throw new LedgerError(
`Transaction reference "${transaction.source_reference}" is missing debit date for currency ${transaction.currency}`,
);
}
if (!transaction.business_id && !validationOptions.skipBusinessId) {
throw new LedgerError(
`Transaction reference "${transaction.source_reference}" is missing business_id`,
);
}
const debit_timestamp =
transaction.debit_date_override ?? transaction.debit_timestamp ?? transaction.debit_date;
return {
...transaction,
debit_timestamp,
currency: formatCurrency(transaction.currency),
} as ValidateTransaction;
}
export function generatePartialLedgerEntry(
transaction: ValidateTransaction,
ownerId: string,
exchangeRate?: number,
): Omit<StrictLedgerProto, 'creditAccountID1' | 'debitAccountID1'> {
// set amounts
let amount = Number(transaction.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;
return {
id: transaction.id,
invoiceDate: transaction.event_date,
valueDate: transaction.debit_timestamp,
currency: transaction.currency,
creditAmount1: absForeignAmount,
localCurrencyCreditAmount1: absAmount,
debitAmount1: absForeignAmount,
localCurrencyDebitAmount1: absAmount,
description: transaction.source_description ?? undefined,
reference: transaction.origin_key,
isCreditorCounterparty,
ownerId,
currencyRate: transaction.currency_rate ? Number(transaction.currency_rate) : undefined,
chargeId: transaction.charge_id,
};
}
function entrySingleAccountBalancer(
ledgerBalance: Map<
string,
{
amount: number;
entityId: string;
foreignAmounts?: Partial<Record<Currency, { local: number; foreign: number }>>;
}
>,
entry: LedgerProto,
isCredit: boolean,
ledgerEntityNumber: 1 | 2,
defaultLocalCurrency: Currency,
) {
const entityId = isCredit
? ledgerEntityNumber === 1
? entry.creditAccountID1
: entry.creditAccountID2
: ledgerEntityNumber === 1
? entry.debitAccountID1
: entry.debitAccountID2;
if (!entityId) {
return;
}
const current = ledgerBalance.get(entityId);
const factor = isCredit ? 1 : -1;
const currency = entry.currency;
const amount =
((isCredit
? ledgerEntityNumber === 1
? entry.creditAmount1
: entry.creditAmount2
: ledgerEntityNumber === 1
? entry.debitAmount1
: entry.debitAmount2) ?? 0) * factor;
const localAmount =
((isCredit
? ledgerEntityNumber === 1
? entry.localCurrencyCreditAmount1
: entry.localCurrencyCreditAmount2
: ledgerEntityNumber === 1
? entry.localCurrencyDebitAmount1
: entry.localCurrencyDebitAmount2) ?? 0) * factor;
if (current) {
current.amount += localAmount;
current.foreignAmounts ||= {};
if (amount && currency !== defaultLocalCurrency) {
current.foreignAmounts[currency] = {
foreign: (current.foreignAmounts[currency]?.foreign ?? 0) + amount,
local: (current.foreignAmounts[currency]?.local ?? 0) + localAmount,
};
}
} else {
ledgerBalance.set(entityId, {
amount: localAmount,
entityId,
foreignAmounts:
amount && currency !== defaultLocalCurrency
? {
[currency]: {
foreign: amount,
local: localAmount,
},
}
: {},
});
}
}
export function updateLedgerBalanceByEntry(
entry: LedgerProto,
ledgerBalance: Map<
string,
{
amount: number;
entityId: string;
foreignAmounts?: Partial<Record<Currency, { local: number; foreign: number }>>;
}
>,
defaultLocalCurrency: Currency,
): void {
entrySingleAccountBalancer(ledgerBalance, entry, true, 1, defaultLocalCurrency);
entrySingleAccountBalancer(ledgerBalance, entry, true, 2, defaultLocalCurrency);
entrySingleAccountBalancer(ledgerBalance, entry, false, 1, defaultLocalCurrency);
entrySingleAccountBalancer(ledgerBalance, entry, false, 2, defaultLocalCurrency);
return;
}
export async function getLedgerBalanceInfo(
injector: Injector,
ledgerBalance: Map<string, { amount: number; entityId: string }>,
errors: Set<string> = new Set(),
allowedUnbalancedBusinesses: Set<string> = new Set(),
financialEntities?: Array<IGetFinancialEntitiesByIdsResult>,
): Promise<
LedgerBalanceInfoType & {
financialEntities: Array<IGetFinancialEntitiesByIdsResult>;
}
> {
const { defaultLocalCurrency } = await injector
.get(AdminContextProvider)
.getVerifiedAdminContext();
let ledgerBalanceSum = 0;
let isBalanced = true;
const unbalancedEntities: Array<{ entityId: string; balance: FinancialAmount }> = [];
if (!financialEntities) {
const financialEntityIDs = new Set<string>(
Array.from(ledgerBalance.values()).map(v => v.entityId),
);
financialEntities = (await injector
.get(FinancialEntitiesProvider)
.getFinancialEntityByIdLoader.loadMany(Array.from(financialEntityIDs))
.then(res =>
res.filter(fe => !!fe && 'id' in fe),
)) as Array<IGetFinancialEntitiesByIdsResult>;
}
for (const { amount, entityId } of ledgerBalance.values()) {
if (Math.abs(amount) < 0.005) {
continue;
}
const isBusiness =
financialEntities?.some(
financialEntity => financialEntity.id === entityId && financialEntity.type === 'business',
) ?? true;
if (isBusiness && !allowedUnbalancedBusinesses.has(entityId)) {
let businessIdentification = `ID="${entityId}"`;
if (financialEntities) {
const businessName = financialEntities.find(fe => fe.id === entityId)?.name;
if (businessName) {
businessIdentification = `"${businessName}"`;
}
}
errors.add(
`Business ${businessIdentification} is unbalanced (By ${amount.toFixed(2)}${getCurrencySymbol(defaultLocalCurrency)})`,
);
isBalanced = false;
}
unbalancedEntities.push({
entityId,
balance: formatFinancialAmount(amount, defaultLocalCurrency),
});
ledgerBalanceSum += amount;
}
if (Math.abs(ledgerBalanceSum) >= 0.005) {
errors.add(`Total ledger balance is ${ledgerBalanceSum}`);
isBalanced = false;
}
return {
isBalanced,
unbalancedEntities,
balanceSum: ledgerBalanceSum,
financialEntities,
};
}
export function ledgerProtoToRecordsConverter(
records: LedgerProto[],
): IGetLedgerRecordsByChargesIdsResult[] {
return records.map(record => {
const adjustedRecord: IGetLedgerRecordsByChargesIdsResult = {
charge_id: record.chargeId,
created_at: new Date(),
credit_entity1: record.creditAccountID1 ?? null,
credit_entity2: record.creditAccountID2 ?? null,
credit_foreign_amount1: record.creditAmount1?.toString() ?? null,
credit_foreign_amount2: record.creditAmount2?.toString() ?? null,
credit_local_amount1: record.localCurrencyCreditAmount1?.toString(),
credit_local_amount2: record.localCurrencyCreditAmount2?.toString() ?? null,
currency: record.currency,
debit_entity1: record.debitAccountID1 ?? null,
debit_entity2: record.debitAccountID2 ?? null,
debit_foreign_amount1: record.debitAmount1?.toString() ?? null,
debit_foreign_amount2: record.debitAmount2?.toString() ?? null,
debit_local_amount1: record.localCurrencyDebitAmount1?.toString(),
debit_local_amount2: record.localCurrencyDebitAmount2?.toString() ?? null,
description: record.description ?? null,
id: EMPTY_UUID,
invoice_date: record.invoiceDate,
owner_id: record.ownerId ?? null,
reference1: record.reference ?? null,
updated_at: new Date(),
value_date: record.valueDate,
locked: false,
};
return adjustedRecord;
});
}
export async function getFinancialAccountTaxCategoryId(
injector: Injector,
transaction: IGetTransactionsByChargeIdsResult,
): Promise<string> {
const taxCategory = await injector
.get(TaxCategoriesProvider)
.taxCategoryByFinancialAccountIdsAndCurrenciesLoader.load({
financialAccountId: transaction.account_id,
currency: transaction.currency as Currency,
});
if (!taxCategory) {
throw new LedgerError(
`Account id "${transaction.account_id}", with currency ${transaction.currency} is missing tax category`,
);
}
return taxCategory.id;
}
export async function multipleForeignCurrenciesBalanceEntries(
injector: Injector,
defaultLocalCurrency: Currency,
documentEntries: LedgerProto[],
transactionEntries: LedgerProto[],
charge: IGetChargesByIdsResult,
foreignAmounts: Partial<Record<Currency, { local: number; foreign: number }>>,
balanceAgainstLocal?: boolean,
): Promise<LedgerProto[]> {
const useDocuments = !charge.documents_optional_flag;
if (!transactionEntries.length) {
throw new LedgerError(`Failed to locate transaction entries for charge "${charge.id}"`);
}
if (!documentEntries.length && useDocuments) {
throw new LedgerError(`Failed to locate document entries for charge "${charge.id}"`);
}
const ledgerEntries: LedgerProto[] = [];
const { mainBusinessId } = await getChargeBusinesses(charge.id, injector);
if (mainBusinessId && Object.keys(foreignAmounts).length > 0) {
const transactionEntry = transactionEntries.reduce((prev, curr) => {
if (!prev) {
return curr;
}
return prev.valueDate.getTime() > curr.valueDate.getTime() ? prev : curr;
});
const invoiceDate = new Date(
Math.min(
...(useDocuments
? documentEntries.map(entry => entry.invoiceDate.getTime())
: transactionEntries.map(entry => entry.valueDate.getTime())),
),
);
// get the main foreign currency + diff in local currency
let mainForeignCurrency: { amount: number; currency: Currency } | undefined = undefined;
let localDiff = 0;
if (Object.keys(foreignAmounts).length === 1 && balanceAgainstLocal) {
const [currency, { local, foreign }] = Object.entries(foreignAmounts)[0];
if (foreign === 0) {
return [];
}
const isCreditorCounterparty = foreign < 0;
const ledgerEntry: LedgerProto = {
id: transactionEntry.id + `|${currency}-balance`, // NOTE: this field is dummy
creditAccountID1: mainBusinessId,
debitAccountID1: mainBusinessId,
localCurrencyCreditAmount1: Math.abs(local),
localCurrencyDebitAmount1: Math.abs(local),
...(isCreditorCounterparty
? {
creditAmount1: Math.abs(foreign),
}
: {
debitAmount1: Math.abs(foreign),
}),
description: 'Foreign currency balance',
isCreditorCounterparty,
invoiceDate,
valueDate: transactionEntry.valueDate,
currency: currency as Currency,
ownerId: transactionEntry.ownerId,
chargeId: charge.id,
};
return [ledgerEntry];
}
for (const [currency, { local }] of Object.entries(foreignAmounts)) {
if (mainForeignCurrency) {
if (mainForeignCurrency.amount < local) {
mainForeignCurrency = { amount: local, currency: currency as Currency };
}
} else {
mainForeignCurrency = { amount: local, currency: currency as Currency };
}
localDiff += local;
}
if (balanceAgainstLocal && !foreignAmounts[defaultLocalCurrency]) {
foreignAmounts[defaultLocalCurrency] = {
local: -localDiff,
foreign: 0,
};
}
for (const [currency, { local, foreign }] of Object.entries(foreignAmounts)) {
let localToUse = local;
if (!balanceAgainstLocal) {
if (Math.abs(foreign) < 0.005) {
continue;
}
if (mainForeignCurrency?.currency === currency) {
localToUse -= localDiff;
}
}
const isLocalCurrencyAndShouldBeBalanced =
balanceAgainstLocal && mainForeignCurrency && defaultLocalCurrency === currency;
const isCreditorCounterparty = isLocalCurrencyAndShouldBeBalanced
? mainForeignCurrency!.amount > 0
: foreign < 0;
const ledgerEntry: LedgerProto = {
id: transactionEntry.id + `|${currency}-balance`, // NOTE: this field is dummy
...(isCreditorCounterparty
? {
creditAccountID1: mainBusinessId,
}
: {
debitAccountID1: mainBusinessId,
}),
localCurrencyCreditAmount1: Math.abs(localToUse),
localCurrencyDebitAmount1: Math.abs(localToUse),
...(balanceAgainstLocal && !foreign
? {}
: {
creditAmount1: Math.abs(foreign),
debitAmount1: Math.abs(foreign),
}),
description: 'Foreign currency balance',
isCreditorCounterparty,
invoiceDate,
valueDate: transactionEntry.valueDate,
currency: currency as Currency,
ownerId: transactionEntry.ownerId,
chargeId: charge.id,
};
ledgerEntries.push(ledgerEntry);
}
}
return ledgerEntries;
}