diff --git a/src/modules/expenses/dtos/upsert-expense.dto.ts b/src/modules/expenses/dtos/upsert-expense.dto.ts index f7975d4..c79ffd9 100644 --- a/src/modules/expenses/dtos/upsert-expense.dto.ts +++ b/src/modules/expenses/dtos/upsert-expense.dto.ts @@ -1,21 +1,19 @@ import { Transform, Type } from "class-transformer"; -import { IsDefined, IsNumber, IsOptional, IsString, maxLength, MaxLength, Min, ValidateIf, ValidateNested } from "class-validator"; +import { IsNumber, IsOptional, IsString, 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; + amount?: number; @ValidateIf(o => (o.type ?? '').toUpperCase() === 'MILEAGE') - @IsDefined() @IsNumber() @Min(0) - mileage!: number; + mileage?: number; @IsString() @MaxLength(280) diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 973171b..dea5bea 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -72,32 +72,60 @@ export class ExpensesCommandService extends BaseApprovalService { orderBy: [{ date: 'asc' }, { id: 'asc' }], }); - return rows.map(this.mapDbToDayResponse); + 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; + type: string; + amount?: number; + mileage?: number; + comment: string; attachment?: string; }): Promise<{ - type: string; + type: string; bank_code_id: number; - amount: Prisma.Decimal; - comment: string; - attachment: string | null; + amount: Prisma.Decimal; + mileage: number | null; + comment: string; + attachment: string | null; }> => { - const type = this.normalizeType(payload.type); - const comment = this.assertAndTrimComment(payload.comment); + 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); + 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); + } return { type, bank_code_id, - amount, + amount, + mileage, comment, attachment }; @@ -105,18 +133,20 @@ export class ExpensesCommandService extends BaseApprovalService { const findExactOld = async (norm: { bank_code_id: number; - amount: Prisma.Decimal; - comment: string; - attachment: string | null; + amount: Prisma.Decimal; + mileage: number | null; + comment: string; + attachment: string | null; }) => { return tx.expenses.findFirst({ where: { timesheet_id: timesheet_id, - date: dateOnly, + date: dateOnly, bank_code_id: norm.bank_code_id, - amount: norm.amount, - comment: norm.comment, - attachment: norm.attachment, + amount: norm.amount, + comment: norm.comment, + attachment: norm.attachment, + ...(norm.mileage !== null ? { mileage: norm.mileage } : { mileage: null }), }, select: { id: true }, }); @@ -136,24 +166,24 @@ export class ExpensesCommandService extends BaseApprovalService { await tx.expenses.delete({where: { id: existing.id } }); action = 'deleted'; } - //-------------------- CREATE -------------------- + //-------------------- 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, + 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, + amount: new_exp.amount, + mileage: new_exp.mileage, + comment: new_exp.comment, + attachment: new_exp.attachment, + is_approved: false, }, }); action = 'created'; } - + //-------------------- UPDATE -------------------- else if(old_expense && new_expense) { const oldNorm = await normalizePayload(old_expense); const existing = await findExactOld(oldNorm); @@ -169,10 +199,10 @@ export class ExpensesCommandService extends BaseApprovalService { 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, + amount: new_exp.amount, + mileage: new_exp.mileage, + comment: new_exp.comment, + attachment: new_exp.attachment, }, }); action = 'updated'; @@ -188,7 +218,7 @@ export class ExpensesCommandService extends BaseApprovalService { } - //helpers imported from utils and repos. + //-------------------- helpers -------------------- private readonly normalizeType = (type: string): string => normalizeTypeUtil(type); @@ -210,7 +240,10 @@ export class ExpensesCommandService extends BaseApprovalService { private readonly computeAmountDecimal = ( type: string, - payload: { amount?: number; mileage?: number;}, + payload: { + amount?: number; + mileage?: number; + }, modifier: number, ): Prisma.Decimal => { if(type === 'MILEAGE') { @@ -222,11 +255,12 @@ export class ExpensesCommandService extends BaseApprovalService { }; private readonly mapDbToDayResponse = (row: { - date: Date; - amount: Prisma.Decimal | number | string; - comment: string; + date: Date; + amount: Prisma.Decimal | number | string; + mileage: Prisma.Decimal | number | string; + comment: string; is_approved: boolean; - bank_code: { type: string } | null; + bank_code: { type: string } | null; }): DayExpenseResponse => mapDbExpenseToDayResponse(row); diff --git a/src/modules/expenses/utils/expenses.utils.ts b/src/modules/expenses/utils/expenses.utils.ts index 9dd2497..edd2c4a 100644 --- a/src/modules/expenses/utils/expenses.utils.ts +++ b/src/modules/expenses/utils/expenses.utils.ts @@ -1,5 +1,6 @@ import { BadRequestException } from "@nestjs/common"; import { DayExpenseResponse } from "../types and interfaces/expenses.types.interfaces"; +import { Prisma } from "@prisma/client"; //uppercase and trim for validation export function normalizeType(type: string): string { @@ -48,18 +49,21 @@ export function toNumberSafe(value: DecimalLike): number { //map of a row for DayExpenseResponse export function mapDbExpenseToDayResponse(row: { - date: Date; - amount: DecimalLike; - comment: string; + date: Date; + amount: Prisma.Decimal | number | string | null; + mileage?: Prisma.Decimal | number | string | null; + comment: string; is_approved: boolean; - bank_code?: { type?: string | null } | null; + bank_code?: { type?: string | null } | null; }): DayExpenseResponse { const yyyyMmDd = row.date.toISOString().slice(0,10); + const toNum = (value: any)=> (value == null ? 0 : Number(value)); return { - date: yyyyMmDd, - type: normalizeType(row.bank_code?.type ?? 'UNKNOWN'), - amount: toNumberSafe(row.amount), - comment: row.comment, + date: yyyyMmDd, + type: normalizeType(row.bank_code?.type ?? 'UNKNOWN'), + amount: toNum(row.amount), + comment: row.comment, is_approved: row.is_approved, + ...(row.mileage !== null ? { mileage: toNum(row.mileage) }: {}), }; } \ No newline at end of file