From 52114deb337265c474830ca3b38625daa4167ff4 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 30 Sep 2025 10:43:48 -0400 Subject: [PATCH] feat(expenses): upsert function for expenses --- prisma/schema.prisma | 12 +- src/app.module.ts | 30 +-- .../controllers/expenses.controller.ts | 29 ++- .../expenses/dtos/create-expense.dto.ts | 2 +- .../expenses/dtos/upsert-expense.dto.ts | 42 ++++ src/modules/expenses/expenses.module.ts | 11 +- src/modules/expenses/repos/bank-codes.repo.ts | 34 +++ src/modules/expenses/repos/employee.repo.ts | 32 +++ src/modules/expenses/repos/timesheets.repo.ts | 42 ++++ .../services/expenses-command.service.ts | 216 +++++++++++++++++- .../services/expenses-query.service.ts | 2 +- .../expenses.types.interfaces.ts | 14 ++ src/modules/expenses/utils/expenses.utils.ts | 65 ++++++ .../shifts/controllers/shifts.controller.ts | 2 +- src/modules/shifts/dtos/upsert-shift.dto.ts | 2 +- .../shifts/services/shifts-command.service.ts | 2 +- .../services/timesheets-query.service.ts | 28 +-- 17 files changed, 512 insertions(+), 53 deletions(-) create mode 100644 src/modules/expenses/dtos/upsert-expense.dto.ts create mode 100644 src/modules/expenses/repos/bank-codes.repo.ts create mode 100644 src/modules/expenses/repos/employee.repo.ts create mode 100644 src/modules/expenses/repos/timesheets.repo.ts create mode 100644 src/modules/expenses/types and interfaces/expenses.types.interfaces.ts create mode 100644 src/modules/expenses/utils/expenses.utils.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d2c344c..9e56a04 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -225,9 +225,10 @@ model Expenses { bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id]) bank_code_id Int date DateTime @db.Date - amount Decimal @db.Money - attachement String? - comment String? + amount Decimal @db.Money + mileage Decimal? + attachment String? + comment String is_approved Boolean @default(false) supervisor_comment String? @@ -244,8 +245,9 @@ model ExpensesArchive { archived_at DateTime @default(now()) bank_code_id Int date DateTime @db.Date - amount Decimal @db.Money - attachement String? + amount Decimal? @db.Money + mileage Decimal? + attachment String? comment String? is_approved Boolean supervisor_comment String? diff --git a/src/app.module.ts b/src/app.module.ts index ebd5c5d..407535a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,30 +1,30 @@ import { BadRequestException, Module, ValidationPipe } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; import { ArchivalModule } from './modules/archival/archival.module'; import { AuthenticationModule } from './modules/authentication/auth.module'; -import { BankCodesModule } from './modules/bank-codes/bank-codes.module'; +import { BankCodesModule } from './modules/bank-codes/bank-codes.module'; import { BusinessLogicsModule } from './modules/business-logics/business-logics.module'; // import { CsvExportModule } from './modules/exports/csv-exports.module'; -import { CustomersModule } from './modules/customers/customers.module'; -import { EmployeesModule } from './modules/employees/employees.module'; -import { ExpensesModule } from './modules/expenses/expenses.module'; -import { HealthModule } from './health/health.module'; +import { CustomersModule } from './modules/customers/customers.module'; +import { EmployeesModule } from './modules/employees/employees.module'; +import { ExpensesModule } from './modules/expenses/expenses.module'; +import { HealthModule } from './health/health.module'; import { HealthController } from './health/health.controller'; import { LeaveRequestsModule } from './modules/leave-requests/leave-requests.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module'; -import { OvertimeService } from './modules/business-logics/services/overtime.service'; +import { OvertimeService } from './modules/business-logics/services/overtime.service'; import { PayperiodsModule } from './modules/pay-periods/pay-periods.module'; -import { PrismaModule } from './prisma/prisma.module'; -import { ScheduleModule } from '@nestjs/schedule'; -import { ShiftsModule } from './modules/shifts/shifts.module'; +import { PrismaModule } from './prisma/prisma.module'; +import { ScheduleModule } from '@nestjs/schedule'; +import { ShiftsModule } from './modules/shifts/shifts.module'; import { TimesheetsModule } from './modules/timesheets/timesheets.module'; -import { UsersModule } from './modules/users-management/users.module'; -import { ConfigModule } from '@nestjs/config'; +import { UsersModule } from './modules/users-management/users.module'; +import { ConfigModule } from '@nestjs/config'; import { APP_FILTER, APP_PIPE } from '@nestjs/core'; -import { HttpExceptionFilter } from './common/filters/http-exception.filter'; -import { ValidationError } from 'class-validator'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import { ValidationError } from 'class-validator'; @Module({ imports: [ diff --git a/src/modules/expenses/controllers/expenses.controller.ts b/src/modules/expenses/controllers/expenses.controller.ts index f4f20cb..0309397 100644 --- a/src/modules/expenses/controllers/expenses.controller.ts +++ b/src/modules/expenses/controllers/expenses.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; import { ExpensesQueryService } from "../services/expenses-query.service"; import { CreateExpenseDto } from "../dtos/create-expense.dto"; import { Expenses } from "@prisma/client"; @@ -8,6 +8,8 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagg import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { ExpensesCommandService } from "../services/expenses-command.service"; import { SearchExpensesDto } from "../dtos/search-expense.dto"; +import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; +import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces"; @ApiTags('Expenses') @ApiBearerAuth('access-token') @@ -15,17 +17,26 @@ import { SearchExpensesDto } from "../dtos/search-expense.dto"; @Controller('Expenses') export class ExpensesController { constructor( - private readonly expensesService: ExpensesQueryService, - private readonly expensesApprovalService: ExpensesCommandService, + private readonly query: ExpensesQueryService, + private readonly command: ExpensesCommandService, ) {} + @Put('upsert/:email/:date') + async upsert_by_date( + @Param('email') email: string, + @Param('date') date: string, + @Body() dto: UpsertExpenseDto, + ): Promise { + return this.command.upsertExpensesByDate(email, date, dto); + } + @Post() //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) @ApiOperation({ summary: 'Create expense' }) @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto }) @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) create(@Body() dto: CreateExpenseDto): Promise { - return this.expensesService.create(dto); + return this.query.create(dto); } @Get() @@ -35,7 +46,7 @@ export class ExpensesController { @ApiResponse({ status: 400, description: 'List of expenses not found' }) @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) findAll(@Query() filters: SearchExpensesDto): Promise { - return this.expensesService.findAll(filters); + return this.query.findAll(filters); } @Get(':id') @@ -44,7 +55,7 @@ export class ExpensesController { @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto }) @ApiResponse({ status: 400, description: 'Expense not found' }) findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.expensesService.findOne(id); + return this.query.findOne(id); } @Patch(':id') @@ -53,7 +64,7 @@ export class ExpensesController { @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto }) @ApiResponse({ status: 400, description: 'Expense not found' }) update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) { - return this.expensesService.update(id,dto); + return this.query.update(id,dto); } @Delete(':id') @@ -62,13 +73,13 @@ export class ExpensesController { @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto }) @ApiResponse({ status: 400, description: 'Expense not found' }) remove(@Param('id', ParseIntPipe) id: number): Promise { - return this.expensesService.remove(id); + return this.query.remove(id); } @Patch('approval/:id') //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { - return this.expensesApprovalService.updateApproval(id, isApproved); + return this.command.updateApproval(id, isApproved); } } \ No newline at end of file diff --git a/src/modules/expenses/dtos/create-expense.dto.ts b/src/modules/expenses/dtos/create-expense.dto.ts index b840a14..d0e4863 100644 --- a/src/modules/expenses/dtos/create-expense.dto.ts +++ b/src/modules/expenses/dtos/create-expense.dto.ts @@ -46,7 +46,7 @@ export class CreateExpenseDto { description:'explain`s why the expense was made' }) @IsString() - comment?: string; + comment: string; @ApiProperty({ example: 'DENIED, APPROUVED, PENDING, etc...', diff --git a/src/modules/expenses/dtos/upsert-expense.dto.ts b/src/modules/expenses/dtos/upsert-expense.dto.ts new file mode 100644 index 0000000..f7975d4 --- /dev/null +++ b/src/modules/expenses/dtos/upsert-expense.dto.ts @@ -0,0 +1,42 @@ +import { Transform, Type } from "class-transformer"; +import { IsDefined, IsNumber, IsOptional, IsString, maxLength, MaxLength, Min, ValidateIf, ValidateNested } from "class-validator"; + +export class ExpensePayloadDto { + @IsString() + type!: string; + + @ValidateIf(o => (o.type ?? '').toUpperCase() !== 'MILEAGE') + @IsDefined() + @IsNumber() + @Min(0) + amount!: number; + + @ValidateIf(o => (o.type ?? '').toUpperCase() === 'MILEAGE') + @IsDefined() + @IsNumber() + @Min(0) + mileage!: number; + + @IsString() + @MaxLength(280) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + comment!: string; + + @IsOptional() + @IsString() + @MaxLength(255) + attachment?: string; +} + + +export class UpsertExpenseDto { + @IsOptional() + @ValidateNested() + @Type(()=> ExpensePayloadDto) + old_expense?: ExpensePayloadDto; + + @IsOptional() + @ValidateNested() + @Type(()=> ExpensePayloadDto) + new_expense?: ExpensePayloadDto; +} \ No newline at end of file diff --git a/src/modules/expenses/expenses.module.ts b/src/modules/expenses/expenses.module.ts index 04c3965..2cbd302 100644 --- a/src/modules/expenses/expenses.module.ts +++ b/src/modules/expenses/expenses.module.ts @@ -3,11 +3,20 @@ import { Module } from "@nestjs/common"; import { ExpensesQueryService } from "./services/expenses-query.service"; import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; import { ExpensesCommandService } from "./services/expenses-command.service"; +import { BankCodesRepo } from "./repos/bank-codes.repo"; +import { TimesheetsRepo } from "./repos/timesheets.repo"; +import { EmployeesRepo } from "./repos/employee.repo"; @Module({ imports: [BusinessLogicsModule], controllers: [ExpensesController], - providers: [ExpensesQueryService, ExpensesCommandService], + providers: [ + ExpensesQueryService, + ExpensesCommandService, + BankCodesRepo, + TimesheetsRepo, + EmployeesRepo, + ], exports: [ ExpensesQueryService ], }) diff --git a/src/modules/expenses/repos/bank-codes.repo.ts b/src/modules/expenses/repos/bank-codes.repo.ts new file mode 100644 index 0000000..1de277d --- /dev/null +++ b/src/modules/expenses/repos/bank-codes.repo.ts @@ -0,0 +1,34 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + + +type Tx = Prisma.TransactionClient | PrismaClient; + +@Injectable() +export class BankCodesRepo { + constructor(private readonly prisma: PrismaService) {} + + //find id and modifier by type + readonly findByType = async ( type: string, client?: Tx + ): Promise<{id:number; modifier: number }> => { + const db = client ?? this.prisma; + const bank = await db.bankCodes.findFirst({ + where: { + type, + }, + select: { + id: true, + modifier: true, + }, + }); + + if(!bank) { + throw new NotFoundException(`Unknown bank code type: ${type}`); + } + return { + id: bank.id, + modifier: bank.modifier, + }; + }; +} \ No newline at end of file diff --git a/src/modules/expenses/repos/employee.repo.ts b/src/modules/expenses/repos/employee.repo.ts new file mode 100644 index 0000000..aeefe53 --- /dev/null +++ b/src/modules/expenses/repos/employee.repo.ts @@ -0,0 +1,32 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + + +type Tx = Prisma.TransactionClient | PrismaClient; + +@Injectable() +export class EmployeesRepo { + constructor(private readonly prisma: PrismaService) {} + + // find employee id by email + readonly findIdByEmail = async ( email: string, client?: Tx + ): Promise => { + const db = client ?? this.prisma; + const employee = await db.employees.findFirst({ + where: { + user: { + email, + }, + }, + select: { + id: true, + }, + }); + + if(!employee) { + throw new NotFoundException(`Employee with email: ${email} not found`); + } + return employee.id; + } +} \ No newline at end of file diff --git a/src/modules/expenses/repos/timesheets.repo.ts b/src/modules/expenses/repos/timesheets.repo.ts new file mode 100644 index 0000000..e140402 --- /dev/null +++ b/src/modules/expenses/repos/timesheets.repo.ts @@ -0,0 +1,42 @@ +import { Injectable } from "@nestjs/common"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { weekStartMondayUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; +import { PrismaService } from "src/prisma/prisma.service"; + + +type Tx = Prisma.TransactionClient | PrismaClient; + +@Injectable() +export class TimesheetsRepo { + constructor(private readonly prisma: PrismaService) {} + + //find an existing timesheet linked to the employee + readonly ensureForDate = async (employee_id: number, date: Date, client?: Tx, + ): Promise<{id: number; start_date: Date }> => { + const db = client ?? this.prisma; + const startOfWeek = weekStartMondayUTC(date); + const existing = await db.timesheets.findFirst({ + where: { + employee_id: employee_id, + start_date: startOfWeek, + }, + select: { + id: true, + start_date: true, + }, + }); + if(existing) return existing; + + const created = await db.timesheets.create({ + data: { + employee_id: employee_id, + start_date: startOfWeek, + }, + select: { + id: true, + start_date: true, + }, + }); + return created; + } +} \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 7a8f722..41fd316 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -1,13 +1,23 @@ -import { Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { Expenses, Prisma } from "@prisma/client"; -import { Decimal } from "@prisma/client/runtime/library"; -import { transcode } from "buffer"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; 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 { assertAndTrimComment, computeMileageAmount, mapDbExpenseToDayResponse, normalizeType as normalizeTypeUtil } from "../utils/expenses.utils"; +import { DayExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; +import { toDateOnlyUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; @Injectable() export class ExpensesCommandService extends BaseApprovalService { - constructor(prisma: PrismaService) { super(prisma); } + constructor( + prisma: PrismaService, + private readonly bankCodesRepo: BankCodesRepo, + private readonly timesheetsRepo: TimesheetsRepo, + private readonly employeesRepo: EmployeesRepo, + ) { super(prisma); } protected get delegate() { return this.prisma.expenses; @@ -22,4 +32,202 @@ export class ExpensesCommandService extends BaseApprovalService { this.updateApprovalWithTransaction(transaction, id, isApproved), ); } + + //-------------------- Master CRUD function -------------------- + readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto, + ): Promise<{ action:UpsertAction; day: DayExpenseResponse[] }> => { + + //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 dateOnly = toDateOnlyUTC(date); + if(Number.isNaN(dateOnly.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, dateOnly); + + return this.prisma.$transaction(async (tx) => { + const loadDay = async (): Promise => { + const rows = await tx.expenses.findMany({ + where: { + timesheet_id: timesheet_id, + date: dateOnly, + }, + include: { + bank_code: { + select: { + type: true, + }, + }, + }, + orderBy: [{ date: 'asc' }, { id: 'asc' }], + }); + + return rows.map(this.mapDbToDayResponse); + }; + + const normalizePayload = async (payload: { + type: string; + amount?: number; + mileage?: number; + comment: string; + attachment?: string; + }): Promise<{ + type: string; + bank_code_id: number; + amount: Prisma.Decimal; + comment: string; + attachment: string | null; + }> => { + const type = this.normalizeType(payload.type); + const comment = this.assertAndTrimComment(payload.comment); + const attachment = payload.attachment?.trim()?.length ? payload.attachment.trim() : null; + const { id: bank_code_id, modifier } = await this.lookupBankCodeOrThrow(tx, type); + const amount = this.computeAmountDecimal(type, payload, modifier); + + return { + type, + bank_code_id, + amount, + comment, + attachment + }; + }; + + const findExactOld = async (norm: { + bank_code_id: number; + amount: Prisma.Decimal; + comment: string; + attachment: string | null; + }) => { + return tx.expenses.findFirst({ + where: { + timesheet_id: timesheet_id, + date: dateOnly, + bank_code_id: norm.bank_code_id, + amount: norm.amount, + comment: norm.comment, + attachment: norm.attachment, + }, + 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 = 'deleted'; + } + //-------------------- CREATE -------------------- + else if (!old_expense && new_expense) { + const new_exp = await normalizePayload(new_expense); + await tx.expenses.create({ + data: { + timesheet_id: timesheet_id, + date: dateOnly, + bank_code_id: new_exp.bank_code_id, + amount: new_exp.amount, + mileage: null, + comment: new_exp.comment, + attachment: new_exp.attachment, + is_approved: false, + }, + }); + action = 'created'; + } + + 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: null, + comment: new_exp.comment, + attachment: new_exp.attachment, + }, + }); + action = 'updated'; + } + else { + throw new BadRequestException('Invalid upsert combination'); + } + + const day = await loadDay(); + + return { action, day }; + }); + } + + + //helpers imported from utils and repos. + private readonly normalizeType = (type: string): string => + normalizeTypeUtil(type); + + private readonly assertAndTrimComment = (comment: string): string => + assertAndTrimComment(comment); + + 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; + comment: string; + is_approved: boolean; + bank_code: { type: string } | null; + }): DayExpenseResponse => mapDbExpenseToDayResponse(row); + + } \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index 42c9679..b719a79 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -121,7 +121,7 @@ export class ExpensesQueryService { bank_code_id: exp.bank_code_id, date: exp.date, amount: exp.amount, - attachement: exp.attachement, + attachment: exp.attachment, comment: exp.comment, is_approved: exp.is_approved, supervisor_comment: exp.supervisor_comment, diff --git a/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts b/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts new file mode 100644 index 0000000..e567070 --- /dev/null +++ b/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts @@ -0,0 +1,14 @@ +export type UpsertAction = 'created' | 'updated' | 'deleted'; + +export interface DayExpenseResponse { + date: string; + type: string; + amount: number; + comment: string; + is_approved: boolean; +}; + +export type UpsertExpenseResult = { + action: UpsertAction; + day: DayExpenseResponse[] +}; \ No newline at end of file diff --git a/src/modules/expenses/utils/expenses.utils.ts b/src/modules/expenses/utils/expenses.utils.ts new file mode 100644 index 0000000..9dd2497 --- /dev/null +++ b/src/modules/expenses/utils/expenses.utils.ts @@ -0,0 +1,65 @@ +import { BadRequestException } from "@nestjs/common"; +import { DayExpenseResponse } from "../types and interfaces/expenses.types.interfaces"; + +//uppercase and trim for validation +export function normalizeType(type: string): string { + return (type ?? '').trim().toUpperCase(); +}; + +//required comment after trim +export function assertAndTrimComment(comment: string): string { + const cmt = (comment ?? '').trim(); + if(cmt.length === 0) { + throw new BadRequestException('A comment is required'); + } + return cmt; +}; + +//rounding $ to 2 decimals +export function roundMoney2(num: number): number { + return Math.round((num + Number.EPSILON) * 100)/ 100; +}; + +export function computeMileageAmount(km: number, modifier: number): number { + if(km < 0) throw new BadRequestException('mileage must be positive'); + if(modifier < 0) throw new BadRequestException('modifier must be positive'); + return roundMoney2(km * modifier); +}; + +//compat. types with Prisma.Decimal. work around Prisma import in utils. +export type DecimalLike = + | number + | string + | { toNumber?: () => number } + | { toString?: () => string }; + + +//safe conversion to number +export function toNumberSafe(value: DecimalLike): number { + if(typeof value === 'number') return value; + if(value && typeof (value as any).toNumber === 'function') return (value as any).toNumber(); + return Number( + typeof (value as any)?.toString === 'function' + ? (value as any).toString() + : value, + ); +} + + +//map of a row for DayExpenseResponse +export function mapDbExpenseToDayResponse(row: { + date: Date; + amount: DecimalLike; + comment: string; + is_approved: boolean; + bank_code?: { type?: string | null } | null; +}): DayExpenseResponse { + const yyyyMmDd = row.date.toISOString().slice(0,10); + return { + date: yyyyMmDd, + type: normalizeType(row.bank_code?.type ?? 'UNKNOWN'), + amount: toNumberSafe(row.amount), + comment: row.comment, + is_approved: row.is_approved, + }; +} \ No newline at end of file diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index b323988..89af236 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -28,7 +28,7 @@ export class ShiftsController { @Param('date') date_param: string, @Body() payload: UpsertShiftDto, ) { - return this.shiftsCommandService.upsertShfitsByDate(email_param, date_param, payload); + return this.shiftsCommandService.upsertShiftsByDate(email_param, date_param, payload); } @Post() diff --git a/src/modules/shifts/dtos/upsert-shift.dto.ts b/src/modules/shifts/dtos/upsert-shift.dto.ts index 83900dd..b82fbb5 100644 --- a/src/modules/shifts/dtos/upsert-shift.dto.ts +++ b/src/modules/shifts/dtos/upsert-shift.dto.ts @@ -1,7 +1,7 @@ import { Type } from "class-transformer"; import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator"; -export const COMMENT_MAX_LENGTH = 512; +export const COMMENT_MAX_LENGTH = 280; export class ShiftPayloadDto { diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index e0c55af..cccfbcc 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -20,7 +20,7 @@ export class ShiftsCommandService extends BaseApprovalService { constructor(prisma: PrismaService) { super(prisma); } //create/update/delete master method -async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto): +async upsertShiftsByDate(email:string, date_string: string, dto: UpsertShiftDto): Promise<{ action: UpsertAction; day: DayShiftResponse[] }> { const { old_shift, new_shift } = dto; diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index f4517e2..11961c9 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -1,12 +1,12 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { PrismaService } from 'src/prisma/prisma.service'; import { Timesheets, TimesheetsArchive } from '@prisma/client'; -import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; -import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; -import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers'; +import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; +import { TimesheetDto } from '../dtos/overview-timesheet.dto'; +import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers'; -import { TimesheetDto } from '../dtos/overview-timesheet.dto'; @Injectable() @@ -161,14 +161,14 @@ export class TimesheetsQueryService { const to_HH_mm = (date: Date) => date.toISOString().slice(11, 16); //maps all shifts of selected timesheet - const shifts = timesheet.shift.map((sft) => ({ - bank_type: sft.bank_code?.type ?? '', - date: formatDateISO(sft.date), - start_time: to_HH_mm(sft.start_time), - end_time: to_HH_mm(sft.end_time), - comment: sft.comment ?? '', - is_approved: sft.is_approved ?? false, - is_remote: sft.is_remote ?? false, + const shifts = timesheet.shift.map((shift_row) => ({ + bank_type: shift_row.bank_code?.type ?? '', + date: formatDateISO(shift_row.date), + start_time: to_HH_mm(shift_row.start_time), + end_time: to_HH_mm(shift_row.end_time), + comment: shift_row.comment ?? '', + is_approved: shift_row.is_approved ?? false, + is_remote: shift_row.is_remote ?? false, })); //maps all expenses of selected timsheet