Commit 180233b3 authored by ThinhNC's avatar ThinhNC

feat(budgets): add budget management and usage tracking

parent a90e14ed
......@@ -19,6 +19,10 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
update và delete giao dịch cập nhật Wallet trong Prisma transaction mức Serializable.
- Hóa đơn Transaction được lưu cục bộ dưới `storage/receipts`, chỉ đọc qua API có auth;
hỗ trợ JPEG, PNG, WebP, PDF và giới hạn mặc định 5 MB.
- Budget hỗ trợ phạm vi tổng (`OVERALL`) hoặc danh mục chi (`CATEGORY`), chu kỳ
`CUSTOM`, `WEEKLY`, `MONTHLY`, `YEARLY` và archive để giữ lịch sử. Mức sử dụng,
phần trăm cùng cảnh báo ngưỡng được tổng hợp trực tiếp từ Transaction `EXPENSE`
trong khoảng thời gian `[startDate, endDate)` khi đọc API.
## Trạng thái đã biết
......@@ -27,14 +31,15 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
lint là verification khả dụng cho tới khi config được bổ sung.
- `env.config.ts` dùng port fallback `8888`, còn `.env.example` dùng `7777`; README
ghi rõ cả hai và dùng `7777` cho hướng dẫn chạy theo file env mẫu.
- Budget chưa được expose qua route. Wallet, Category và Transaction đã có API theo
ownership; Category đồng thời trả các category hệ thống dùng chung.
- Wallet, Category, Transaction và Budget đã có API theo ownership; Category đồng
thời trả các category hệ thống dùng chung.
- Các migration `20260728170000_improve_wallet_management`
`20260728190000_add_category_management` đồng bộ thay đổi của Wallet và Category;
`20260728210000_add_transaction_management` đồng bộ Decimal, receipt/location và index
của Transaction.
Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của auth và
Budget đã được commit trước đó.
của Transaction; `20260729100000_add_budget_management` đồng bộ Decimal, loại,
chu kỳ, ngưỡng cảnh báo, archive và index của Budget.
Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của auth đã
được commit trước đó.
## Khi cập nhật file này
......
......@@ -56,9 +56,9 @@ Express router
## Phạm vi hiện tại
- Route hoạt động: health, auth và users.
- Prisma schema đã có thêm Wallet, Category, Transaction và Budget, nhưng chưa có
module API tương ứng trong `src/modules/`.
- Route hoạt động: health, auth, users, wallets, categories, transactions và budgets.
- Wallet, Category, Transaction và Budget có module API theo ownership trong
`src/modules/`.
- Email verification, password reset, cảnh báo thiết bị và quản lý session nằm
trong module auth.
......@@ -68,4 +68,3 @@ Express router
- `node_modules/`
- Prisma Client được generate
- Migration cũ đã được dùng ở môi trường chia sẻ
......@@ -91,7 +91,7 @@ pnpm run db:seed
```
> Migration history hiện đã bao phủ schema khởi tạo và các thay đổi của Wallet,
> Category, Transaction, nhưng chưa phản ánh đầy đủ một số thay đổi Auth và Budget
> Category, Transaction, Budget, nhưng chưa phản ánh đầy đủ một số thay đổi Auth
> đã có trong `schema.prisma`. Khi dựng database hoàn toàn mới, cần bổ sung migration
> còn thiếu trước khi coi schema đã đồng bộ; không dùng `db push` thay cho migration
> ở môi trường chia sẻ/production.
......@@ -219,7 +219,18 @@ Toàn bộ nhóm này yêu cầu access token và chỉ thao tác trên dữ li
| `GET` | `/transactions/:id/receipt` | Xem hoặc tải hóa đơn |
| `DELETE` | `/transactions/:id/receipt` | Xóa hóa đơn |
> Model `Budget` đã có trong Prisma schema nhưng chưa được expose qua API.
### Budgets
Toàn bộ nhóm này yêu cầu access token và chỉ thao tác trên ngân sách của chính người dùng. Dữ liệu sử dụng được tổng hợp từ giao dịch chi tiêu theo thời gian thực.
| Method | Endpoint | Mô tả |
| -------- | ---------------------- | ------------------------------------------------------------ |
| `GET` | `/budgets` | Danh sách ngân sách có filter, pagination và trạng thái dùng |
| `POST` | `/budgets` | Tạo ngân sách tổng hoặc theo danh mục |
| `GET` | `/budgets/:id` | Chi tiết và mức sử dụng ngân sách |
| `PUT` | `/budgets/:id` | Cập nhật ngân sách |
| `PATCH` | `/budgets/:id/restore` | Khôi phục ngân sách đã archive |
| `DELETE` | `/budgets/:id` | Archive ngân sách để giữ lịch sử |
## Dữ liệu seed
......
-- Add explicit budget scopes, cycle metadata, alert configuration, and archival.
CREATE TYPE "BudgetType" AS ENUM ('OVERALL', 'CATEGORY');
CREATE TYPE "BudgetPeriod" AS ENUM ('CUSTOM', 'WEEKLY', 'MONTHLY', 'YEARLY');
ALTER TABLE "budgets"
ALTER COLUMN "amount" SET DATA TYPE DECIMAL(18, 2)
USING "amount"::DECIMAL(18, 2),
ALTER COLUMN "category_id" DROP NOT NULL,
ADD COLUMN "type" "BudgetType" NOT NULL DEFAULT 'CATEGORY',
ADD COLUMN "period" "BudgetPeriod" NOT NULL DEFAULT 'CUSTOM',
ADD COLUMN "alert_threshold" DECIMAL(5, 2) NOT NULL DEFAULT 80,
ADD COLUMN "is_archived" BOOLEAN NOT NULL DEFAULT false;
-- Keep historical budgets when a category is referenced.
ALTER TABLE "budgets"
DROP CONSTRAINT "budgets_category_id_fkey",
ADD CONSTRAINT "budgets_category_id_fkey"
FOREIGN KEY ("category_id") REFERENCES "categories"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "budgets"
ADD CONSTRAINT "budgets_amount_positive_check"
CHECK ("amount" > 0),
ADD CONSTRAINT "budgets_date_range_check"
CHECK ("end_date" > "start_date"),
ADD CONSTRAINT "budgets_alert_threshold_check"
CHECK ("alert_threshold" > 0 AND "alert_threshold" <= 100),
ADD CONSTRAINT "budgets_scope_category_check"
CHECK (
("type" = 'OVERALL' AND "category_id" IS NULL)
OR ("type" = 'CATEGORY' AND "category_id" IS NOT NULL)
);
CREATE INDEX "budgets_category_id_idx"
ON "budgets"("category_id");
CREATE INDEX "budgets_user_id_is_archived_idx"
ON "budgets"("user_id", "is_archived");
CREATE INDEX "budgets_user_id_start_date_end_date_idx"
ON "budgets"("user_id", "start_date", "end_date");
CREATE INDEX "budgets_user_id_type_period_idx"
ON "budgets"("user_id", "type", "period");
......@@ -12,6 +12,18 @@ enum TransactionType {
EXPENSE
}
enum BudgetType {
OVERALL
CATEGORY
}
enum BudgetPeriod {
CUSTOM
WEEKLY
MONTHLY
YEARLY
}
model User {
id String @id @default(uuid()) @db.Uuid
email String? @unique
......@@ -130,18 +142,25 @@ model Transaction {
model Budget {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
categoryId String @map("category_id") @db.Uuid
categoryId String? @map("category_id") @db.Uuid
name String
amount Decimal @db.Decimal(18, 2)
type BudgetType @default(CATEGORY)
period BudgetPeriod @default(CUSTOM)
startDate DateTime @map("start_date")
endDate DateTime @map("end_date")
alertThreshold Decimal @default(80) @map("alert_threshold") @db.Decimal(5, 2)
isArchived Boolean @default(false) @map("is_archived")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
category Category? @relation(fields: [categoryId], references: [id], onDelete: Restrict)
@@index([categoryId])
@@index([userId, isArchived])
@@index([userId, startDate, endDate])
@@index([userId, type, period])
@@map("budgets")
}
......
......@@ -17,6 +17,8 @@ export const ERROR_CODE = {
CATEGORY_CYCLE: 'CATEGORY_CYCLE',
CATEGORY_IN_USE: 'CATEGORY_IN_USE',
TRANSACTION_CATEGORY_TYPE_MISMATCH: 'TRANSACTION_CATEGORY_TYPE_MISMATCH',
BUDGET_ARCHIVED: 'BUDGET_ARCHIVED',
BUDGET_CATEGORY_TYPE_INVALID: 'BUDGET_CATEGORY_TYPE_INVALID',
FILE_TYPE_UNSUPPORTED: 'FILE_TYPE_UNSUPPORTED',
FILE_TOO_LARGE: 'FILE_TOO_LARGE',
RECEIPT_NOT_FOUND: 'RECEIPT_NOT_FOUND',
......
This diff is collapsed.
import { NextFunction, Request, Response } from 'express';
import {
BudgetQueryDto,
CreateBudgetDto,
UpdateBudgetDto,
} from './budget.dto';
import { BudgetService } from './budget.service';
export class BudgetController {
private readonly service = new BudgetService();
findAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.findAll(
req.user.id,
req.query as unknown as BudgetQueryDto,
);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
findById = async (req: Request, res: Response, next: NextFunction) => {
try {
const budget = await this.service.findById(req.user.id, req.params.id);
res.json({ success: true, data: budget });
} catch (error) {
next(error);
}
};
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const budget = await this.service.create(
req.user.id,
req.body as CreateBudgetDto,
);
res.status(201).json({ success: true, data: budget });
} catch (error) {
next(error);
}
};
update = async (req: Request, res: Response, next: NextFunction) => {
try {
const budget = await this.service.update(
req.user.id,
req.params.id,
req.body as UpdateBudgetDto,
);
res.json({ success: true, data: budget });
} catch (error) {
next(error);
}
};
archive = async (req: Request, res: Response, next: NextFunction) => {
try {
const budget = await this.service.archive(req.user.id, req.params.id);
res.json({
success: true,
message: 'Budget archived successfully',
data: budget,
});
} catch (error) {
next(error);
}
};
restore = async (req: Request, res: Response, next: NextFunction) => {
try {
const budget = await this.service.restore(req.user.id, req.params.id);
res.json({ success: true, data: budget });
} catch (error) {
next(error);
}
};
}
import { BudgetPeriod, BudgetType, TransactionType } from '@prisma/client';
export type BudgetSortField =
| 'name'
| 'amount'
| 'startDate'
| 'endDate'
| 'createdAt'
| 'updatedAt';
export type SortOrder = 'asc' | 'desc';
export type BudgetTimeStatus = 'UPCOMING' | 'ACTIVE' | 'ENDED';
export type BudgetUsageStatus = 'ON_TRACK' | 'NEAR_LIMIT' | 'EXCEEDED';
export interface BudgetQueryDto {
search?: string;
type?: BudgetType;
period?: BudgetPeriod;
categoryId?: string;
activeAt?: Date;
includeArchived: boolean;
sortBy: BudgetSortField;
order: SortOrder;
page: number;
limit: number;
}
export interface CreateBudgetDto {
name: string;
amount: string;
type: BudgetType;
period: BudgetPeriod;
categoryId?: string | null;
startDate: Date;
endDate?: Date;
alertThreshold: string;
}
export interface UpdateBudgetDto {
name?: string;
amount?: string;
type?: BudgetType;
period?: BudgetPeriod;
categoryId?: string | null;
startDate?: Date;
endDate?: Date;
alertThreshold?: string;
}
export interface PersistBudgetDto {
name: string;
amount: string;
type: BudgetType;
period: BudgetPeriod;
categoryId: string | null;
startDate: Date;
endDate: Date;
alertThreshold: string;
}
export interface BudgetCategoryDto {
id: string;
name: string;
type: TransactionType;
icon: string | null;
color: string | null;
}
export interface BudgetUsageDto {
spentAmount: string;
remainingAmount: string;
usagePercentage: string;
transactionCount: number;
lastTransactionAt: Date | null;
timeStatus: BudgetTimeStatus;
status: BudgetUsageStatus;
}
export interface BudgetResponseDto {
id: string;
name: string;
amount: string;
type: BudgetType;
period: BudgetPeriod;
categoryId: string | null;
startDate: Date;
endDate: Date;
alertThreshold: string;
isArchived: boolean;
createdAt: Date;
updatedAt: Date;
category: BudgetCategoryDto | null;
usage: BudgetUsageDto;
}
import {
Prisma,
TransactionType,
} from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import {
BudgetQueryDto,
PersistBudgetDto,
} from './budget.dto';
const budgetSelect = {
id: true,
name: true,
amount: true,
type: true,
period: true,
categoryId: true,
startDate: true,
endDate: true,
alertThreshold: true,
isArchived: true,
createdAt: true,
updatedAt: true,
category: {
select: {
id: true,
name: true,
type: true,
icon: true,
color: true,
},
},
} satisfies Prisma.BudgetSelect;
export type BudgetRecord = Prisma.BudgetGetPayload<{
select: typeof budgetSelect;
}>;
export interface BudgetSpendingSummary {
amount: Prisma.Decimal;
transactionCount: number;
lastTransactionAt: Date | null;
}
export class BudgetRepository {
async findAll(userId: string, query: BudgetQueryDto) {
const {
search,
type,
period,
categoryId,
activeAt,
includeArchived,
sortBy,
order,
page,
limit,
} = query;
const where: Prisma.BudgetWhereInput = {
userId,
...(type ? { type } : {}),
...(period ? { period } : {}),
...(categoryId ? { categoryId } : {}),
...(activeAt
? {
startDate: { lte: activeAt },
endDate: { gt: activeAt },
}
: {}),
...(includeArchived ? {} : { isArchived: false }),
...(search
? {
OR: [
{ name: { contains: search, mode: Prisma.QueryMode.insensitive } },
{
category: {
name: { contains: search, mode: Prisma.QueryMode.insensitive },
},
},
],
}
: {}),
};
const skip = (page - 1) * limit;
const [budgets, total] = await prisma.$transaction([
prisma.budget.findMany({
where,
select: budgetSelect,
orderBy: [
{ [sortBy]: order },
{ id: order },
],
skip,
take: limit,
}),
prisma.budget.count({ where }),
]);
return {
data: budgets,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
findById(userId: string, id: string) {
return prisma.budget.findFirst({
where: { id, userId },
select: budgetSelect,
});
}
findCategory(userId: string, categoryId: string) {
return prisma.category.findFirst({
where: {
id: categoryId,
OR: [
{ userId },
{ userId: null, isSystem: true },
],
},
select: {
id: true,
type: true,
isArchived: true,
},
});
}
async getSpendingSummary(
userId: string,
categoryId: string | null,
startDate: Date,
endDate: Date,
): Promise<BudgetSpendingSummary> {
const result = await prisma.transaction.aggregate({
where: {
userId,
type: TransactionType.EXPENSE,
...(categoryId ? { categoryId } : {}),
date: {
gte: startDate,
lt: endDate,
},
},
_sum: { amount: true },
_count: { _all: true },
_max: { date: true },
});
return {
amount: result._sum.amount ?? new Prisma.Decimal(0),
transactionCount: result._count._all,
lastTransactionAt: result._max.date,
};
}
create(userId: string, data: PersistBudgetDto) {
return prisma.budget.create({
data: {
userId,
...data,
},
select: budgetSelect,
});
}
update(id: string, data: PersistBudgetDto) {
return prisma.budget.update({
where: { id },
data,
select: budgetSelect,
});
}
archive(id: string) {
return prisma.budget.update({
where: { id },
data: { isArchived: true },
select: budgetSelect,
});
}
restore(id: string) {
return prisma.budget.update({
where: { id },
data: { isArchived: false },
select: budgetSelect,
});
}
}
import { Router } from 'express';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { BudgetController } from './budget.controller';
import {
budgetParamsSchema,
createBudgetSchema,
findBudgetsSchema,
updateBudgetSchema,
} from './budget.validation';
const router = Router();
const controller = new BudgetController();
router.use(authMiddleware);
router.get('/', validate(findBudgetsSchema, 'query'), controller.findAll);
router.post('/', validate(createBudgetSchema), controller.create);
router.get('/:id', validate(budgetParamsSchema, 'params'), controller.findById);
router.put(
'/:id',
validate(budgetParamsSchema, 'params'),
validate(updateBudgetSchema),
controller.update,
);
router.patch(
'/:id/restore',
validate(budgetParamsSchema, 'params'),
controller.restore,
);
router.delete(
'/:id',
validate(budgetParamsSchema, 'params'),
controller.archive,
);
export default router;
import {
BudgetPeriod,
BudgetType,
Prisma,
TransactionType,
} from '@prisma/client';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import {
BudgetQueryDto,
BudgetResponseDto,
BudgetTimeStatus,
BudgetUsageStatus,
CreateBudgetDto,
PersistBudgetDto,
UpdateBudgetDto,
} from './budget.dto';
import {
BudgetRecord,
BudgetRepository,
BudgetSpendingSummary,
} from './budget.repository';
const DAYS_PER_WEEK = 7;
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
export class BudgetService {
private readonly repository = new BudgetRepository();
async findAll(userId: string, query: BudgetQueryDto) {
const result = await this.repository.findAll(userId, query);
const data = await Promise.all(
result.data.map((budget) => this.toResponse(userId, budget)),
);
return { data, meta: result.meta };
}
async findById(userId: string, id: string) {
const budget = await this.findRecord(userId, id);
return this.toResponse(userId, budget);
}
async create(userId: string, data: CreateBudgetDto) {
const persistence = await this.resolveCreateData(userId, data);
const budget = await this.repository.create(userId, persistence);
return this.toResponse(userId, budget);
}
async update(userId: string, id: string, data: UpdateBudgetDto) {
const current = await this.findRecord(userId, id);
if (current.isArchived) {
throw new AppError(
'Restore the budget before updating it',
409,
ERROR_CODE.BUDGET_ARCHIVED,
);
}
const persistence = await this.resolveUpdateData(userId, current, data);
const budget = await this.repository.update(id, persistence);
return this.toResponse(userId, budget);
}
async archive(userId: string, id: string) {
const current = await this.findRecord(userId, id);
const budget = current.isArchived
? current
: await this.repository.archive(id);
return this.toResponse(userId, budget);
}
async restore(userId: string, id: string) {
const current = await this.findRecord(userId, id);
if (!current.isArchived) {
return this.toResponse(userId, current);
}
if (current.categoryId) {
await this.ensureExpenseCategory(userId, current.categoryId);
}
const budget = await this.repository.restore(id);
return this.toResponse(userId, budget);
}
private async findRecord(userId: string, id: string) {
const budget = await this.repository.findById(userId, id);
if (!budget) {
throw new AppError('Budget not found', 404, ERROR_CODE.NOT_FOUND);
}
return budget;
}
private async resolveCreateData(
userId: string,
data: CreateBudgetDto,
): Promise<PersistBudgetDto> {
const categoryId = await this.resolveCategoryId(
userId,
data.type,
data.categoryId,
);
const endDate = this.resolveEndDate(
data.period,
data.startDate,
data.endDate,
);
return {
name: data.name,
amount: data.amount,
type: data.type,
period: data.period,
categoryId,
startDate: data.startDate,
endDate,
alertThreshold: data.alertThreshold,
};
}
private async resolveUpdateData(
userId: string,
current: BudgetRecord,
data: UpdateBudgetDto,
): Promise<PersistBudgetDto> {
const type = data.type ?? current.type;
const period = data.period ?? current.period;
const startDate = data.startDate ?? current.startDate;
const requestedCategoryId = data.categoryId !== undefined
? data.categoryId
: data.type === BudgetType.OVERALL
? null
: current.categoryId;
const categoryId = await this.resolveCategoryId(
userId,
type,
requestedCategoryId,
);
let customEndDate = data.endDate;
if (
period === BudgetPeriod.CUSTOM
&& customEndDate === undefined
&& current.period === BudgetPeriod.CUSTOM
) {
customEndDate = current.endDate;
}
if (period !== BudgetPeriod.CUSTOM && data.endDate !== undefined) {
throw new AppError(
'endDate is calculated automatically for recurring periods',
422,
ERROR_CODE.VALIDATION_ERROR,
);
}
const endDate = this.resolveEndDate(period, startDate, customEndDate);
return {
name: data.name ?? current.name,
amount: data.amount ?? current.amount.toFixed(2),
type,
period,
categoryId,
startDate,
endDate,
alertThreshold: data.alertThreshold ?? current.alertThreshold.toFixed(2),
};
}
private async resolveCategoryId(
userId: string,
type: BudgetType,
categoryId: string | null | undefined,
): Promise<string | null> {
if (type === BudgetType.OVERALL) {
if (categoryId) {
throw new AppError(
'categoryId must be omitted for an overall budget',
422,
ERROR_CODE.VALIDATION_ERROR,
);
}
return null;
}
if (!categoryId) {
throw new AppError(
'categoryId is required for a category budget',
422,
ERROR_CODE.VALIDATION_ERROR,
);
}
await this.ensureExpenseCategory(userId, categoryId);
return categoryId;
}
private async ensureExpenseCategory(userId: string, categoryId: string) {
const category = await this.repository.findCategory(userId, categoryId);
if (!category) {
throw new AppError('Category not found', 404, ERROR_CODE.NOT_FOUND);
}
if (category.isArchived) {
throw new AppError(
'Archived category cannot be used for budgets',
409,
ERROR_CODE.CATEGORY_ARCHIVED,
);
}
if (category.type !== TransactionType.EXPENSE) {
throw new AppError(
'Budgets can only target expense categories',
409,
ERROR_CODE.BUDGET_CATEGORY_TYPE_INVALID,
);
}
}
private resolveEndDate(
period: BudgetPeriod,
startDate: Date,
customEndDate?: Date,
) {
let endDate: Date;
switch (period) {
case BudgetPeriod.WEEKLY:
endDate = new Date(
startDate.getTime() + DAYS_PER_WEEK * MILLISECONDS_PER_DAY,
);
break;
case BudgetPeriod.MONTHLY:
endDate = this.addUtcMonthsClamped(startDate, 1);
break;
case BudgetPeriod.YEARLY:
endDate = this.addUtcMonthsClamped(startDate, 12);
break;
default:
if (!customEndDate) {
throw new AppError(
'endDate is required for a custom budget',
422,
ERROR_CODE.VALIDATION_ERROR,
);
}
endDate = customEndDate;
}
if (endDate <= startDate) {
throw new AppError(
'endDate must be after startDate',
422,
ERROR_CODE.VALIDATION_ERROR,
);
}
return endDate;
}
private addUtcMonthsClamped(date: Date, months: number) {
const result = new Date(date);
const day = result.getUTCDate();
result.setUTCDate(1);
result.setUTCMonth(result.getUTCMonth() + months);
const lastDay = new Date(Date.UTC(
result.getUTCFullYear(),
result.getUTCMonth() + 1,
0,
)).getUTCDate();
result.setUTCDate(Math.min(day, lastDay));
return result;
}
private async toResponse(
userId: string,
budget: BudgetRecord,
): Promise<BudgetResponseDto> {
const spending = await this.repository.getSpendingSummary(
userId,
budget.categoryId,
budget.startDate,
budget.endDate,
);
return {
...budget,
amount: budget.amount.toFixed(2),
alertThreshold: budget.alertThreshold.toFixed(2),
usage: this.calculateUsage(budget, spending),
};
}
private calculateUsage(
budget: BudgetRecord,
spending: BudgetSpendingSummary,
) {
const usagePercentage = spending.amount
.dividedBy(budget.amount)
.times(100);
const thresholdReached = usagePercentage.greaterThanOrEqualTo(
budget.alertThreshold,
);
let status: BudgetUsageStatus = 'ON_TRACK';
if (spending.amount.greaterThan(budget.amount)) {
status = 'EXCEEDED';
} else if (thresholdReached) {
status = 'NEAR_LIMIT';
}
return {
spentAmount: spending.amount.toFixed(2),
remainingAmount: budget.amount.minus(spending.amount).toFixed(2),
usagePercentage: usagePercentage.toFixed(2),
transactionCount: spending.transactionCount,
lastTransactionAt: spending.lastTransactionAt,
timeStatus: this.getTimeStatus(budget),
status,
};
}
private getTimeStatus(budget: BudgetRecord): BudgetTimeStatus {
const now = new Date();
if (now < budget.startDate) {
return 'UPCOMING';
}
if (now >= budget.endDate) {
return 'ENDED';
}
return 'ACTIVE';
}
}
import { Prisma } from '@prisma/client';
import { z } from 'zod';
const budgetTypeSchema = z.enum(['OVERALL', 'CATEGORY']);
const budgetPeriodSchema = z.enum(['CUSTOM', 'WEEKLY', 'MONTHLY', 'YEARLY']);
const amountSchema = z
.string()
.trim()
.regex(
/^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/,
'Amount must be a positive decimal string with at most 16 integer digits and 2 decimal places',
)
.refine(
(value) => new Prisma.Decimal(value).greaterThan(0),
'Amount must be greater than zero',
);
const alertThresholdSchema = z
.union([z.string().trim(), z.number().finite().transform(String)])
.refine(
(value) => /^(?:0|[1-9]\d{0,2})(?:\.\d{1,2})?$/.test(value),
'Alert threshold must have at most 2 decimal places',
)
.refine((value) => {
const threshold = new Prisma.Decimal(value);
return threshold.greaterThan(0) && threshold.lessThanOrEqualTo(100);
}, 'Alert threshold must be greater than 0 and less than or equal to 100');
const dateSchema = z
.string()
.datetime({ offset: true, message: 'Date must be a valid ISO 8601 date-time' })
.transform((value) => new Date(value));
export const budgetParamsSchema = z.object({
id: z.string().uuid('Invalid budget id'),
});
export const findBudgetsSchema = z.object({
search: z.string().trim().min(1).max(200).optional(),
type: budgetTypeSchema.optional(),
period: budgetPeriodSchema.optional(),
categoryId: z.string().uuid('Invalid category id').optional(),
activeAt: dateSchema.optional(),
includeArchived: z
.enum(['true', 'false'])
.transform((value) => value === 'true')
.optional()
.default('false'),
sortBy: z
.enum(['name', 'amount', 'startDate', 'endDate', 'createdAt', 'updatedAt'])
.optional()
.default('startDate'),
order: z.enum(['asc', 'desc']).optional().default('desc'),
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
});
export const createBudgetSchema = z
.object({
name: z.string().trim().min(1, 'Name is required').max(100),
amount: amountSchema,
type: budgetTypeSchema,
period: budgetPeriodSchema,
categoryId: z.string().uuid('Invalid category id').nullable().optional(),
startDate: dateSchema,
endDate: dateSchema.optional(),
alertThreshold: alertThresholdSchema.optional().default('80'),
})
.superRefine((data, context) => {
if (data.type === 'CATEGORY' && !data.categoryId) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['categoryId'],
message: 'categoryId is required for a category budget',
});
}
if (data.type === 'OVERALL' && data.categoryId) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['categoryId'],
message: 'categoryId must be omitted for an overall budget',
});
}
if (data.period === 'CUSTOM' && !data.endDate) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['endDate'],
message: 'endDate is required for a custom budget',
});
}
if (data.period !== 'CUSTOM' && data.endDate) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['endDate'],
message: 'endDate is calculated automatically for recurring periods',
});
}
if (data.endDate && data.endDate <= data.startDate) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['endDate'],
message: 'endDate must be after startDate',
});
}
});
export const updateBudgetSchema = z
.object({
name: z.string().trim().min(1, 'Name cannot be empty').max(100).optional(),
amount: amountSchema.optional(),
type: budgetTypeSchema.optional(),
period: budgetPeriodSchema.optional(),
categoryId: z.string().uuid('Invalid category id').nullable().optional(),
startDate: dateSchema.optional(),
endDate: dateSchema.optional(),
alertThreshold: alertThresholdSchema.optional(),
})
.refine((data) => Object.keys(data).length > 0, {
message: 'At least one field is required',
})
.superRefine((data, context) => {
if (data.period && data.period !== 'CUSTOM' && data.endDate) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['endDate'],
message: 'endDate is calculated automatically for recurring periods',
});
}
if (data.startDate && data.endDate && data.endDate <= data.startDate) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['endDate'],
message: 'endDate must be after startDate',
});
}
});
......@@ -4,6 +4,7 @@ import userRoute from '../modules/users/user.route';
import walletRoute from '../modules/wallets/wallet.route';
import categoryRoute from '../modules/categories/category.route';
import transactionRoute from '../modules/transactions/transaction.route';
import budgetRoute from '../modules/budgets/budget.route';
const router = Router();
......@@ -16,5 +17,6 @@ router.use('/users', userRoute);
router.use('/wallets', walletRoute);
router.use('/categories', categoryRoute);
router.use('/transactions', transactionRoute);
router.use('/budgets', budgetRoute);
export default router;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment