UNPKG

@accounter/server

Version:
290 lines • 15.7 kB
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