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 { BankCodesRepo } from "../repos/bank-codes.repo"; import { TimesheetsRepo } from "../repos/timesheets.repo"; import { EmployeesRepo } from "../repos/employee.repo"; import { toDateOnlyUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; import { assertAndTrimComment, computeMileageAmount, mapDbExpenseToDayResponse, normalizeType as normalizeTypeUtil } from "../utils/expenses.utils"; @Injectable() export class ExpensesCommandService extends BaseApprovalService { constructor( prisma: PrismaService, private readonly bankCodesRepo: BankCodesRepo, private readonly timesheetsRepo: TimesheetsRepo, private readonly employeesRepo: EmployeesRepo, ) { super(prisma); } protected get delegate() { return this.prisma.expenses; } protected delegateFor(transaction: Prisma.TransactionClient){ return transaction.expenses; } async updateApproval(id: number, isApproved: boolean): Promise { 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 = toDateOnlyUTC(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.resolveEmployeeIdByEmail(email); //make sure a timesheet existes const timesheet_id = await this.ensureTimesheetForDate(employee_id, date_only); return this.prisma.$transaction(async (tx) => { const loadDay = async (): Promise => { const rows = await tx.expenses.findMany({ where: { timesheet_id: timesheet_id, date: date_only, }, include: { bank_code: { select: { type: true, }, }, }, orderBy: [{ date: 'asc' }, { id: 'asc' }], }); return rows.map((r) => this.mapDbToDayResponse({ 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 = this.normalizeType(payload.type); const comment = this.assertAndTrimComment(payload.comment); const attachment = this.parseAttachmentId(payload.attachment); const { id: bank_code_id, modifier } = await this.lookupBankCodeOrThrow(tx, type); let amount = this.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 attachmentRow = await tx.attachments.findUnique({ where: { id: attachment }, select: { status: true }, }); if (!attachmentRow || attachmentRow.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: timesheet_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 oldNorm = await normalizePayload(old_expense); const existing = await findExactOld(oldNorm); 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: timesheet_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 oldNorm = await normalizePayload(old_expense); const existing = await findExactOld(oldNorm); 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 }; }); } //-------------------- helpers -------------------- private readonly normalizeType = (type: string): string => normalizeTypeUtil(type); private readonly assertAndTrimComment = (comment: string): string => assertAndTrimComment(comment); private readonly parseAttachmentId = (value: unknown): number | null => { if (value == null) { return null; } if (typeof value === 'number') { if (!Number.isInteger(value) || value <= 0) { throw new BadRequestException('Invalid attachment id'); } return value; } if (typeof value === 'string') { const trimmed = value.trim(); if (!trimmed.length) return null; if (!/^\d+$/.test(trimmed)) throw new BadRequestException('Invalid attachment id'); const parsed = Number(trimmed); if (parsed <= 0) throw new BadRequestException('Invalid attachment id'); return parsed; } throw new BadRequestException('Invalid attachment id'); }; private readonly resolveEmployeeIdByEmail = async (email: string): Promise => this.employeesRepo.findIdByEmail(email); private readonly ensureTimesheetForDate = async ( employee_id: number, date: Date ): Promise => { const { id } = await this.timesheetsRepo.ensureForDate(employee_id, date); return id; }; private readonly lookupBankCodeOrThrow = async ( transaction: Prisma.TransactionClient, type: string ): Promise<{id: number; modifier: number}> => this.bankCodesRepo.findByType(type, transaction); private readonly computeAmountDecimal = ( type: string, payload: { amount?: number; mileage?: number; }, modifier: number, ): Prisma.Decimal => { if(type === 'MILEAGE') { const km = payload.mileage ?? 0; const amountNumber = computeMileageAmount(km, modifier); return new Prisma.Decimal(amountNumber); } return new Prisma.Decimal(payload.amount!); }; private readonly mapDbToDayResponse = (row: { date: Date; amount: Prisma.Decimal | number | string; mileage: Prisma.Decimal | number | string; comment: string; is_approved: boolean; bank_code: { type: string } | null; }): ExpenseResponse => mapDbExpenseToDayResponse(row); }