@accounter/server
Version:
Accounter GraphQL server
254 lines • 13.3 kB
JavaScript
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