import { CreateExpenseResult, UpdateExpensePayload, UpdateExpenseResult, DeleteExpenseResult, NormalizedExpense } from "src/time-and-attendance/utils/type.utils"; import { toDateFromString, toStringFromDate, weekStartSunday } from "src/time-and-attendance/utils/date-time.utils"; import { Injectable, NotFoundException } from "@nestjs/common"; import { expense_select } from "src/time-and-attendance/utils/selects.utils"; import { PrismaService } from "src/prisma/prisma.service"; import { ExpenseDto } from "src/time-and-attendance/expenses/dtos/expense-create.dto"; import { GetExpenseDto } from "src/time-and-attendance/expenses/dtos/expense-get.dto"; import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils"; @Injectable() export class ExpenseUpsertService { constructor( private readonly prisma: PrismaService, private readonly emailResolver: EmailToIdResolver, ) { } //_________________________________________________________________ // CREATE //_________________________________________________________________ async createExpense( dto: ExpenseDto, email: string): Promise { try { //fetch employee_id using req.user.email const employee_id = await this.emailResolver.findIdByEmail(email); //normalize strings and dates const normed_expense = this.normalizeExpenseDto(dto); //finds the timesheet using expense.date const start_date = weekStartSunday(normed_expense.date); //parse numbers const parsed_amount = this.parseOptionalNumber(dto.amount, "amount"); const parsed_mileage = this.parseOptionalNumber(dto.mileage, "mileage"); const parsed_attachment = this.parseOptionalNumber(dto.attachment, "attachment"); const timesheet = await this.prisma.timesheets.findFirst({ where: { start_date, employee_id }, select: { id: true, employee_id: true }, }); if(!timesheet) throw new NotFoundException(`Timesheet with id ${dto.timesheet_id} not found`); //create a new expense const expense = await this.prisma.expenses.create({ data: { timesheet_id: timesheet.id, bank_code_id: dto.bank_code_id, attachment: parsed_attachment, date: normed_expense.date, amount: parsed_amount, mileage: parsed_mileage, comment: normed_expense.comment, supervisor_comment: normed_expense.supervisor_comment, is_approved: dto.is_approved, }, //return the newly created expense with id select: expense_select, }); //build an object to return to the frontend to display const created: GetExpenseDto = { id: expense.id, timesheet_id: expense.timesheet_id, bank_code_id: expense.bank_code_id, attachment: expense.attachment ?? undefined, date: toStringFromDate(expense.date), amount: expense.amount?.toNumber(), mileage: expense.mileage?.toNumber(), comment: expense.comment, supervisor_comment: expense.supervisor_comment ?? undefined, is_approved: expense.is_approved, }; return { ok: true, data: created } } catch (error) { return { ok: false, error: error } } } //_________________________________________________________________ // UPDATE //_________________________________________________________________ async updateExpense({id, dto}: UpdateExpensePayload): Promise { try { //checks for modifications const data: Record = {}; if (dto.date !== undefined) data.date = toDateFromString(dto.date); if (dto.comment !== undefined) data.comment = this.truncate280(dto.comment); if (dto.attachment !== undefined) data.attachment = this.parseOptionalNumber(dto.attachment, "attachment"); if (dto.amount !== undefined) data.amount = this.parseOptionalNumber(dto.amount, "amount"); if (dto.mileage !== undefined) data.mileage = this.parseOptionalNumber(dto.mileage, "mileage"); if (dto.bank_code_id !== undefined) data.bank_code_id = dto.bank_code_id; if (dto.supervisor_comment !== undefined) { data.supervisor_comment = dto.supervisor_comment?.trim() ? this.truncate280(dto.supervisor_comment.trim()) : null; } //return an error if no fields needs an update if(!Object.keys(data).length) { return { ok: false, id, error: new Error("Nothing to update")}; } const expense = await this.prisma.expenses.update({ where: { id }, data, select: expense_select, }); const updated: GetExpenseDto = { id: expense.id, timesheet_id: expense.timesheet_id, bank_code_id: expense.bank_code_id, attachment: expense.attachment ?? undefined, date: toStringFromDate(expense.date), amount: expense.amount?.toNumber(), mileage: expense.mileage?.toNumber(), comment: expense.comment, supervisor_comment: expense.supervisor_comment ?? undefined, is_approved: expense.is_approved, }; return { ok: true, id: expense.id, data: updated }; } catch (error) { return { ok: false, id: id, error: error} } } //_________________________________________________________________ // DELETE //_________________________________________________________________ async deleteExpense(expense_id: number): Promise { try { await this.prisma.$transaction(async (tx) => { const expense = await tx.expenses.findUnique({ where: { id: expense_id }, select: { id: true }, }); if(!expense) throw new NotFoundException(`Expense with id: ${expense_id} not found`); await tx.expenses.delete({ where: { id: expense_id }}); return { success: true }; }); return { ok: true, id: expense_id }; } catch (error) { return { ok: false, id: expense_id, error }; } } //_________________________________________________________________ // LOCAL HELPERS //_________________________________________________________________ //makes sure that comments are the right length the date is of Date type private normalizeExpenseDto(dto: ExpenseDto): NormalizedExpense { const date = toDateFromString(dto.date); const comment = this.truncate280(dto.comment); const supervisor_comment = dto.supervisor_comment && dto.supervisor_comment.trim() ? this.truncate280(dto.supervisor_comment.trim()) : undefined; return { date, comment, supervisor_comment }; } //makes sure that a string cannot exceed 280 chars private truncate280 = (input: string): string => { return input.length > 280 ? input.slice(0, 280) : input; } //makes sure that the type of data of numeric values is valid private parseOptionalNumber = (value: unknown, field: string) => { if (value == null) return undefined; const parsed = Number(value); if (Number.isNaN(parsed)) throw new Error(`Invalid value : ${value} for ${field}`); return parsed; }; }