313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
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<Expenses> {
|
|
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<Expenses> {
|
|
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<ExpenseResponse[]> => {
|
|
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<number> =>
|
|
this.employeesRepo.findIdByEmail(email);
|
|
|
|
private readonly ensureTimesheetForDate = async ( employee_id: number, date: Date
|
|
): Promise<number> => {
|
|
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);
|
|
|
|
|
|
} |