@accounter/server
Version:
Accounter GraphQL server
290 lines • 15.7 kB
JavaScript
import { CountryCode, Currency } from '../../../shared/enums.js';
import { formatCurrency, formatFinancialAmount } from '../../../shared/helpers/index.js';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import { ExchangeProvider } from '../../exchange-rates/providers/exchange.provider.js';
import { getTransactionDebitDate } from '../../transactions/helpers/debit-date.helper.js';
import { TransactionsProvider } from '../../transactions/providers/transactions.provider.js';
export class BusinessTripError extends Error {
constructor(message) {
super(message);
}
}
export function convertSummaryCategoryDataToRow(category, data) {
const excessExpenditure = (data[Currency.Ils]?.taxable ?? 0) - (data[Currency.Ils]?.total ?? 0);
return {
type: category,
totalForeignCurrency: formatFinancialAmount(data[Currency.Usd]?.total ?? 0, Currency.Usd),
taxableForeignCurrency: formatFinancialAmount(data[Currency.Usd]?.taxable ?? 0, Currency.Usd),
maxTaxableForeignCurrency: formatFinancialAmount(data[Currency.Usd]?.maxTaxable ?? 0, Currency.Usd),
totalLocalCurrency: formatFinancialAmount(data[Currency.Ils]?.total ?? 0, Currency.Ils),
taxableLocalCurrency: formatFinancialAmount(data[Currency.Ils]?.taxable ?? 0, Currency.Ils),
maxTaxableLocalCurrency: formatFinancialAmount(data[Currency.Ils]?.maxTaxable ?? 0, Currency.Ils),
excessExpenditure: formatFinancialAmount(Math.max(excessExpenditure, 0), Currency.Ils),
};
}
export function calculateTotalReportSummaryCategory(data) {
const totalSumCategory = Object.values(data).reduce((acc, category) => {
Object.entries(category).map(([currency, { total, taxable }]) => {
acc[currency] ||= { total: 0, taxable: 0, maxTaxable: 0 };
acc[currency].total += total;
acc[currency].taxable += taxable;
});
return acc;
}, {});
return totalSumCategory;
}
export function getExpenseCoreData(tripExpense) {
if (tripExpense.payed_by_employee) {
if (!tripExpense.currency || !tripExpense.amount || !tripExpense.date) {
throw new BusinessTripError(`Currency, amount or date not found for employee-paid trip expense ID ${tripExpense.id}`);
}
return {
amount: Number(tripExpense.amount),
currency: formatCurrency(tripExpense.currency),
date: new Date(tripExpense.date),
};
}
if (!tripExpense.currency || !tripExpense.amount || !tripExpense.value_date) {
throw new BusinessTripError(`Currency, amount or date not found for business trip expense ID ${tripExpense.id}`);
}
return {
amount: Number(tripExpense.amount),
currency: formatCurrency(tripExpense.currency),
date: new Date(tripExpense.value_date),
};
}
async function getDefaultCurrenciesAmountsAndExchangeRate(injector, currency, amount, date) {
const { defaultLocalCurrency, defaultCryptoConversionFiatCurrency } = await injector
.get(AdminContextProvider)
.getVerifiedAdminContext();
const exchangeRatePromise = currency === defaultLocalCurrency
? Promise.resolve(1)
: injector.get(ExchangeProvider).getExchangeRates(currency, defaultLocalCurrency, date);
const usdRatePromise = currency === defaultCryptoConversionFiatCurrency
? Promise.resolve(1)
: injector
.get(ExchangeProvider)
.getExchangeRates(currency, defaultCryptoConversionFiatCurrency, date);
const [localRate, foreignRate] = await Promise.all([exchangeRatePromise, usdRatePromise]);
const localAmount = localRate * amount;
const foreignAmount = foreignRate * amount;
return { localAmount, foreignAmount };
}
export async function getExpenseAmountsData(injector, businessTripExpense) {
try {
const { amount, currency, date } = getExpenseCoreData(businessTripExpense);
const { localAmount, foreignAmount } = await getDefaultCurrenciesAmountsAndExchangeRate(injector, currency, amount, date);
return { localAmount, foreignAmount };
}
catch (error) {
// handle merged transaction of different currencies
if (!(error instanceof Error) ||
!error?.message?.includes('Currency, amount or date not found for business trip expense') ||
!businessTripExpense.transaction_ids ||
businessTripExpense.transaction_ids.length <= 1) {
throw error;
}
const transactions = await injector
.get(TransactionsProvider)
.transactionByIdLoader.loadMany(businessTripExpense.transaction_ids);
const allTransactions = transactions.filter(transaction => transaction && !(transaction instanceof Error));
let localAmount = 0;
let foreignAmount = 0;
await Promise.all(allTransactions.map(async (transaction) => {
if (!transaction || transaction instanceof Error) {
return;
}
const amount = Number(transaction.amount);
const currency = transaction.currency;
const date = getTransactionDebitDate(transaction);
if (!amount || !currency || !date) {
const errorMessage = `Currency, amount or date not found for transaction ID ${transaction.id}`;
console.error(errorMessage);
throw new BusinessTripError(errorMessage);
}
const { localAmount: transactionLocalAmount, foreignAmount: transactionForeignAmount } = await getDefaultCurrenciesAmountsAndExchangeRate(injector, currency, amount, date);
localAmount += transactionLocalAmount;
foreignAmount += transactionForeignAmount;
}));
return { localAmount, foreignAmount };
}
}
export async function flightExpenseDataCollector(injector, businessTripExpense, partialSummaryData) {
// populate category
partialSummaryData['FLIGHT'] ??= {};
const category = partialSummaryData['FLIGHT'];
const { localAmount, foreignAmount } = await getExpenseAmountsData(injector, businessTripExpense);
// calculate taxable amount
const fullyTaxableClasses = ['ECONOMY', 'PREMIUM_ECONOMY', 'BUSINESS'];
if (!businessTripExpense.class) {
console.error(`Flight class not found for flight expense ID ${businessTripExpense.id}`);
throw new BusinessTripError('Flights expenses: some flights are missing class');
}
if (!fullyTaxableClasses.includes(businessTripExpense.class)) {
console.error(`Taxability logic for flight class ${businessTripExpense.class} is not implemented yet (trip expense ID: ${businessTripExpense.id})`);
throw new BusinessTripError(`Flights expenses: taxability logic for class ${businessTripExpense.class} is not implemented yet`);
}
// for all classes <= business, the amount is fully taxable
const localTaxable = localAmount;
const foreignTaxable = foreignAmount;
// update amounts
const { defaultLocalCurrency, defaultCryptoConversionFiatCurrency } = await injector
.get(AdminContextProvider)
.getVerifiedAdminContext();
category[defaultLocalCurrency] ||= { total: 0, taxable: 0, maxTaxable: 0 };
category[defaultLocalCurrency].total += localAmount;
category[defaultLocalCurrency].taxable += localTaxable;
category[defaultLocalCurrency].maxTaxable += localTaxable;
category[defaultCryptoConversionFiatCurrency] ||= { total: 0, taxable: 0, maxTaxable: 0 };
category[defaultCryptoConversionFiatCurrency].total += foreignAmount;
category[defaultCryptoConversionFiatCurrency].taxable += foreignTaxable;
category[defaultCryptoConversionFiatCurrency].maxTaxable += foreignTaxable;
return void 0;
}
export async function employeeAccommodationDataByTrip() { }
export async function otherExpensesDataCollector(injector, otherExpenses, partialSummaryData) {
const { defaultLocalCurrency, defaultCryptoConversionFiatCurrency } = await injector
.get(AdminContextProvider)
.getVerifiedAdminContext();
// populate category
partialSummaryData['OTHER'] ??= {};
const category = partialSummaryData['OTHER'];
category[defaultLocalCurrency] ||= { total: 0, taxable: 0, maxTaxable: 0 };
category[defaultCryptoConversionFiatCurrency] ||= { total: 0, taxable: 0, maxTaxable: 0 };
if (otherExpenses.length === 0) {
return void 0;
}
await Promise.all(otherExpenses.map(async (businessTripExpense) => {
const { localAmount, foreignAmount } = await getExpenseAmountsData(injector, businessTripExpense);
category[defaultLocalCurrency].total += localAmount;
category[defaultLocalCurrency].taxable += localAmount;
category[defaultLocalCurrency].maxTaxable += localAmount;
category[defaultCryptoConversionFiatCurrency].total += foreignAmount;
category[defaultCryptoConversionFiatCurrency].taxable += foreignAmount;
category[defaultCryptoConversionFiatCurrency].maxTaxable += foreignAmount;
}));
return void 0;
}
export async function travelAndSubsistenceExpensesDataCollector(injector, businessTripExpenses, partialSummaryData, taxVariables, { destinationCode, unAccommodatedDays, attendees }) {
const { defaultLocalCurrency, defaultCryptoConversionFiatCurrency } = await injector
.get(AdminContextProvider)
.getVerifiedAdminContext();
// populate category
partialSummaryData['TRAVEL_AND_SUBSISTENCE'] ??= {};
const category = partialSummaryData['TRAVEL_AND_SUBSISTENCE'];
category[defaultLocalCurrency] ||= { total: 0, taxable: 0, maxTaxable: 0 };
category[defaultCryptoConversionFiatCurrency] ||= { total: 0, taxable: 0, maxTaxable: 0 };
const totalBusinessDays = Array.from(attendees.values()).reduce((acc, attendee) => acc + attendee.daysCount, 0);
const maxTaxableUsd = getAttendeeTravelAndSubsistenceMaxTax(totalBusinessDays, unAccommodatedDays, destinationCode, taxVariables);
category[defaultLocalCurrency].maxTaxable += 0; // TODO: calculate
category[defaultCryptoConversionFiatCurrency].maxTaxable += maxTaxableUsd;
// set actual expense amounts
await Promise.all(businessTripExpenses.map(async (businessTripExpense) => {
const { localAmount, foreignAmount } = await getExpenseAmountsData(injector, businessTripExpense);
category[defaultLocalCurrency].total += localAmount;
category[defaultCryptoConversionFiatCurrency].total += foreignAmount;
}));
// set taxable amounts
const taxableAmount = Math.max(category[defaultCryptoConversionFiatCurrency].total, maxTaxableUsd);
const taxablePortion = category[defaultCryptoConversionFiatCurrency].total === 0
? 0
: taxableAmount / category[defaultCryptoConversionFiatCurrency].total;
category[defaultLocalCurrency].taxable += category[defaultLocalCurrency].total * taxablePortion;
category[defaultCryptoConversionFiatCurrency].taxable += taxableAmount;
return void 0;
}
export async function carRentalExpensesDataCollector(injector, businessTripExpenses, partialSummaryData, taxVariables, destinationCode) {
const { defaultLocalCurrency, defaultCryptoConversionFiatCurrency } = await injector
.get(AdminContextProvider)
.getVerifiedAdminContext();
// populate category
partialSummaryData['CAR_RENTAL'] ??= {};
const category = partialSummaryData['CAR_RENTAL'];
category[defaultLocalCurrency] ||= { total: 0, taxable: 0, maxTaxable: 0 };
category[defaultCryptoConversionFiatCurrency] ||= { total: 0, taxable: 0, maxTaxable: 0 };
let rentalDays = 0;
// set actual expense amounts
await Promise.all(businessTripExpenses.map(async (businessTripExpense) => {
if (!businessTripExpense.is_fuel_expense) {
rentalDays += businessTripExpense.days;
}
const { localAmount, foreignAmount } = await getExpenseAmountsData(injector, businessTripExpense);
category[defaultLocalCurrency].total += localAmount;
category[defaultCryptoConversionFiatCurrency].total += foreignAmount;
}));
// set max taxable amounts
const { max_car_rental_per_day } = taxVariables;
const maxDailyRentalAmount = Number(max_car_rental_per_day);
if (Number.isNaN(maxDailyRentalAmount)) {
throw new BusinessTripError('Tax variables are not set');
}
const increasedLimitDestination = isIncreasedLimitDestination(destinationCode) ? 1.25 : 1;
const maxTaxableUsd = maxDailyRentalAmount * rentalDays * increasedLimitDestination * -1;
category[defaultLocalCurrency].maxTaxable += 0; // TODO: calculate
category[defaultCryptoConversionFiatCurrency].maxTaxable += maxTaxableUsd;
// set taxable amounts
const taxableAmount = Math.max(category[defaultCryptoConversionFiatCurrency].total, maxTaxableUsd);
const taxablePortion = category[defaultCryptoConversionFiatCurrency].total === 0
? 0
: taxableAmount / category[defaultCryptoConversionFiatCurrency].total;
category[defaultLocalCurrency].taxable += category[defaultLocalCurrency].total * taxablePortion;
category[defaultCryptoConversionFiatCurrency].taxable += taxableAmount;
return void 0;
}
export function isIncreasedLimitDestination(destinationCode) {
if (!destinationCode) {
return false;
}
const increasedLimitDestinations = [
CountryCode.Angola,
CountryCode.Australia,
CountryCode.Austria,
CountryCode.Belgium,
CountryCode.Cameroon,
CountryCode.Canada,
CountryCode.Denmark,
CountryCode['United Arab Emirates (the)'],
CountryCode.Finland,
CountryCode.France,
CountryCode.Germany,
CountryCode.Greece,
CountryCode['Hong Kong'],
CountryCode.Iceland,
CountryCode.Ireland,
CountryCode.Italy,
CountryCode.Japan,
CountryCode['Korea (the Republic of)'],
CountryCode["Korea (the Democratic People's Republic of)"],
CountryCode.Luxembourg,
CountryCode['Netherlands (the)'],
CountryCode.Norway,
CountryCode.Oman,
CountryCode.Qatar,
CountryCode.Spain,
CountryCode.Sweden,
CountryCode.Switzerland,
CountryCode['Taiwan (Province of China)'],
CountryCode['United Kingdom of Great Britain and Northern Ireland (the)'],
];
return increasedLimitDestinations.includes(destinationCode.toLocaleUpperCase());
}
export function onlyUnique(value, index, array) {
return array.indexOf(value) === index;
}
export function getAttendeeTravelAndSubsistenceMaxTax(totalBusinessDays, unAccommodatedDays, destinationCode, taxVariables) {
const { max_tns_with_accommodation, max_tns_without_accommodation } = taxVariables;
const maxExpenseWithAccommodation = Number(max_tns_with_accommodation);
const maxExpenseWithoutAccommodation = Number(max_tns_without_accommodation);
if (Number.isNaN(maxExpenseWithAccommodation) || Number.isNaN(maxExpenseWithoutAccommodation)) {
throw new BusinessTripError('Tax variables are not set');
}
const accommodatedDays = totalBusinessDays - unAccommodatedDays;
const increasedLimitFactor = isIncreasedLimitDestination(destinationCode) ? 1.25 : 1;
const maxTaxableUsd = (maxExpenseWithAccommodation * accommodatedDays +
maxExpenseWithoutAccommodation * unAccommodatedDays) *
increasedLimitFactor *
-1;
return maxTaxableUsd;
}
export * from './business-trip-report-accommodation.helper.js';
//# sourceMappingURL=business-trip-report.helper.js.map