fix(expense): Fix issue where expenses were not properly calculating amount

Also add better permission handling when creating shifts or expenses for other users through timesheet-approval module
This commit is contained in:
Nic D 2026-03-10 16:44:08 -04:00
parent 6df3aa944d
commit 91fe8843b3
10 changed files with 64 additions and 24 deletions

View File

@ -22,13 +22,26 @@ export class ExpenseCreateService {
// CREATE // CREATE
//_________________________________________________________________ //_________________________________________________________________
async createExpense( async createExpense(
dto: ExpenseDto, dto: ExpenseDto,
email: string, requesterEmail: string,
employee_email?: string targetEmail?: string
): Promise<Result<ExpenseDto, string>> { ): Promise<Result<ExpenseDto, string>> {
try { try {
const accountEmail = employee_email ?? email; // check if requester has access to timesheet_approval
if (!!targetEmail && requesterEmail !== targetEmail) {
const requester = await this.prisma.users.findFirst({
where: { email: requesterEmail, },
select: { user_module_access: true, }
})
if (!requester) return { success: false, error: 'INVALID_USER' }
if (!requester.user_module_access?.timesheets_approval)
return { success: false, error: 'ACCESS_DENIED' }
}
const accountEmail = targetEmail ?? requesterEmail;
//fetch employee_id using req.user.email //fetch employee_id using req.user.email
const employee_id = await this.emailResolver.findIdByEmail(accountEmail); const employee_id = await this.emailResolver.findIdByEmail(accountEmail);
if (!employee_id.success) return { success: false, error: employee_id.error }; if (!employee_id.success) return { success: false, error: employee_id.error };
@ -77,6 +90,7 @@ export class ExpenseCreateService {
employee_email: accountEmail, employee_email: accountEmail,
event_type: 'expense', event_type: 'expense',
action: 'create', action: 'create',
date: created.date,
}); });
return { success: true, data: created }; return { success: true, data: created };

View File

@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common";
import { PrismaPostgresService } from "prisma/postgres/prisma-postgres.service"; import { PrismaPostgresService } from "prisma/postgres/prisma-postgres.service";
import { Result } from "src/common/errors/result-error.factory"; import { Result } from "src/common/errors/result-error.factory";
import { EmailToIdResolver } from "src/common/mappers/email-id.mapper"; import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { toStringFromDate } from "src/common/utils/date-utils";
import { PayPeriodEventService } from "src/time-and-attendance/pay-period/services/pay-period-event.service"; import { PayPeriodEventService } from "src/time-and-attendance/pay-period/services/pay-period-event.service";
@Injectable() @Injectable()
@ -25,6 +26,7 @@ export class ExpenseDeleteService {
const expense = await this.prisma.expenses.findUnique({ const expense = await this.prisma.expenses.findUnique({
where: { id: expense_id }, where: { id: expense_id },
select: { select: {
date: true,
timesheet: { timesheet: {
select: { select: {
employee_id: true, employee_id: true,
@ -52,6 +54,7 @@ export class ExpenseDeleteService {
employee_email: email, employee_email: email,
event_type: 'expense', event_type: 'expense',
action: 'delete', action: 'delete',
date: toStringFromDate(expense.date)
}); });
return { success: true, data: expense_id }; return { success: true, data: expense_id };

View File

@ -20,38 +20,51 @@ export class ExpenseUpdateService {
async updateExpense( async updateExpense(
dto: ExpenseDto, dto: ExpenseDto,
email: string, requesterEmail: string,
employee_email?: string targetEmail?: string
): Promise<Result<ExpenseDto, string>> { ): Promise<Result<ExpenseDto, string>> {
try { try {
const account_email = employee_email ?? email; // check if requester has access to timesheet_approval
//fetch employee_id using req.user.email if (!!targetEmail && requesterEmail !== targetEmail) {
const employee_id = await this.emailResolver.findIdByEmail(account_email); const requester = await this.prisma.users.findFirst({
where: { email: requesterEmail, },
select: { user_module_access: true, }
})
if (!requester) return { success: false, error: 'INVALID_USER' }
if (!requester.user_module_access?.timesheets_approval)
return { success: false, error: 'ACCESS_DENIED' }
}
const accountEmail = targetEmail ?? requesterEmail;
const employee_id = await this.emailResolver.findIdByEmail(accountEmail);
if (!employee_id.success) return { success: false, error: employee_id.error }; if (!employee_id.success) return { success: false, error: employee_id.error };
//normalize string , date format and parse numbers
const normed_expense = await normalizeAndParseExpenseDto(dto); const normed_expense = await normalizeAndParseExpenseDto(dto);
if (!normed_expense.success) return { success: false, error: normed_expense.error } if (!normed_expense.success) return { success: false, error: normed_expense.error }
const type = await this.typeResolver.findBankCodeIDByType(dto.type); const type = await this.typeResolver.findBankCodeIDByType(dto.type);
if (!type.success) return { success: false, error: 'INVALID_EXPENSE_TYPE' } if (!type.success) return { success: false, error: 'INVALID_EXPENSE_TYPE' }
//added timesheet_id modification check according to the new date
const new_timesheet_start_date = weekStartSunday(toDateFromString(dto.date)); const new_timesheet_start_date = weekStartSunday(toDateFromString(dto.date));
const timesheet = await this.prisma.timesheets.findFirst({ const timesheet = await this.prisma.timesheets.findFirst({
where: { start_date: new_timesheet_start_date, employee_id: employee_id.data }, where: { start_date: new_timesheet_start_date, employee_id: employee_id.data },
select: timesheet_select, select: timesheet_select,
}); });
if (!timesheet) return { success: false, error: `TIMESHEET_NOT_FOUND` } if (!timesheet) return { success: false, error: `TIMESHEET_NOT_FOUND` }
//checks for modifications
const data = { const data = {
...normed_expense.data, ...normed_expense.data,
bank_code_id: type.data, bank_code_id: type.data,
is_approved: dto.is_approved, is_approved: dto.is_approved,
}; };
if (!data) return { success: false, error: `INVALID_EXPENSE` } if (!data) return { success: false, error: `INVALID_EXPENSE` }
//push updates and get updated datas
const expense = await this.prisma.expenses.update({ const expense = await this.prisma.expenses.update({
where: { id: dto.id, timesheet_id: timesheet.id }, where: { id: dto.id, timesheet_id: timesheet.id },
data, data,
@ -59,7 +72,6 @@ export class ExpenseUpdateService {
}); });
if (!expense) return { success: false, error: 'INVALID_EXPENSE' } if (!expense) return { success: false, error: 'INVALID_EXPENSE' }
//build an object to return to the frontend
const updated: ExpenseDto = { const updated: ExpenseDto = {
...expense, ...expense,
type: expense.bank_code.type, type: expense.bank_code.type,
@ -70,12 +82,13 @@ export class ExpenseUpdateService {
}; };
// notify timesheet-approval observers of changes, but only if it came // notify timesheet-approval observers of changes, but only if it came
// from timesheet and not timesheet-approval (no employee_email) // from timesheet and not timesheet-approval (no targetEmail)
if (!employee_email) { if (!targetEmail) {
this.payPeriodEventService.emit({ this.payPeriodEventService.emit({
employee_email: email, employee_email: accountEmail,
event_type: 'expense', event_type: 'expense',
action: 'update', action: 'update',
date: updated.date,
}); });
} }

View File

@ -79,7 +79,7 @@ export class CsvExportService {
const week = computeWeekNumber(start, shift.date); const week = computeWeekNumber(start, shift.date);
const type_transaction = shift.bank_code.bank_code.charAt(0); const type_transaction = shift.bank_code.bank_code.charAt(0);
const code = Number(shift.bank_code.bank_code.slice(1,)); const code = Number(shift.bank_code.bank_code.slice(1,));
const isPTO = PTO_SHIFT_CODES.includes(shift.bank_code.bank_code) const isPTO = PTO_SHIFT_CODES.includes(shift.bank_code.bank_code);
return { return {
timesheet_id: shift.timesheet.id, timesheet_id: shift.timesheet.id,
@ -118,6 +118,8 @@ export class CsvExportService {
const type_transaction = expense.bank_code.bank_code.charAt(0); const type_transaction = expense.bank_code.bank_code.charAt(0);
const code = Number(expense.bank_code.bank_code.slice(1,)) const code = Number(expense.bank_code.bank_code.slice(1,))
const week = computeWeekNumber(start, expense.date); const week = computeWeekNumber(start, expense.date);
const amount = expense.bank_code.bank_code === 'G503' ? expense.mileage : expense.amount;
const adjustedAmount = Number(amount) * expense.bank_code.modifier;
rows.push({ rows.push({
timesheet_id: expense.timesheet.id, timesheet_id: expense.timesheet.id,
@ -129,7 +131,7 @@ export class CsvExportService {
code: code, code: code,
quantite_hre: undefined, quantite_hre: undefined,
taux_horaire: undefined, taux_horaire: undefined,
montant: Number(expense.amount), montant: adjustedAmount,
semaine_no: week, semaine_no: week,
division_no: undefined, division_no: undefined,
service_no: undefined, service_no: undefined,

View File

@ -2,4 +2,5 @@ export class PayPeriodEvent {
employee_email: string; employee_email: string;
event_type: 'expense' | 'shift' | 'preset'; event_type: 'expense' | 'shift' | 'preset';
action: 'create' | 'update' | 'delete'; action: 'create' | 'update' | 'delete';
date: string;
} }

View File

@ -87,6 +87,7 @@ export class SchedulePresetsApplyService {
employee_email: user_email, employee_email: user_email,
event_type: 'preset', event_type: 'preset',
action: 'create', action: 'create',
date: created_shifts[0].date
}) })
} }
@ -142,6 +143,7 @@ export class SchedulePresetsApplyService {
employee_email: user_email, employee_email: user_email,
event_type: 'preset', event_type: 'preset',
action: 'create', action: 'create',
date
}) })
} }

View File

@ -65,7 +65,8 @@ export class ShiftsCreateService {
this.payPeriodEventService.emit({ this.payPeriodEventService.emit({
employee_email: email, employee_email: email,
event_type: 'shift', event_type: 'shift',
action: 'create' action: 'create',
date: shifts[0].date,
}) })
} }

View File

@ -4,6 +4,7 @@ import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { PrismaPostgresService } from "prisma/postgres/prisma-postgres.service"; import { PrismaPostgresService } from "prisma/postgres/prisma-postgres.service";
import { PaidTimeOffBankHoursService } from "src/time-and-attendance/paid-time-off/paid-time-off.service"; import { PaidTimeOffBankHoursService } from "src/time-and-attendance/paid-time-off/paid-time-off.service";
import { PayPeriodEventService } from "src/time-and-attendance/pay-period/services/pay-period-event.service"; import { PayPeriodEventService } from "src/time-and-attendance/pay-period/services/pay-period-event.service";
import { toStringFromDate } from "src/common/utils/date-utils";
@Injectable() @Injectable()
export class ShiftsDeleteService { export class ShiftsDeleteService {
@ -72,7 +73,8 @@ export class ShiftsDeleteService {
this.payPeriodEventService.emit({ this.payPeriodEventService.emit({
employee_email: email, employee_email: email,
event_type: 'shift', event_type: 'shift',
action: 'delete' action: 'delete',
date: toStringFromDate(shift.date),
}) })
} }

View File

@ -63,7 +63,8 @@ export class ShiftsUpdateService {
this.payPeriodEventService.emit({ this.payPeriodEventService.emit({
employee_email: email, employee_email: email,
event_type: 'shift', event_type: 'shift',
action: 'update' action: 'update',
date: shifts[0].date
}) })
} }

View File

@ -106,7 +106,8 @@ export const select_csv_shift_lines = {
export const select_csv_expense_lines = { export const select_csv_expense_lines = {
date: true, date: true,
amount: true, amount: true,
bank_code: { select: { bank_code: true } }, mileage: true,
bank_code: { select: { bank_code: true, modifier: true } },
timesheet: { timesheet: {
select: { select: {
id: true, id: true,