UNPKG

@accounter/server

Version:
402 lines (363 loc) • 14.1 kB
import type { Injector } from 'graphql-modules'; import type { Currency } from '../../../shared/enums.js'; import type { LedgerProto } from '../../../shared/types/index.js'; import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js'; import type { AdminContext } from '../../admin-context/types.js'; import { getChargeBusinesses, getChargeDocumentsMeta, } from '../../charges/helpers/common.helper.js'; import { ChargeSpreadProvider } from '../../charges/providers/charge-spread.provider.js'; import type { IGetChargesByIdsResult } from '../../charges/types.js'; import { FinancialEntitiesProvider } from '../../financial-entities/providers/financial-entities.provider.js'; import { LedgerError } from './utils.helper.js'; function divideAmount(yearsCount: number, amount?: number): number | undefined { if (yearsCount === 1) { return amount; } return amount ? amount / yearsCount : undefined; } function divideEntryAmounts( entry: LedgerProto, yearsCount: number, predefinedAmount = 0, defaultLocalCurrency: Currency, ): Partial< Pick< LedgerProto, 'creditAmount1' | 'debitAmount1' | 'localCurrencyCreditAmount1' | 'localCurrencyDebitAmount1' > > { const originAmount = entry.currency === defaultLocalCurrency ? entry.localCurrencyDebitAmount1 : entry.debitAmount1; const ratio = 1 - Math.abs(predefinedAmount / originAmount!); return { creditAmount1: divideAmount(yearsCount, partialAmountCalculator(entry.creditAmount1, ratio)), debitAmount1: divideAmount(yearsCount, partialAmountCalculator(entry.debitAmount1, ratio)), localCurrencyCreditAmount1: divideAmount( yearsCount, partialAmountCalculator(entry.localCurrencyCreditAmount1, ratio), ), localCurrencyDebitAmount1: divideAmount( yearsCount, partialAmountCalculator(entry.localCurrencyDebitAmount1, ratio), ), }; } function partialAmountCalculator(amount: number | undefined, ratio = 0): number | undefined { if (amount === undefined) { return undefined; } return amount * ratio; } function getPartialEntryAmounts( entry: LedgerProto, stringAmount: string | null, defaultLocalCurrency: Currency, ): Partial<LedgerProto> | null { if (stringAmount === null) { return null; } const partialAmount = Number(stringAmount); if (Number.isNaN(partialAmount)) { return null; } const mainAmount = getEntryMainAmount(entry, defaultLocalCurrency); if ((entry.debitAmount1 || entry.creditAmount1) && partialAmount > mainAmount) { throw new LedgerError('Partial amount exceeds ledger record amount'); } const ratio = partialAmount / mainAmount; return { creditAmount1: partialAmountCalculator(entry.creditAmount1, ratio), creditAmount2: partialAmountCalculator(entry.creditAmount2, ratio), debitAmount1: partialAmountCalculator(entry.debitAmount1, ratio), debitAmount2: partialAmountCalculator(entry.debitAmount2, ratio), localCurrencyCreditAmount1: partialAmountCalculator(entry.localCurrencyCreditAmount1, ratio), localCurrencyCreditAmount2: partialAmountCalculator(entry.localCurrencyCreditAmount2, ratio), localCurrencyDebitAmount1: partialAmountCalculator(entry.localCurrencyDebitAmount1, ratio), localCurrencyDebitAmount2: partialAmountCalculator(entry.localCurrencyDebitAmount2, ratio), }; } export async function handleCrossYearLedgerEntries( charge: IGetChargesByIdsResult, injector: Injector, accountingLedgerEntries: LedgerProto[], ): Promise<LedgerProto[] | null> { const { defaultLocalCurrency, authorities, crossYear: { expensesToPayTaxCategoryId, expensesInAdvanceTaxCategoryId, incomeInAdvanceTaxCategoryId, incomeToCollectTaxCategoryId, }, } = await injector.get(AdminContextProvider).getVerifiedAdminContext(); if (accountingLedgerEntries.length === 0) { return null; } if ( !expensesToPayTaxCategoryId || !expensesInAdvanceTaxCategoryId || !incomeInAdvanceTaxCategoryId || !incomeToCollectTaxCategoryId ) { throw new LedgerError('Some cross-year tax categories are not set'); } const spreadRecords = await injector .get(ChargeSpreadProvider) .getChargeSpreadByChargeIdLoader.load(charge.id); if (!spreadRecords?.length) { return null; } if (accountingLedgerEntries.length > 1 && spreadRecords.length > 1) { throw new LedgerError('Unable to split multiple documents into multiple years'); } let yearsWithoutSpecifiedAmountCount = 0; let predefinedAmount = 0; spreadRecords.map(record => { if (record.amount === null) { yearsWithoutSpecifiedAmountCount += 1; } else { predefinedAmount += Number(record.amount); } }); const entriesAmount = accountingLedgerEntries .map(entry => { const { adjustedEntry } = splitVatPayments(entry, authorities); return adjustedEntry; }) .reduce((acc, entry) => { const mainAmount = getEntryMainAmount(entry, defaultLocalCurrency); return acc + mainAmount; }, 0); const [{ mainBusinessId }, { documentsMinDate }] = await Promise.all([ getChargeBusinesses(charge.id, injector), getChargeDocumentsMeta(charge.id, injector), ]); // handle current year auto-added spread record if amounts do not match if ( yearsWithoutSpecifiedAmountCount === 0 && Math.abs(predefinedAmount - entriesAmount) > 0.005 ) { if ( documentsMinDate && !spreadRecords.some(r => r.year_of_relevance.getFullYear() === documentsMinDate.getFullYear()) ) { spreadRecords.push({ owner_id: charge.owner_id, charge_id: charge.id, year_of_relevance: documentsMinDate, amount: null, }); yearsWithoutSpecifiedAmountCount += 1; } else { throw new Error('Spread amounts do not match ledger entries amounts'); } } let description: string | undefined = undefined; if (mainBusinessId) { try { const mainBusiness = await injector .get(FinancialEntitiesProvider) .getFinancialEntityByIdLoader.load(mainBusinessId); if (mainBusiness) { description = `Main counterparty: ${mainBusiness.name}`; } } catch (error) { console.error('Failed to load financial entity:', error); } } // calculate cross year entries const crossYearEntries: LedgerProto[] = []; if (accountingLedgerEntries.length === 1) { const entry = accountingLedgerEntries[0]; const { adjustedEntry, vatEntries } = splitVatPayments(entry, authorities); crossYearEntries.push(...vatEntries); const defaultAmounts = divideEntryAmounts( adjustedEntry, yearsWithoutSpecifiedAmountCount, predefinedAmount, defaultLocalCurrency, ); for (const spreadRecord of spreadRecords) { const amounts = getPartialEntryAmounts(adjustedEntry, spreadRecord.amount, defaultLocalCurrency) ?? defaultAmounts; const yearDate = spreadRecord.year_of_relevance; if ( adjustedEntry.invoiceDate.getFullYear() === yearDate.getFullYear() && adjustedEntry.valueDate.getFullYear() === yearDate.getFullYear() ) { crossYearEntries.push({ ...adjustedEntry, ...amounts }); continue; } const isYearOfRelevancePrior = adjustedEntry.invoiceDate.getFullYear() > yearDate.getFullYear(); const mediateTaxCategory = !!adjustedEntry.isCreditorCounterparty === !!adjustedEntry.isCreditInvoice ? isYearOfRelevancePrior ? incomeToCollectTaxCategoryId : incomeInAdvanceTaxCategoryId : isYearOfRelevancePrior ? expensesToPayTaxCategoryId : expensesInAdvanceTaxCategoryId; const yearOfRelevanceDates = isYearOfRelevancePrior ? { invoiceDate: new Date(yearDate.getFullYear(), 11, 31) } : { invoiceDate: new Date(yearDate.getFullYear(), 0, 1) }; crossYearEntries.push( { // first chronological entry ...adjustedEntry, ...amounts, ...(adjustedEntry.isCreditorCounterparty ? { creditAccountID1: mediateTaxCategory } : { debitAccountID1: mediateTaxCategory }), ...yearOfRelevanceDates, ...(isYearOfRelevancePrior ? { vat: undefined, description } : {}), }, { // second chronological entry ...adjustedEntry, ...amounts, ...(adjustedEntry.isCreditorCounterparty ? { debitAccountID1: mediateTaxCategory } : { creditAccountID1: mediateTaxCategory }), ...(isYearOfRelevancePrior ? {} : { vat: undefined, description }), }, ); } } else if (spreadRecords.length === 1) { const spreadRecord = spreadRecords[0]; const yearDate = spreadRecord.year_of_relevance; validateEntriesAmountsMatchesSpread(spreadRecord.amount, entriesAmount); for (const entry of accountingLedgerEntries) { const { adjustedEntry, vatEntries } = splitVatPayments(entry, authorities); crossYearEntries.push(...vatEntries); if ( adjustedEntry.invoiceDate.getFullYear() === yearDate.getFullYear() && adjustedEntry.valueDate.getFullYear() === yearDate.getFullYear() ) { crossYearEntries.push(adjustedEntry); continue; } const isYearOfRelevancePrior = adjustedEntry.invoiceDate.getFullYear() > yearDate.getFullYear(); const mediateTaxCategory = !!adjustedEntry.isCreditorCounterparty === !!adjustedEntry.isCreditInvoice ? isYearOfRelevancePrior ? incomeToCollectTaxCategoryId : incomeInAdvanceTaxCategoryId : isYearOfRelevancePrior ? expensesToPayTaxCategoryId : expensesInAdvanceTaxCategoryId; const yearOfRelevanceDates = isYearOfRelevancePrior ? { invoiceDate: new Date(yearDate.getFullYear(), 11, 31) } : { invoiceDate: new Date(yearDate.getFullYear(), 0, 1) }; crossYearEntries.push( { // first chronological entry ...adjustedEntry, ...(adjustedEntry.isCreditorCounterparty ? { creditAccountID1: mediateTaxCategory } : { debitAccountID1: mediateTaxCategory }), ...yearOfRelevanceDates, ...(isYearOfRelevancePrior ? { vat: undefined, description } : {}), }, { // second chronological entry ...adjustedEntry, ...(adjustedEntry.isCreditorCounterparty ? { debitAccountID1: mediateTaxCategory } : { creditAccountID1: mediateTaxCategory }), ...(isYearOfRelevancePrior ? {} : { vat: undefined, description }), }, ); } } return crossYearEntries; } function splitVatPayments( entry: LedgerProto, authorities: AdminContext['authorities'], ): { adjustedEntry: LedgerProto; vatEntries: LedgerProto[]; } { const { inputVatTaxCategoryId, outputVatTaxCategoryId, propertyOutputVatTaxCategoryId } = authorities; const vatTaxIds = [inputVatTaxCategoryId, outputVatTaxCategoryId, propertyOutputVatTaxCategoryId]; const vatEntries: LedgerProto[] = []; const adjustedEntry = { ...entry }; if (vatTaxIds.includes(entry.creditAccountID2 ?? '')) { vatEntries.push({ ...entry, creditAccountID1: entry.creditAccountID2, creditAccountID2: undefined, creditAmount1: entry.creditAmount2, creditAmount2: undefined, localCurrencyCreditAmount1: entry.localCurrencyCreditAmount2!, localCurrencyCreditAmount2: undefined, localCurrencyDebitAmount1: entry.localCurrencyCreditAmount2!, localCurrencyDebitAmount2: undefined, }); // remove VAT from main entry adjustedEntry.debitAmount1 &&= adjustedEntry.debitAmount1 - (entry.creditAmount2 ?? 0); adjustedEntry.localCurrencyDebitAmount1 &&= adjustedEntry.localCurrencyDebitAmount1 - entry.localCurrencyCreditAmount2!; adjustedEntry.creditAccountID2 = undefined; adjustedEntry.creditAmount2 = undefined; adjustedEntry.localCurrencyCreditAmount2 = undefined; } else if (vatTaxIds.includes(entry.debitAccountID2 ?? '')) { vatEntries.push({ ...entry, debitAccountID1: entry.debitAccountID2, debitAmount1: entry.debitAmount2, creditAmount1: entry.debitAmount2, localCurrencyDebitAmount1: entry.localCurrencyDebitAmount2!, localCurrencyCreditAmount1: entry.localCurrencyDebitAmount2!, debitAccountID2: undefined, debitAmount2: undefined, localCurrencyDebitAmount2: undefined, creditAccountID2: undefined, creditAmount2: undefined, localCurrencyCreditAmount2: undefined, }); // remove VAT from main entry adjustedEntry.creditAmount1 &&= adjustedEntry.creditAmount1 - (entry.debitAmount2 ?? 0); adjustedEntry.localCurrencyCreditAmount1 &&= adjustedEntry.localCurrencyCreditAmount1 - entry.localCurrencyDebitAmount2!; adjustedEntry.debitAccountID2 = undefined; adjustedEntry.debitAmount2 = undefined; adjustedEntry.localCurrencyDebitAmount2 = undefined; } if (adjustedEntry.creditAmount1 !== adjustedEntry.debitAmount1) { throw new LedgerError('Credit and debit amounts are not equal'); } return { adjustedEntry, vatEntries }; } function getEntryMainAmount(entry: LedgerProto, defaultLocalCurrency: Currency): number { const mainAmount = Math.abs( Math.max( ...(entry.currency === defaultLocalCurrency ? [entry.localCurrencyDebitAmount1 ?? 0, entry.localCurrencyCreditAmount1 ?? 0] : [entry.debitAmount1 ?? 0, entry.creditAmount1 ?? 0]), ), ); return mainAmount; } function validateEntriesAmountsMatchesSpread( spreadStringifiedAmount: string | null, entriesAmount: number, ): void { if (spreadStringifiedAmount === null) { return; } const spreadAmount = Number(spreadStringifiedAmount); if (Number.isNaN(spreadAmount)) { return; } if (spreadAmount !== entriesAmount) { throw new LedgerError('Spread amount does not match ledger entries amounts'); } }