targo-backend/src/modules/expenses/services/expenses-command.service.ts
2025-10-10 09:27:57 -04:00

250 lines
9.2 KiB
TypeScript

import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { Expenses, Prisma } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils";
import {
BadRequestException,
Injectable,
NotFoundException
} from "@nestjs/common";
import {
assertAndTrimComment,
computeAmountDecimal,
computeMileageAmount,
mapDbExpenseToDayResponse,
normalizeType,
parseAttachmentId
} from "../utils/expenses.utils";
import { toDateOnly } from "src/modules/shifts/helpers/shifts-date-time-helpers";
@Injectable()
export class ExpensesCommandService extends BaseApprovalService<Expenses> {
constructor(
prisma: PrismaService,
private readonly bankCodesResolver: BankCodesResolver,
private readonly timesheetsResolver: EmployeeTimesheetResolver,
private readonly emailResolver: EmailToIdResolver,
) { super(prisma); }
//_____________________________________________________________________________________________
// APPROVAL TX-DELEGATE METHODS
//_____________________________________________________________________________________________
protected get delegate() {
return this.prisma.expenses;
}
protected delegateFor(transaction: Prisma.TransactionClient){
return transaction.expenses;
}
async updateApproval(id: number, isApproved: boolean): Promise<Expenses> {
return this.prisma.$transaction((transaction) =>
this.updateApprovalWithTransaction(transaction, id, isApproved),
);
}
//_____________________________________________________________________________________________
// MASTER CRUD FUNCTION
//_____________________________________________________________________________________________
readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto,
): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => {
//validates if there is an existing expense, at least 1 old or new
const { old_expense, new_expense } = dto ?? {};
if(!old_expense && !new_expense) throw new BadRequestException('At least one expense must be provided');
//validate date format
const date_only = toDateOnly(date);
if(Number.isNaN(date_only.getTime())) throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)');
//resolve employee_id by email
const employee_id = await this.emailResolver.findIdByEmail(email);
//make sure a timesheet existes
const timesheet_id = await this.timesheetsResolver.ensureForDate(employee_id, date_only);
if(!timesheet_id) throw new NotFoundException(`no timesheet found for employee #${employee_id}`)
const {id} = timesheet_id;
return this.prisma.$transaction(async (tx) => {
const loadDay = async (): Promise<ExpenseResponse[]> => {
const rows = await tx.expenses.findMany({
where: {
timesheet_id: id,
date: date_only,
},
include: {
bank_code: {
select: {
type: true,
},
},
},
orderBy: [{ date: 'asc' }, { id: 'asc' }],
});
return rows.map((r) =>
mapDbExpenseToDayResponse({
date: r.date,
amount: r.amount ?? 0,
mileage: r.mileage ?? 0,
comment: r.comment,
is_approved: r.is_approved,
bank_code: r.bank_code,
}));
};
const normalizePayload = async (payload: {
type: string;
amount?: number;
mileage?: number;
comment: string;
attachment?: string | number;
}): Promise<{
type: string;
bank_code_id: number;
amount: Prisma.Decimal;
mileage: number | null;
comment: string;
attachment: number | null;
}> => {
const type = normalizeType(payload.type);
const comment = assertAndTrimComment(payload.comment);
const attachment = parseAttachmentId(payload.attachment);
const { id: bank_code_id, modifier } = await this.bankCodesResolver.findByType(type);
let amount = computeAmountDecimal(type, payload, modifier);
let mileage: number | null = null;
if (type === 'MILEAGE') {
mileage = Number(payload.mileage ?? 0);
if (!(mileage > 0)) {
throw new BadRequestException('Mileage required and must be > 0 for type MILEAGE');
}
const amountNumber = computeMileageAmount(mileage, modifier);
amount = new Prisma.Decimal(amountNumber);
} else {
if (!(typeof payload.amount === 'number' && payload.amount >= 0)) {
throw new BadRequestException('Amount required for non-MILEAGE expense');
}
amount = new Prisma.Decimal(payload.amount);
}
if (attachment !== null) {
const attachment_row = await tx.attachments.findUnique({
where: { id: attachment },
select: { status: true },
});
if (!attachment_row || attachment_row.status !== 'ACTIVE') {
throw new BadRequestException('Attachment not found or inactive');
}
}
return {
type,
bank_code_id,
amount,
mileage,
comment,
attachment
};
};
const findExactOld = async (norm: {
bank_code_id: number;
amount: Prisma.Decimal;
mileage: number | null;
comment: string;
attachment: number | null;
}) => {
return tx.expenses.findFirst({
where: {
timesheet_id: id,
date: date_only,
bank_code_id: norm.bank_code_id,
amount: norm.amount,
comment: norm.comment,
attachment: norm.attachment,
...(norm.mileage !== null ? { mileage: norm.mileage } : { mileage: null }),
},
select: { id: true },
});
};
let action : UpsertAction;
//_____________________________________________________________________________________________
// DELETE
//_____________________________________________________________________________________________
if(old_expense && !new_expense) {
const old_norm = await normalizePayload(old_expense);
const existing = await findExactOld(old_norm);
if(!existing) {
throw new NotFoundException({
error_code: 'EXPENSE_STALE',
message: 'The expense was modified or deleted by someone else',
});
}
await tx.expenses.delete({where: { id: existing.id } });
action = 'delete';
}
//_____________________________________________________________________________________________
// CREATE
//_____________________________________________________________________________________________
else if (!old_expense && new_expense) {
const new_exp = await normalizePayload(new_expense);
await tx.expenses.create({
data: {
timesheet_id: id,
date: date_only,
bank_code_id: new_exp.bank_code_id,
amount: new_exp.amount,
mileage: new_exp.mileage,
comment: new_exp.comment,
attachment: new_exp.attachment,
is_approved: false,
},
});
action = 'create';
}
//_____________________________________________________________________________________________
// UPDATE
//_____________________________________________________________________________________________
else if(old_expense && new_expense) {
const old_norm = await normalizePayload(old_expense);
const existing = await findExactOld(old_norm);
if(!existing) {
throw new NotFoundException({
error_code: 'EXPENSE_STALE',
message: 'The expense was modified or deleted by someone else',
});
}
const new_exp = await normalizePayload(new_expense);
await tx.expenses.update({
where: { id: existing.id },
data: {
bank_code_id: new_exp.bank_code_id,
amount: new_exp.amount,
mileage: new_exp.mileage,
comment: new_exp.comment,
attachment: new_exp.attachment,
},
});
action = 'update';
}
else {
throw new BadRequestException('Invalid upsert combination');
}
const day = await loadDay();
return { action, day };
});
}
}