@accounter/server
Version:
Accounter GraphQL server
315 lines (267 loc) • 11.7 kB
text/typescript
import { differenceInDays } from 'date-fns';
import { GraphQLError } from 'graphql';
import { Injector } from 'graphql-modules';
import type {
AddBusinessTripTravelAndSubsistenceExpenseInput,
BusinessTripAttendeeStayInput,
MutationCreditShareholdersBusinessTripTravelAndSubsistenceArgs,
RequireFields,
Resolver,
ResolverTypeWrapper,
} from '../../../__generated__/types.js';
import { Currency } from '../../../shared/enums.js';
import { dateToTimelessDateString } 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 { EmployeesProvider } from '../../salaries/providers/employees.provider.js';
import type { IGetEmployeesByIdResult } from '../../salaries/types.js';
import {
getAttendeeTravelAndSubsistenceMaxTax,
getExpenseCoreData,
} from '../helpers/business-trip-report.helper.js';
import { createTravelAndSubsistenceExpense } from '../helpers/business-trips-expenses.helper.js';
import { BusinessTripAttendeesProvider } from '../providers/business-trips-attendees.provider.js';
import { BusinessTripAccommodationsExpensesProvider } from '../providers/business-trips-expenses-accommodations.provider.js';
import { BusinessTripTravelAndSubsistenceExpensesProvider } from '../providers/business-trips-expenses-travel-and-subsistence.provider.js';
import { BusinessTripTaxVariablesProvider } from '../providers/business-trips-tax-variables.provider.js';
import { BusinessTripsProvider } from '../providers/business-trips.provider.js';
import type {
BusinessTripProto,
IGetBusinessTripsAccommodationsExpensesByBusinessTripIdsResult,
IGetBusinessTripsAttendeesByBusinessTripIdsResult,
IGetBusinessTripsTravelAndSubsistenceExpensesByBusinessTripIdsResult,
} from '../types.js';
import { businessTripSummary } from './business-trip-summary.resolver.js';
export const creditShareholdersBusinessTripTravelAndSubsistence: Resolver<
readonly ResolverTypeWrapper<string>[],
unknown,
GraphQLModules.Context,
RequireFields<MutationCreditShareholdersBusinessTripTravelAndSubsistenceArgs, 'businessTripId'>
> = async (_, { businessTripId }, context) => {
const { injector } = context;
async function summaryDataPromise() {
const businessTrip = await injector
.get(BusinessTripsProvider)
.getBusinessTripsByIdLoader.load(businessTripId);
if (!businessTrip) {
throw new GraphQLError(`Business trip with id ${businessTripId} not found`);
}
const attendees = await injector
.get(BusinessTripAttendeesProvider)
.getBusinessTripsAttendeesByBusinessTripIdLoader.load(businessTripId);
if (!attendees?.length) {
throw new GraphQLError(`Business trip with id ${businessTripId} is missing attendees`);
}
let toDate: Date | undefined;
attendees.map(({ departure }) => {
if (departure && (!toDate || departure > toDate)) {
toDate = departure;
}
});
if (!toDate) {
throw new GraphQLError(`Business trip with id ${businessTripId} is missing end date`);
}
const { ledgerLock } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
if (ledgerLock && dateToTimelessDateString(toDate) < ledgerLock) {
throw new GraphQLError(
`Cannot credit shareholders for business trip ${businessTrip.name}, because ledger is locked`,
);
}
const summaryData = await businessTripSummary(injector, businessTrip);
return { businessTrip: { ...businessTrip, toDate }, summaryData };
}
const accommodationExpensesPromise = injector
.get(BusinessTripAccommodationsExpensesProvider)
.getBusinessTripsAccommodationsExpensesByBusinessTripIdLoader.load(businessTripId);
const attendeePayedTnSExpensesPromise = injector
.get(BusinessTripTravelAndSubsistenceExpensesProvider)
.getBusinessTripsTravelAndSubsistenceExpensesByBusinessTripIdLoader.load(businessTripId)
.then(expenses => expenses.filter(expense => expense.payed_by_employee));
const [
{ businessTrip, summaryData },
accommodationExpenses,
attendeePayedTnSExpenses,
{ shareholdersMap, attendeesMap },
] = await Promise.all([
summaryDataPromise(),
accommodationExpensesPromise,
attendeePayedTnSExpensesPromise,
shareholdersMapPromise(injector, businessTripId),
]);
if (!businessTrip.toDate) {
throw new GraphQLError(`Business trip with id ${businessTripId} is missing end date`);
}
if (shareholdersMap.size === 0) {
return []; // No shareholders to distribute the credit to
}
async function availableAmountToDistributePromise() {
const travelAndSubsistenceRow = await summaryData.rows.find(
row => 'type' in row && row.type === 'TRAVEL_AND_SUBSISTENCE',
);
if (!travelAndSubsistenceRow) {
throw new GraphQLError(`Travel and subsistence row not found`);
}
const [{ raw: taxableAmount }, { raw: maxTaxableAmount }] = await Promise.all([
travelAndSubsistenceRow.maxTaxableForeignCurrency,
travelAndSubsistenceRow.taxableForeignCurrency,
]);
if (Number.isNaN(taxableAmount) || Number.isNaN(maxTaxableAmount)) {
throw new GraphQLError(`Taxable amounts are missing`);
}
const availableAmountToDistribute = Math.max(maxTaxableAmount - taxableAmount, 0);
return availableAmountToDistribute;
}
const [availableAmountToDistribute, shareholdersPotentialAmountToDistribute] = await Promise.all([
availableAmountToDistributePromise(),
shareholdersPotentialAmountToDistributePromise(
injector,
accommodationExpenses,
shareholdersMap,
attendeesMap,
attendeePayedTnSExpenses,
businessTrip,
),
]);
if (availableAmountToDistribute <= 0) {
return []; // No available amount to distribute
}
const commonFields: AddBusinessTripTravelAndSubsistenceExpenseInput = {
businessTripId,
currency: Currency.Usd,
date: dateToTimelessDateString(businessTrip.toDate!),
valueDate: dateToTimelessDateString(businessTrip.toDate!),
};
const totalPotentialAmountToDistribute = Object.values(
shareholdersPotentialAmountToDistribute,
).reduce((acc, amount) => acc + amount, 0);
const creditRatio =
totalPotentialAmountToDistribute <= availableAmountToDistribute
? 1
: availableAmountToDistribute / totalPotentialAmountToDistribute;
return Object.entries(shareholdersPotentialAmountToDistribute).map(([id, amount]) => {
const shareholder = shareholdersMap.get(id)!;
return createTravelAndSubsistenceExpense(context, {
...commonFields,
amount: amount * creditRatio,
expenseType: `${shareholder.first_name}'s travel and subsistence expenses, no invoice`,
employeeBusinessId: id,
});
});
};
async function shareholdersMapPromise(injector: Injector, businessTripId: string) {
const shareholdersMap = new Map<string, IGetEmployeesByIdResult>();
const attendeesMap = new Map<string, IGetBusinessTripsAttendeesByBusinessTripIdsResult>();
const attendees = await injector
.get(BusinessTripAttendeesProvider)
.getBusinessTripsAttendeesByBusinessTripIdLoader.load(businessTripId);
const shareholders = await injector
.get(EmployeesProvider)
.getEmployeesByIdLoader.loadMany(attendees.map(a => a.id))
.then(
res =>
res.filter(employee => {
if (!employee) {
return false;
}
if (employee instanceof Error) {
throw employee;
}
if (employee.role_status !== 'Controlling shareholder') {
return false;
}
return true;
}) as IGetEmployeesByIdResult[],
);
shareholders.map(shareholder => shareholdersMap.set(shareholder.business_id, shareholder));
attendees.map(attendee => {
if (shareholdersMap.has(attendee.id)) {
attendeesMap.set(attendee.id, attendee);
}
});
return { shareholdersMap, attendeesMap };
}
async function shareholdersPotentialAmountToDistributePromise(
injector: Injector,
accommodationExpenses: IGetBusinessTripsAccommodationsExpensesByBusinessTripIdsResult[],
shareholdersMap: Map<string, IGetEmployeesByIdResult>,
attendeesMap: Map<string, IGetBusinessTripsAttendeesByBusinessTripIdsResult>,
attendeePayedTnSExpenses: IGetBusinessTripsTravelAndSubsistenceExpensesByBusinessTripIdsResult[],
businessTrip: BusinessTripProto & { toDate: Date },
) {
const shareholdersPotentialAmountToDistribute: Record<string, number> = {};
const shareholdersAccommodatedNightsMap = new Map<string, number>();
const { defaultCryptoConversionFiatCurrency } = await injector
.get(AdminContextProvider)
.getVerifiedAdminContext();
// collect shareholders accommodated data
accommodationExpenses.map(expense => {
(expense.attendees_stay.filter(Boolean) as BusinessTripAttendeeStayInput[]).map(
({ attendeeId, nightsCount }) => {
if (shareholdersMap.has(attendeeId)) {
if (shareholdersAccommodatedNightsMap.has(attendeeId)) {
shareholdersAccommodatedNightsMap.set(
attendeeId,
shareholdersAccommodatedNightsMap.get(attendeeId)! + nightsCount,
);
} else {
shareholdersAccommodatedNightsMap.set(attendeeId, nightsCount);
}
}
},
);
});
await Promise.all(
Array.from(shareholdersMap.entries()).map(async ([id, shareholder]) => {
const attendee = attendeesMap.get(id);
if (!attendee) {
throw new Error(`Attendee with id ${id} not found, but is a shareholder`);
}
if (!attendee.arrival || !attendee.departure) {
console.log(`Shareholder ${shareholder.first_name} is missing arrival or departure date`);
return;
}
const taxVariables = await injector
.get(BusinessTripTaxVariablesProvider)
.getTaxVariablesByDateLoader.load(businessTrip.toDate);
if (!taxVariables) {
throw new GraphQLError(`Tax variables are not set for date ${businessTrip.toDate}`);
}
const totalDays = differenceInDays(attendee.departure, attendee.arrival) + 1;
const accommodatedNights = shareholdersAccommodatedNightsMap.get(id) ?? 0;
const unAccommodatedDays = accommodatedNights
? totalDays - accommodatedNights - 1
: totalDays;
const maxTaxableAmount =
getAttendeeTravelAndSubsistenceMaxTax(
totalDays,
unAccommodatedDays,
businessTrip.destination,
taxVariables,
) * -1;
const shareholderPayedTnSExpenses = attendeePayedTnSExpenses.filter(
expense => expense.employee_business_id === id,
);
let payedAmount = 0;
if (shareholderPayedTnSExpenses.length) {
await Promise.all(
shareholderPayedTnSExpenses.map(async expense => {
const { amount, currency, date } = getExpenseCoreData(expense);
if (expense.currency === defaultCryptoConversionFiatCurrency) {
payedAmount += amount;
} else {
const rate = await injector
.get(ExchangeProvider)
.getExchangeRates(currency, defaultCryptoConversionFiatCurrency, new Date(date));
payedAmount += amount * rate;
}
}),
);
}
if (payedAmount > maxTaxableAmount) {
return; // Shareholder already got payed more than the maximum taxable amount
}
shareholdersPotentialAmountToDistribute[id] = maxTaxableAmount - payedAmount;
}),
);
return shareholdersPotentialAmountToDistribute;
}