@accounter/server
Version:
Accounter GraphQL server
402 lines (363 loc) • 14.1 kB
text/typescript
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');
}
}