UNPKG

@accounter/server

Version:
485 lines (423 loc) • 17.7 kB
import { Injector } from 'graphql-modules'; import type { BusinessTripSummaryCategories, BusinessTripSummaryRow, } from '../../../__generated__/types.js'; 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'; import type { IGetTransactionsByIdsResult } from '../../transactions/types.js'; import type { flight_class, IGetAllTaxVariablesResult, IGetBusinessTripsCarRentalExpensesByBusinessTripIdsResult, IGetBusinessTripsExpensesByBusinessTripIdsResult, IGetBusinessTripsFlightsExpensesByBusinessTripIdsResult, IGetBusinessTripsTravelAndSubsistenceExpensesByBusinessTripIdsResult, } from '../types.js'; export class BusinessTripError extends Error { constructor(message: string) { super(message); } } export type SummaryCategoryData = Partial< Record<Currency, { total: number; taxable: number; maxTaxable: number }> >; export type SummaryData = Record<BusinessTripSummaryCategories, SummaryCategoryData>; export type AttendeeInfo = { name: string; arrival: Date | null; departure: Date | null; daysCount: number; nightsCount: number; }; export function convertSummaryCategoryDataToRow( category: BusinessTripSummaryCategories, data: SummaryCategoryData, ): BusinessTripSummaryRow { 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: Partial<SummaryData>) { const totalSumCategory = Object.values(data).reduce((acc, category) => { Object.entries(category).map(([currency, { total, taxable }]) => { acc[currency as 'ILS' | 'USD'] ||= { total: 0, taxable: 0, maxTaxable: 0 }; acc[currency as 'ILS' | 'USD']!.total += total; acc[currency as 'ILS' | 'USD']!.taxable += taxable; }); return acc; }, {}); return totalSumCategory; } export function getExpenseCoreData( tripExpense: Pick< IGetBusinessTripsExpensesByBusinessTripIdsResult, 'payed_by_employee' | 'amount' | 'date' | 'currency' | 'value_date' | 'id' >, ): { amount: number; currency: Currency; date: Date; } { 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: Injector, currency: Currency, amount: number, date: 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: Injector, businessTripExpense: IGetBusinessTripsExpensesByBusinessTripIdsResult, ) { 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), ) as IGetTransactionsByIdsResult[]; 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 as 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: Injector, businessTripExpense: IGetBusinessTripsFlightsExpensesByBusinessTripIdsResult, partialSummaryData: Partial<SummaryData>, ): Promise<void> { // populate category partialSummaryData['FLIGHT'] ??= {}; const category = partialSummaryData['FLIGHT'] as SummaryCategoryData; const { localAmount, foreignAmount } = await getExpenseAmountsData(injector, businessTripExpense); // calculate taxable amount const fullyTaxableClasses: flight_class[] = ['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: Injector, otherExpenses: IGetBusinessTripsExpensesByBusinessTripIdsResult[], partialSummaryData: Partial<SummaryData>, ): Promise<string | void> { const { defaultLocalCurrency, defaultCryptoConversionFiatCurrency } = await injector .get(AdminContextProvider) .getVerifiedAdminContext(); // populate category partialSummaryData['OTHER'] ??= {}; const category = partialSummaryData['OTHER'] as SummaryCategoryData; 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; } type ReportMetaData = { destinationCode: string | null; unAccommodatedDays: number; attendees: Map<string, AttendeeInfo>; }; export async function travelAndSubsistenceExpensesDataCollector( injector: Injector, businessTripExpenses: IGetBusinessTripsTravelAndSubsistenceExpensesByBusinessTripIdsResult[], partialSummaryData: Partial<SummaryData>, taxVariables: IGetAllTaxVariablesResult, { destinationCode, unAccommodatedDays, attendees }: ReportMetaData, ): Promise<void> { const { defaultLocalCurrency, defaultCryptoConversionFiatCurrency } = await injector .get(AdminContextProvider) .getVerifiedAdminContext(); // populate category partialSummaryData['TRAVEL_AND_SUBSISTENCE'] ??= {}; const category = partialSummaryData['TRAVEL_AND_SUBSISTENCE'] as SummaryCategoryData; 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: Injector, businessTripExpenses: IGetBusinessTripsCarRentalExpensesByBusinessTripIdsResult[], partialSummaryData: Partial<SummaryData>, taxVariables: IGetAllTaxVariablesResult, destinationCode: string | null, ): Promise<void> { const { defaultLocalCurrency, defaultCryptoConversionFiatCurrency } = await injector .get(AdminContextProvider) .getVerifiedAdminContext(); // populate category partialSummaryData['CAR_RENTAL'] ??= {}; const category = partialSummaryData['CAR_RENTAL'] as SummaryCategoryData; 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: string | null) { if (!destinationCode) { return false; } const increasedLimitDestinations: CountryCode[] = [ 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() as CountryCode); } export function onlyUnique(value: string, index: number, array: string[]) { return array.indexOf(value) === index; } export function getAttendeeTravelAndSubsistenceMaxTax( totalBusinessDays: number, unAccommodatedDays: number, destinationCode: string | null, taxVariables: IGetAllTaxVariablesResult, ): number { 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';