UNPKG

@accounter/server

Version:
436 lines (381 loc) • 14.7 kB
import { Injector } from 'graphql-modules'; import type { BusinessTripAttendeeStayInput } from '../../../__generated__/types.js'; 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 type { IGetAllTaxVariablesResult, IGetBusinessTripsAccommodationsExpensesByBusinessTripIdsResult, IGetLastFlightByDateAndAttendeeIdResult, } from '../types.js'; import { AttendeeInfo, BusinessTripError, getExpenseAmountsData, isIncreasedLimitDestination, SummaryCategoryData, SummaryData, } from './business-trip-report.helper.js'; type AccommodationTaxVariables = { upToSevenNights: number; eightOrMoreNights: number; }; export async function accommodationExpenseDataCollector( injector: Injector, businessTripExpenses: IGetBusinessTripsAccommodationsExpensesByBusinessTripIdsResult[], partialSummaryData: Partial<SummaryData>, destinationCode: string | null, taxVariables: IGetAllTaxVariablesResult, attendeesMap: Map<string, AttendeeInfo>, ): Promise<number> { const { defaultLocalCurrency, defaultCryptoConversionFiatCurrency } = await injector .get(AdminContextProvider) .getVerifiedAdminContext(); // populate category partialSummaryData['ACCOMMODATION'] ??= {}; const category = partialSummaryData['ACCOMMODATION'] as SummaryCategoryData; 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< string, { localAmount: number; foreignAmount: number; nights: number } >(); // 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: string | null, taxVariables: IGetAllTaxVariablesResult, ): AccommodationTaxVariables { 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: Injector, businessTripExpense: IGetBusinessTripsAccommodationsExpensesByBusinessTripIdsResult, attendeesAccommodationMap: Map< string, { localAmount: number; foreignAmount: number; nights: number } >, ) { 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, ) as BusinessTripAttendeeStayInput[]; 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: Injector, attendeeId: string, attendeeInfo: Pick<AttendeeInfo, 'nightsCount' | 'arrival'>, accommodationTaxVariables: AccommodationTaxVariables, attendeeAccommodationData?: { nights: number; localAmount: number; foreignAmount: number }, ) { 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: number, nights: number, prevNights: number, accommodationTaxVariables: AccommodationTaxVariables, ): number { 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: number, { upToSevenNights, eightOrMoreNights }: AccommodationTaxVariables, ): number { 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: number, prevAccommodatedNights: number, accommodationTaxVariables: AccommodationTaxVariables, ): number { 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: number, accommodatedNights: number, accommodationTaxVariables: AccommodationTaxVariables, ): number { 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: Injector, attendeeId: string, arrival: Date, ): Promise<number> { let attendeePreviousTrip: IGetLastFlightByDateAndAttendeeIdResult | undefined; [attendeePreviousTrip] = await injector .get(BusinessTripAttendeesProvider) .getLastFlightByDateAndAttendeeId({ attendeeBusinessId: attendeeId, date: arrival, }); if (!attendeePreviousTrip) { return 0; } const previousTripIds: string[] = []; 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() as IGetBusinessTripsAccommodationsExpensesByBusinessTripIdsResult[], ); let accommodatedNights = 0; accommodationExpenses.map(expense => { const attendeesStay = expense.attendees_stay.filter(Boolean) as BusinessTripAttendeeStayInput[]; attendeesStay.map(({ attendeeId: expenseAttendeeId, nightsCount }) => { if (expenseAttendeeId === attendeeId) { accommodatedNights += nightsCount; } }); }); return accommodatedNights; }