UNPKG

@accounter/server

Version:
254 lines • 13.3 kB
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js'; import { BusinessTripAttendeesProvider } from '../providers/business-trips-attendees.provider.js'; import { BusinessTripAccommodationsExpensesProvider } from '../providers/business-trips-expenses-accommodations.provider.js'; import { BusinessTripError, getExpenseAmountsData, isIncreasedLimitDestination, } from './business-trip-report.helper.js'; export async function accommodationExpenseDataCollector(injector, businessTripExpenses, partialSummaryData, destinationCode, taxVariables, attendeesMap) { const { defaultLocalCurrency, defaultCryptoConversionFiatCurrency } = await injector .get(AdminContextProvider) .getVerifiedAdminContext(); // populate category partialSummaryData['ACCOMMODATION'] ??= {}; const category = partialSummaryData['ACCOMMODATION']; category[defaultLocalCurrency] ||= { total: 0, taxable: 0, maxTaxable: 0 }; category[defaultCryptoConversionFiatCurrency] ||= { total: 0, taxable: 0, maxTaxable: 0 }; // set actual expense amounts and collect attendee accommodation data const attendeesAccommodationMap = new Map(); // update total amounts and fill attendees accommodation map by expenses await Promise.all(businessTripExpenses.map(async (businessTripExpense) => expenseAccommodationTaxableAmounts(injector, businessTripExpense, attendeesAccommodationMap).then(({ localAmount, foreignAmount }) => { category[defaultLocalCurrency].total += localAmount; category[defaultCryptoConversionFiatCurrency].total += foreignAmount; }))); // get tax variables const accommodationTaxVariables = getAccommodationTaxVariablesUSD(destinationCode, taxVariables); let unAccommodatedDays = 0; // update taxable amounts and max taxable amounts by attendees await Promise.all(Array.from(attendeesMap.entries()).map(async ([attendeeId, attendeeInfo]) => { const { taxableLocal, taxableForeign, maxTaxableLocal, maxTaxableForeign, unAccommodatedAttendeeDays, } = await attendeeAccommodationTaxableAmounts(injector, attendeeId, attendeeInfo, accommodationTaxVariables, attendeesAccommodationMap.get(attendeeId)); category[defaultLocalCurrency].maxTaxable += maxTaxableLocal; category[defaultCryptoConversionFiatCurrency].maxTaxable += maxTaxableForeign; category[defaultLocalCurrency].taxable += taxableLocal; category[defaultCryptoConversionFiatCurrency].taxable += taxableForeign; unAccommodatedDays += unAccommodatedAttendeeDays; })); return unAccommodatedDays; } function getAccommodationTaxVariablesUSD(destinationCode, taxVariables) { const { max_accommodation_per_night_first_7_nights, max_accommodation_per_night_nights_8_to_90 } = taxVariables; const increasedLimitDestination = isIncreasedLimitDestination(destinationCode) ? 1.25 : 1; const upToSevenNights = Number(max_accommodation_per_night_first_7_nights) * increasedLimitDestination; const eightOrMoreNights = Number(max_accommodation_per_night_nights_8_to_90) * increasedLimitDestination; if (Number.isNaN(upToSevenNights) || Number.isNaN(eightOrMoreNights)) { throw new BusinessTripError('Tax variables are not set'); } return { upToSevenNights, eightOrMoreNights, }; } async function expenseAccommodationTaxableAmounts(injector, businessTripExpense, attendeesAccommodationMap) { const { localAmount, foreignAmount } = await getExpenseAmountsData(injector, businessTripExpense); if (!businessTripExpense.nights_count) { console.error(`Nights count not found for accommodation trip expense ID ${businessTripExpense.id}`); throw new BusinessTripError('Accommodation expenses: some expenses are missing nights count'); } if (!Number.isInteger(businessTripExpense.nights_count)) { console.error(`Nights count must be an integer`); throw new BusinessTripError('Accommodation expenses: nights count must be an integer'); } const localAmountPerNight = localAmount / businessTripExpense.nights_count; const foreignAmountPerNight = foreignAmount / businessTripExpense.nights_count; const attendeesStay = businessTripExpense.attendees_stay.filter(Boolean); let cumulativeExpenseAccommodationNights = 0; attendeesStay.map(async ({ attendeeId, nightsCount = 0 }) => { cumulativeExpenseAccommodationNights += nightsCount; if (!attendeesAccommodationMap.has(attendeeId)) { attendeesAccommodationMap.set(attendeeId, { nights: nightsCount, localAmount: localAmountPerNight * nightsCount, foreignAmount: foreignAmountPerNight * nightsCount, }); return; } const attendeeAccommodationData = attendeesAccommodationMap.get(attendeeId); attendeesAccommodationMap.set(attendeeId, { nights: attendeeAccommodationData.nights + nightsCount, localAmount: attendeeAccommodationData.localAmount + localAmountPerNight * nightsCount, foreignAmount: attendeeAccommodationData.foreignAmount + foreignAmountPerNight * nightsCount, }); }); if (cumulativeExpenseAccommodationNights !== businessTripExpense.nights_count) { console.error(`Attendees nights count (${cumulativeExpenseAccommodationNights}) doesn't match total nights count (${businessTripExpense.nights_count}) for expense ID ${businessTripExpense.id}`); throw new BusinessTripError('Accommodation expenses: attendees nights count mismatch'); } return { localAmount, foreignAmount }; } async function attendeeAccommodationTaxableAmounts(injector, attendeeId, attendeeInfo, accommodationTaxVariables, attendeeAccommodationData) { const { nightsCount: totalNights, arrival } = attendeeInfo; if (!totalNights) { // no nights, no accommodation return { taxableLocal: 0, taxableForeign: 0, maxTaxableLocal: 0, maxTaxableForeign: 0, unAccommodatedAttendeeDays: 0, }; } if (totalNights && !attendeeAccommodationData) { // no accommodation data, no accommodation return { taxableLocal: 0, taxableForeign: 0, maxTaxableLocal: 0, maxTaxableForeign: 0, unAccommodatedAttendeeDays: totalNights + 1, }; } if (arrival === null) { console.error(`Arrival date not found for attendee ID ${attendeeId}`); throw new BusinessTripError('Accommodation expenses: arrival date not found'); } const prevAccommodatedNights = await sumAccommodatedNightsOnPreviousConsecutiveTrips(injector, attendeeId, arrival); // validate attendee accommodation data const { nights: accommodationNights, localAmount, foreignAmount } = attendeeAccommodationData; if (accommodationNights > totalNights) { console.error(`Accommodated nights (${accommodationNights}) exceed total nights (${totalNights})`); throw new BusinessTripError('Accommodation expenses: accommodated nights exceed total nights stay'); } // calculate taxable amount const maxTaxableUsd = calculateMaxTaxableAttendeeAmountWithPrevTrips(accommodationNights, prevAccommodatedNights, accommodationTaxVariables); const maxTaxableLocal = 0; // TODO: calculate const maxTaxableForeign = maxTaxableUsd; const unAccommodatedAttendeeDays = totalNights > accommodationNights ? totalNights - accommodationNights : 0; const effectiveTaxableForeignAmount = calculateEffectiveTaxableAmountOverWeek(foreignAmount, accommodationNights, prevAccommodatedNights, accommodationTaxVariables); const taxableAmount = Math.max(effectiveTaxableForeignAmount, maxTaxableUsd); // amounts are negative, so we take the max (abs min) const taxablePortion = taxableAmount / foreignAmount; const taxableLocal = localAmount * taxablePortion; const taxableForeign = taxableAmount; return { taxableLocal, taxableForeign, maxTaxableLocal, maxTaxableForeign, unAccommodatedAttendeeDays, }; } function calculateEffectiveTaxableAmountOverWeek(amount, nights, prevNights, accommodationTaxVariables) { if (prevNights + nights < 7) { return amount; } if (prevNights > 7) { return calculateEffectiveTaxableAmount(amount, nights, accommodationTaxVariables); } const leftAccommodatedNightsUnderWeek = 7 - prevNights; const dailyAmount = amount / nights; // calculate leftAccommodatedNightsUnderWeek with full tax rate const fullTaxableNightsAmount = leftAccommodatedNightsUnderWeek * dailyAmount; // calculate rest of the days with lower tax rate const overSevenNights = Math.max(0, nights - leftAccommodatedNightsUnderWeek); const partialTaxableNightsAmount = overSevenNights * dailyAmount; return (fullTaxableNightsAmount + calculateEffectiveTaxableAmount(partialTaxableNightsAmount, overSevenNights, accommodationTaxVariables)); } function calculateMaxTaxableAttendeeAmount(totalAttendeeNights, { upToSevenNights, eightOrMoreNights }) { if (totalAttendeeNights === 0) { return 0; } let maxTaxableUsd = 0; if (totalAttendeeNights <= 7) { // up to 7 days maxTaxableUsd += totalAttendeeNights * upToSevenNights; } else if (totalAttendeeNights > 90) { // over 90 days maxTaxableUsd += totalAttendeeNights * eightOrMoreNights; } else { // 8 to 90 days const remainingNights = totalAttendeeNights - 7; maxTaxableUsd += 7 * upToSevenNights + remainingNights * upToSevenNights * 0.75; } maxTaxableUsd *= -1; return maxTaxableUsd; } function calculateMaxTaxableAttendeeAmountWithPrevTrips(totalAttendeeNights, prevAccommodatedNights, accommodationTaxVariables) { if (!prevAccommodatedNights) { // case no previous trips, regular calculation of current trip return calculateMaxTaxableAttendeeAmount(totalAttendeeNights, accommodationTaxVariables); } if (prevAccommodatedNights + totalAttendeeNights > 90) { // case over 90 days, regular calculation of all days return calculateMaxTaxableAttendeeAmount(prevAccommodatedNights + totalAttendeeNights, accommodationTaxVariables); } // case 8 to 90 days, calculate current trip taxable amount const allConsecutiveTripsTaxableAmount = calculateMaxTaxableAttendeeAmount(prevAccommodatedNights + totalAttendeeNights, accommodationTaxVariables); const prevTripsTaxableAmount = calculateMaxTaxableAttendeeAmount(prevAccommodatedNights, accommodationTaxVariables); const currentTripTaxableAmount = allConsecutiveTripsTaxableAmount - prevTripsTaxableAmount; return currentTripTaxableAmount; } function calculateEffectiveTaxableAmount(amount, accommodatedNights, accommodationTaxVariables) { const minTaxableAmount = accommodatedNights * accommodationTaxVariables.eightOrMoreNights * -1; // negative amount since we're dealing with expenses if (amount > minTaxableAmount) { // dealing with negative amounts so sign is flipped return amount; } if (amount * 0.75 < minTaxableAmount) { // dealing with negative amounts so sign is flipped return amount * 0.75; } return minTaxableAmount; } /** * Calculate the sum of accommodated nights on previous consecutive trips * @param injector * @param attendeeId * @param arrival * @returns */ async function sumAccommodatedNightsOnPreviousConsecutiveTrips(injector, attendeeId, arrival) { let attendeePreviousTrip; [attendeePreviousTrip] = await injector .get(BusinessTripAttendeesProvider) .getLastFlightByDateAndAttendeeId({ attendeeBusinessId: attendeeId, date: arrival, }); if (!attendeePreviousTrip) { return 0; } const previousTripIds = []; do { previousTripIds.push(attendeePreviousTrip.business_trip_id); if (attendeePreviousTrip?.arrival) { [attendeePreviousTrip] = await injector .get(BusinessTripAttendeesProvider) .getLastFlightByDateAndAttendeeId({ attendeeBusinessId: attendeeId, date: attendeePreviousTrip.arrival, }); } else { attendeePreviousTrip = undefined; } } while (attendeePreviousTrip); if (previousTripIds.length === 0) { return 0; } const accommodationExpenses = await injector .get(BusinessTripAccommodationsExpensesProvider) .getBusinessTripsAccommodationsExpensesByBusinessTripIdLoader.loadMany(previousTripIds) .then(res => res .filter(r => !(r instanceof Error)) .flat()); let accommodatedNights = 0; accommodationExpenses.map(expense => { const attendeesStay = expense.attendees_stay.filter(Boolean); attendeesStay.map(({ attendeeId: expenseAttendeeId, nightsCount }) => { if (expenseAttendeeId === attendeeId) { accommodatedNights += nightsCount; } }); }); return accommodatedNights; } //# sourceMappingURL=business-trip-report-accommodation.helper.js.map