UNPKG

@accounter/server

Version:
492 lines (448 loc) • 16.5 kB
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; }