@accounter/server
Version:
Accounter GraphQL server
337 lines (302 loc) • 12.2 kB
text/typescript
import { GraphQLError, GraphQLResolveInfo } from 'graphql';
import type { Injector } from 'graphql-modules';
import type {
AddBusinessTripTravelAndSubsistenceExpenseInput,
BusinessTripExpenseCategories,
} from '../../../__generated__/types.js';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import type { AdminContext } from '../../admin-context/types.js';
import { ChargesProvider } from '../../charges/providers/charges.provider.js';
import { isSupplementalFeeTransaction } from '../../ledger/helpers/fee-transactions.js';
import { LedgerError } from '../../ledger/helpers/utils.helper.js';
import { generateLedgerRecordsForBusinessTrip } from '../../ledger/resolvers/ledger-generation/business-trip-ledger-generation.resolver.js';
import type { IGetExpensesByChargeIdsResult } from '../../misc-expenses/types.js';
import { ChargeTagsProvider } from '../../tags/providers/charge-tags.provider.js';
import type { IGetTransactionsByChargeIdsResult } from '../../transactions/types.js';
import { BusinessTripEmployeePaymentsProvider } from '../providers/business-trips-employee-payments.provider.js';
import { BusinessTripAccommodationsExpensesProvider } from '../providers/business-trips-expenses-accommodations.provider.js';
import { BusinessTripCarRentalExpensesProvider } from '../providers/business-trips-expenses-car-rental.provider.js';
import { BusinessTripFlightsExpensesProvider } from '../providers/business-trips-expenses-flights.provider.js';
import { BusinessTripOtherExpensesProvider } from '../providers/business-trips-expenses-other.provider.js';
import { BusinessTripExpensesTransactionsMatchProvider } from '../providers/business-trips-expenses-transactions-match.provider.js';
import { BusinessTripTravelAndSubsistenceExpensesProvider } from '../providers/business-trips-expenses-travel-and-subsistence.provider.js';
import { BusinessTripExpensesProvider } from '../providers/business-trips-expenses.provider.js';
import { BusinessTripsProvider } from '../providers/business-trips.provider.js';
import type {
business_trip_transaction_type,
IGetBusinessTripsExpenseMatchesByTransactionIdsResult,
IUpdateBusinessTripEmployeePaymentParams,
IUpdateBusinessTripExpenseParams,
} from '../types.js';
function validateTransactionAgainstBusinessTripsExpenses(
transaction: IGetTransactionsByChargeIdsResult,
transactionMatchingExpenses: IGetBusinessTripsExpenseMatchesByTransactionIdsResult[],
miscExpenses: IGetExpensesByChargeIdsResult[],
financialAccounts: AdminContext['financialAccounts'],
): boolean {
if (!transactionMatchingExpenses?.length && !miscExpenses?.length) {
throw new LedgerError(
`Transaction reference "${transaction.source_reference}" is not part of a business trip`,
);
}
const totalAmount = transactionMatchingExpenses.reduce(
(acc, expense) => acc + Number(expense.amount),
0,
);
const direction = isSupplementalFeeTransaction(transaction, financialAccounts) ? -1 : 1;
const miscExpensesAmount = miscExpenses.reduce(
(acc, expense) => Number(expense.amount) * direction + acc,
0,
);
if (Math.abs(Number(transaction.amount) - miscExpensesAmount - totalAmount) > 0.005) {
throw new LedgerError(
`Transaction reference "${transaction.source_reference}" amount does not match the business trip expenses total amount`,
);
}
return true;
}
export const validateTransactionAgainstBusinessTrips = async (
injector: Injector,
transaction: IGetTransactionsByChargeIdsResult,
): Promise<boolean> => {
const transactionMatchingExpenses = await injector
.get(BusinessTripExpensesTransactionsMatchProvider)
.getBusinessTripsExpenseMatchesByTransactionIdLoader.load(transaction.id);
const { financialAccounts } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
return validateTransactionAgainstBusinessTripsExpenses(
transaction,
transactionMatchingExpenses,
[],
financialAccounts,
);
};
export const getTransactionMatchedAmount = async (
injector: Injector,
transaction: IGetTransactionsByChargeIdsResult,
): Promise<{ isFullyMatched: boolean; amount: number; errors?: string[] }> => {
const transactionMatchingExpensesPromise = injector
.get(BusinessTripExpensesTransactionsMatchProvider)
.getBusinessTripsExpenseMatchesByTransactionIdLoader.load(transaction.id);
const [transactionMatchingExpenses] = await Promise.all([transactionMatchingExpensesPromise]);
const expensesSum = transactionMatchingExpenses.reduce(
(acc, expense) => (expense.amount ? acc + Number(expense.amount) : acc),
0,
);
const { financialAccounts } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
try {
validateTransactionAgainstBusinessTripsExpenses(
transaction,
transactionMatchingExpenses,
[],
financialAccounts,
);
} catch (e) {
const errors = [];
if (e instanceof LedgerError) {
errors.push(e.message);
}
return {
isFullyMatched: false,
amount: expensesSum,
errors,
};
}
const isFullyMatched = Math.abs(Number(transaction.amount) - expensesSum) < 0.005;
return {
isFullyMatched,
amount: Number(transaction.amount),
};
};
export async function coreExpenseUpdate(
injector: Injector,
fields: IUpdateBusinessTripExpenseParams &
IUpdateBusinessTripEmployeePaymentParams & { id: string },
categoryToValidate?: business_trip_transaction_type,
) {
const { id, businessTripId, date, valueDate, amount, currency, employeeBusinessId, chargeId } =
fields;
const currentExpense = await injector
.get(BusinessTripExpensesProvider)
.getBusinessTripsExpensesByIdLoader.load(id);
if (!currentExpense) {
throw new GraphQLError(`Business trip expense with id ${id} not found`);
}
if (categoryToValidate && currentExpense.category !== categoryToValidate) {
throw new GraphQLError(
`Business trip expense with id ${id} is not a ${categoryToValidate} expense`,
);
}
const updateCoreExpensePromise = async () => {
return businessTripId
? injector.get(BusinessTripExpensesProvider).updateBusinessTripExpense({
businessTripExpenseId: id,
businessTripId,
...(currentExpense.payed_by_employee
? {
date,
valueDate,
currency,
employeeBusinessId,
}
: {}),
})
: Promise.resolve();
};
const updateEmployeePaymentPromise = async () => {
const hasCommonFieldsToUpdate =
date || valueDate || amount || currency || employeeBusinessId || chargeId;
return hasCommonFieldsToUpdate
? injector.get(BusinessTripEmployeePaymentsProvider).updateBusinessTripEmployeePayment({
businessTripExpenseId: id,
chargeId,
date,
valueDate,
currency,
employeeBusinessId,
})
: Promise.resolve();
};
const [updatedTripExpense] = await Promise.all([
updateCoreExpensePromise(),
updateEmployeePaymentPromise(),
]);
return updatedTripExpense;
}
export async function generateChargeForEmployeePayment(
injector: Injector,
businessTripId: string,
description?: string,
) {
const {
ownerId,
businessTrips: { businessTripTaxCategoryId },
} = await injector.get(AdminContextProvider).getVerifiedAdminContext();
if (!businessTripTaxCategoryId) {
throw new GraphQLError('Business trip tax category not set');
}
try {
const { id: chargeId } = await injector.get(ChargesProvider).generateCharge({
ownerId,
taxCategoryId: businessTripTaxCategoryId,
userDescription: description || 'Employee payment charge',
type: 'BUSINESS_TRIP',
});
if (!chargeId) {
throw new GraphQLError('Failed to generate initial charge');
}
await injector.get(BusinessTripsProvider).updateChargeBusinessTrip(chargeId, businessTripId);
return chargeId;
} catch (e) {
console.error('Failed to generate charge for employee payment', e);
throw new GraphQLError('Failed to generate charge for employee payment');
}
}
export const updateExistingTripExpense = async (
injector: Injector,
businessTripExpenseId: string,
transactionId: string,
category?: BusinessTripExpenseCategories,
amount?: number | null,
) => {
const updateTransactionMatchPromise = injector
.get(BusinessTripExpensesTransactionsMatchProvider)
.insertBusinessTripExpenseMatch({
businessTripExpenseId,
transactionId,
amount,
});
const insertToCategoryPromise = async () => {
switch (category) {
case 'FLIGHT':
return injector.get(BusinessTripFlightsExpensesProvider).insertBusinessTripFlightsExpense({
id: businessTripExpenseId,
});
case 'ACCOMMODATION':
return injector
.get(BusinessTripAccommodationsExpensesProvider)
.insertBusinessTripAccommodationsExpense({
id: businessTripExpenseId,
});
case 'TRAVEL_AND_SUBSISTENCE':
return injector
.get(BusinessTripTravelAndSubsistenceExpensesProvider)
.insertBusinessTripTravelAndSubsistenceExpense({ id: businessTripExpenseId });
case 'OTHER':
return injector.get(BusinessTripOtherExpensesProvider).insertBusinessTripOtherExpense({
id: businessTripExpenseId,
});
case 'CAR_RENTAL':
return injector
.get(BusinessTripCarRentalExpensesProvider)
.insertBusinessTripCarRentalExpense({
id: businessTripExpenseId,
days: 0,
isFuelExpense: false,
});
default:
throw new GraphQLError(`Invalid category ${category}`);
}
};
await Promise.all([updateTransactionMatchPromise, insertToCategoryPromise()]);
};
export async function createTravelAndSubsistenceExpense(
context: GraphQLModules.Context,
fields: AddBusinessTripTravelAndSubsistenceExpenseInput,
): Promise<string> {
const { injector } = context;
const adminContext = await injector.get(AdminContextProvider).getVerifiedAdminContext();
const { businessTripTagId } = adminContext.businessTrips;
try {
const coreExpensePromise = injector
.get(BusinessTripExpensesProvider)
.insertBusinessTripExpense({
businessTripId: fields.businessTripId,
category: 'TRAVEL_AND_SUBSISTENCE',
})
.then(res => res[0]);
const chargeGenerationPromise = generateChargeForEmployeePayment(
injector,
fields.businessTripId,
fields.expenseType ?? undefined,
);
const [coreExpense, chargeId] = await Promise.all([
coreExpensePromise,
chargeGenerationPromise,
]);
const [charge] = await Promise.all([
injector.get(ChargesProvider).getChargeByIdLoader.load(chargeId),
injector
.get(BusinessTripTravelAndSubsistenceExpensesProvider)
.insertBusinessTripTravelAndSubsistenceExpense({
id: coreExpense.id,
expenseType: fields.expenseType,
}),
injector.get(BusinessTripEmployeePaymentsProvider).insertBusinessTripEmployeePayment({
businessTripExpenseId: coreExpense.id,
chargeId,
date: fields.date,
valueDate: fields.valueDate,
amount: fields.amount,
currency: fields.currency,
employeeBusinessId: fields.employeeBusinessId,
}),
businessTripTagId
? injector.get(ChargeTagsProvider).insertChargeTag({ chargeId, tagId: businessTripTagId })
: Promise.resolve(),
]);
if (!charge) {
throw new GraphQLError('Failed to generate charge for employee payment');
}
// generate ledger records
await generateLedgerRecordsForBusinessTrip(
charge,
{ insertLedgerRecordsIfNotExists: true },
context,
{} as GraphQLResolveInfo,
);
return coreExpense.id;
} catch (e) {
console.error(`Error adding new business trip travel & subsistence expense`, e);
throw new GraphQLError('Error adding new business trip travel & subsistence expense');
}
}