Commit 97a31c0d authored by ThinhNC's avatar ThinhNC

feat: implement recurring budget management including scheduling, automated...

feat: implement recurring budget management including scheduling, automated renewal, and service-level tracking
parent ca1e55d2
......@@ -41,6 +41,11 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
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` cùng currency trong `[startDate, endDate)` khi đọc API.
Hỗ trợ tính năng tự động gia hạn chu kỳ (`isRecurring`, `autoRenew`, `recurrenceGroupId`,
`rolloverMode`, `rolloverAmount`, `autoRenewUntil`). Kích hoạt song song qua Background
Worker chạy định kỳ và cơ chế JIT Fallback trong `BudgetService.findAll` khi người dùng
truy cập danh sách ngân sách. Khóa duy nhất `(recurrenceGroupId, startDate)` đảm bảo
tính idempotency, tránh tạo trùng lặp chu kỳ.
- Saving Goal có trạng thái `ACTIVE`, `PAUSED`, `COMPLETED`, dùng archive để giữ lịch sử
và tổng hợp tiến độ từ Saving Contribution. Trạng thái hoàn thành được đồng bộ tự
động trong transaction Serializable khi contribution hoặc số tiền mục tiêu thay đổi;
......@@ -117,6 +122,9 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
delivery outbox và user reminders cùng các enum/index phục vụ worker nền.
`20260820120000_add_recurring_transactions` thêm lịch giao dịch định kỳ, occurrence idempotency,
quan hệ với transaction được tạo và các index phục vụ worker.
`20260910183000_add_budget_recurrence` thêm cấu hình tự động gia hạn ngân sách (`isRecurring`,
`autoRenew`, `recurrenceGroupId`, `rolloverMode`, `rolloverAmount`, `autoRenewUntil`), quan hệ phả hệ
chu kỳ (`parentBudgetId`), enum `BudgetRolloverMode` và ràng buộc duy nhất `(recurrenceGroupId, startDate)`.
Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của auth đã
được commit trước đó.
......
-- CreateEnum
CREATE TYPE "BudgetRolloverMode" AS ENUM ('RESET', 'ROLLOVER_SURPLUS', 'ROLLOVER_DEFICIT', 'ROLLOVER_NET');
-- AlterTable
ALTER TABLE "budgets" ADD COLUMN "is_recurring" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "auto_renew" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "recurrence_group_id" UUID,
ADD COLUMN "rollover_mode" "BudgetRolloverMode" NOT NULL DEFAULT 'RESET',
ADD COLUMN "rollover_amount" DECIMAL(18,2) NOT NULL DEFAULT 0,
ADD COLUMN "auto_renew_until" DATE,
ADD COLUMN "renewed_at" TIMESTAMPTZ(3),
ADD COLUMN "parent_budget_id" UUID;
-- CreateIndex
CREATE INDEX "budgets_user_id_is_recurring_auto_renew_idx" ON "budgets"("user_id", "is_recurring", "auto_renew");
CREATE INDEX "budgets_recurrence_group_id_start_date_idx" ON "budgets"("recurrence_group_id", "start_date");
CREATE UNIQUE INDEX "budgets_recurrence_group_id_start_date_key" ON "budgets"("recurrence_group_id", "start_date");
-- AddForeignKey
ALTER TABLE "budgets" ADD CONSTRAINT "budgets_parent_budget_id_fkey" FOREIGN KEY ("parent_budget_id") REFERENCES "budgets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
......@@ -315,24 +315,41 @@ model Transfer {
@@map("transfers")
}
enum BudgetRolloverMode {
RESET
ROLLOVER_SURPLUS
ROLLOVER_DEFICIT
ROLLOVER_NET
}
model Budget {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
categoryId String? @map("category_id") @db.Uuid
name String
amount Decimal @db.Decimal(18, 2)
currency String @default("VND") @db.VarChar(3)
type BudgetType @default(CATEGORY)
period BudgetPeriod @default(CUSTOM)
startDate DateTime @map("start_date") @db.Date
endDate DateTime @map("end_date") @db.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") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
category Category? @relation(fields: [categoryId], references: [id], onDelete: Restrict)
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
categoryId String? @map("category_id") @db.Uuid
name String
amount Decimal @db.Decimal(18, 2)
currency String @default("VND") @db.VarChar(3)
type BudgetType @default(CATEGORY)
period BudgetPeriod @default(CUSTOM)
startDate DateTime @map("start_date") @db.Date
endDate DateTime @map("end_date") @db.Date
alertThreshold Decimal @default(80) @map("alert_threshold") @db.Decimal(5, 2)
isArchived Boolean @default(false) @map("is_archived")
isRecurring Boolean @default(false) @map("is_recurring")
autoRenew Boolean @default(false) @map("auto_renew")
recurrenceGroupId String? @map("recurrence_group_id") @db.Uuid
rolloverMode BudgetRolloverMode @default(RESET) @map("rollover_mode")
rolloverAmount Decimal @default(0) @map("rollover_amount") @db.Decimal(18, 2)
autoRenewUntil DateTime? @map("auto_renew_until") @db.Date
renewedAt DateTime? @map("renewed_at") @db.Timestamptz(3)
parentBudgetId String? @map("parent_budget_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
category Category? @relation(fields: [categoryId], references: [id], onDelete: Restrict)
parentBudget Budget? @relation("BudgetHistory", fields: [parentBudgetId], references: [id], onDelete: SetNull)
childBudgets Budget[] @relation("BudgetHistory")
@@index([categoryId])
@@index([userId, isArchived])
......@@ -340,6 +357,9 @@ model Budget {
@@index([userId, currency, startDate, endDate])
@@index([userId, type, period])
@@index([isArchived, startDate, endDate])
@@index([userId, isRecurring, autoRenew])
@@index([recurrenceGroupId, startDate])
@@unique([recurrenceGroupId, startDate])
@@map("budgets")
}
......
......@@ -73,6 +73,30 @@ export class BudgetController {
}
};
toggleAutoRenew = async (req: Request, res: Response, next: NextFunction) => {
try {
const budget = await this.service.toggleAutoRenew(
req.user.id,
req.params.id,
req.body.autoRenew,
);
res.json({ success: true, data: budget });
} catch (error) {
next(error);
}
};
findSeries = async (req: Request, res: Response, next: NextFunction) => {
try {
const series = await this.service.findSeries(req.user.id, req.params.id);
res.json({ success: true, data: series });
} 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);
......
import { BudgetPeriod, BudgetType, TransactionType } from '@prisma/client';
import {
BudgetPeriod,
BudgetRolloverMode,
BudgetType,
TransactionType,
} from '@prisma/client';
import { BusinessDate } from '../../common/date-time/business-time';
export { BudgetRolloverMode };
export type BudgetSortField =
| 'name'
| 'amount'
......@@ -20,6 +27,7 @@ export interface BudgetQueryDto {
categoryId?: string;
activeAt?: BusinessDate;
includeArchived: boolean;
isRecurring?: boolean;
sortBy: BudgetSortField;
order: SortOrder;
page: number;
......@@ -36,6 +44,10 @@ export interface CreateBudgetDto {
startDate: BusinessDate;
endDate?: BusinessDate;
alertThreshold: string;
isRecurring?: boolean;
autoRenew?: boolean;
rolloverMode?: BudgetRolloverMode;
autoRenewUntil?: BusinessDate | null;
}
export interface UpdateBudgetDto {
......@@ -48,6 +60,14 @@ export interface UpdateBudgetDto {
startDate?: BusinessDate;
endDate?: BusinessDate;
alertThreshold?: string;
isRecurring?: boolean;
autoRenew?: boolean;
rolloverMode?: BudgetRolloverMode;
autoRenewUntil?: BusinessDate | null;
}
export interface ToggleAutoRenewDto {
autoRenew: boolean;
}
export interface PersistBudgetDto {
......@@ -60,6 +80,14 @@ export interface PersistBudgetDto {
startDate: BusinessDate;
endDate: BusinessDate;
alertThreshold: string;
isRecurring?: boolean;
autoRenew?: boolean;
recurrenceGroupId?: string | null;
rolloverMode?: BudgetRolloverMode;
rolloverAmount?: string;
autoRenewUntil?: BusinessDate | null;
renewedAt?: Date | null;
parentBudgetId?: string | null;
}
export interface BudgetCategoryDto {
......@@ -82,6 +110,7 @@ export interface BudgetUsageDto {
export interface BudgetResponseDto {
id: string;
userId: string;
name: string;
amount: string;
currency: string;
......@@ -92,6 +121,14 @@ export interface BudgetResponseDto {
endDate: BusinessDate;
alertThreshold: string;
isArchived: boolean;
isRecurring: boolean;
autoRenew: boolean;
recurrenceGroupId: string | null;
rolloverMode: BudgetRolloverMode;
rolloverAmount: string;
autoRenewUntil: BusinessDate | null;
renewedAt: Date | null;
parentBudgetId: string | null;
createdAt: Date;
updatedAt: Date;
category: BudgetCategoryDto | null;
......
......@@ -15,6 +15,7 @@ import {
const budgetSelect = {
id: true,
userId: true,
name: true,
amount: true,
currency: true,
......@@ -25,6 +26,14 @@ const budgetSelect = {
endDate: true,
alertThreshold: true,
isArchived: true,
isRecurring: true,
autoRenew: true,
recurrenceGroupId: true,
rolloverMode: true,
rolloverAmount: true,
autoRenewUntil: true,
renewedAt: true,
parentBudgetId: true,
createdAt: true,
updatedAt: true,
category: {
......@@ -58,6 +67,7 @@ export class BudgetRepository {
categoryId,
activeAt,
includeArchived,
isRecurring,
sortBy,
order,
page,
......@@ -69,6 +79,7 @@ export class BudgetRepository {
...(period ? { period } : {}),
...(currency ? { currency } : {}),
...(categoryId ? { categoryId } : {}),
...(isRecurring !== undefined ? { isRecurring } : {}),
...(activeAt
? {
startDate: { lte: businessDateToPrismaDate(activeAt) },
......@@ -228,9 +239,27 @@ export class BudgetRepository {
return prisma.budget.create({
data: {
userId,
...data,
name: data.name,
amount: data.amount,
currency: data.currency,
type: data.type,
period: data.period,
categoryId: data.categoryId,
alertThreshold: data.alertThreshold,
startDate: businessDateToPrismaDate(data.startDate),
endDate: businessDateToPrismaDate(data.endDate),
isRecurring: data.isRecurring ?? false,
autoRenew: data.autoRenew ?? (data.isRecurring ?? false),
recurrenceGroupId: data.recurrenceGroupId,
rolloverMode: data.rolloverMode,
rolloverAmount: data.rolloverAmount
? new Prisma.Decimal(data.rolloverAmount)
: undefined,
autoRenewUntil: data.autoRenewUntil
? businessDateToPrismaDate(data.autoRenewUntil)
: null,
renewedAt: data.renewedAt,
parentBudgetId: data.parentBudgetId,
},
select: budgetSelect,
});
......@@ -240,14 +269,125 @@ export class BudgetRepository {
return prisma.budget.update({
where: { id },
data: {
...data,
name: data.name,
amount: data.amount,
currency: data.currency,
type: data.type,
period: data.period,
categoryId: data.categoryId,
alertThreshold: data.alertThreshold,
startDate: businessDateToPrismaDate(data.startDate),
endDate: businessDateToPrismaDate(data.endDate),
isRecurring: data.isRecurring,
autoRenew: data.autoRenew,
recurrenceGroupId: data.recurrenceGroupId,
rolloverMode: data.rolloverMode,
rolloverAmount: data.rolloverAmount
? new Prisma.Decimal(data.rolloverAmount)
: undefined,
autoRenewUntil: data.autoRenewUntil
? businessDateToPrismaDate(data.autoRenewUntil)
: null,
},
select: budgetSelect,
});
}
updateAutoRenew(userId: string, id: string, autoRenew: boolean) {
return prisma.budget.update({
where: { id, userId },
data: { autoRenew },
select: budgetSelect,
});
}
findDueForRenewal(today: BusinessDate, limit = 50) {
const todayDate = businessDateToPrismaDate(today);
return prisma.budget.findMany({
where: {
isRecurring: true,
autoRenew: true,
isArchived: false,
endDate: { lt: todayDate },
renewedAt: null,
OR: [
{ autoRenewUntil: null },
{ autoRenewUntil: { gte: todayDate } },
],
},
select: budgetSelect,
take: limit,
orderBy: { endDate: 'asc' },
});
}
findDueForRenewalByUser(userId: string, today: BusinessDate) {
const todayDate = businessDateToPrismaDate(today);
return prisma.budget.findMany({
where: {
userId,
isRecurring: true,
autoRenew: true,
isArchived: false,
endDate: { lt: todayDate },
renewedAt: null,
OR: [
{ autoRenewUntil: null },
{ autoRenewUntil: { gte: todayDate } },
],
},
select: budgetSelect,
orderBy: { endDate: 'asc' },
});
}
findSeries(userId: string, recurrenceGroupId: string) {
return prisma.budget.findMany({
where: { userId, recurrenceGroupId },
select: budgetSelect,
orderBy: { startDate: 'asc' },
});
}
renewBudgetTransaction(
parentBudget: BudgetRecord,
newBudgetData: PersistBudgetDto,
) {
return prisma.$transaction(async (tx) => {
// 1. Mark parent budget as renewed
await tx.budget.update({
where: { id: parentBudget.id },
data: { renewedAt: new Date() },
});
// 2. Insert new budget for next cycle
return tx.budget.create({
data: {
userId: parentBudget.userId,
name: newBudgetData.name,
amount: newBudgetData.amount,
currency: newBudgetData.currency,
type: newBudgetData.type,
period: newBudgetData.period,
categoryId: newBudgetData.categoryId,
alertThreshold: newBudgetData.alertThreshold,
startDate: businessDateToPrismaDate(newBudgetData.startDate),
endDate: businessDateToPrismaDate(newBudgetData.endDate),
isRecurring: true,
autoRenew: true,
recurrenceGroupId: parentBudget.recurrenceGroupId,
rolloverMode: parentBudget.rolloverMode,
rolloverAmount: newBudgetData.rolloverAmount
? new Prisma.Decimal(newBudgetData.rolloverAmount)
: new Prisma.Decimal(0),
autoRenewUntil: parentBudget.autoRenewUntil,
parentBudgetId: parentBudget.id,
},
select: budgetSelect,
});
});
}
archive(id: string) {
return prisma.budget.update({
where: { id },
......
......@@ -8,6 +8,7 @@ import {
budgetParamsSchema,
createBudgetSchema,
findBudgetsSchema,
toggleAutoRenewSchema,
updateBudgetSchema,
} from './budget.validation';
......@@ -19,6 +20,7 @@ router.use(authMiddleware);
router.get('/', requirePermission(PERMISSIONS.BUDGET_READ), validate(findBudgetsSchema, 'query'), controller.findAll);
router.post('/', requirePermission(PERMISSIONS.BUDGET_CREATE), validate(createBudgetSchema), controller.create);
router.get('/:id', requirePermission(PERMISSIONS.BUDGET_READ), validate(budgetParamsSchema, 'params'), controller.findById);
router.get('/:id/series', requirePermission(PERMISSIONS.BUDGET_READ), validate(budgetParamsSchema, 'params'), controller.findSeries);
router.put(
'/:id',
requirePermission(PERMISSIONS.BUDGET_UPDATE),
......@@ -26,6 +28,13 @@ router.put(
validate(updateBudgetSchema),
controller.update,
);
router.patch(
'/:id/auto-renew',
requirePermission(PERMISSIONS.BUDGET_UPDATE),
validate(budgetParamsSchema, 'params'),
validate(toggleAutoRenewSchema),
controller.toggleAutoRenew,
);
router.patch(
'/:id/restore',
requirePermission(PERMISSIONS.BUDGET_UPDATE),
......
import { randomUUID } from 'crypto';
import {
BudgetPeriod,
BudgetRolloverMode,
BudgetType,
NotificationPriority,
NotificationSourceType,
NotificationType,
Prisma,
TransactionType,
} from '@prisma/client';
......@@ -28,11 +33,17 @@ import {
BudgetRepository,
BudgetSpendingSummary,
} from './budget.repository';
import { NotificationService } from '../notifications/notification.service';
export class BudgetService {
private readonly repository = new BudgetRepository();
private readonly notificationService = new NotificationService();
async findAll(userId: string, query: BudgetQueryDto) {
// JIT: check if any due recurring budgets for this user need catch-up
const today = instantToBusinessDate(new Date());
await this.catchUpDueBudgetsForUser(userId, today);
const result = await this.repository.findAll(userId, query);
const summaries = await this.repository.getBatchSpendingSummaries(
userId,
......@@ -55,6 +66,29 @@ export class BudgetService {
return this.toResponse(userId, budget);
}
async findSeries(userId: string, id: string) {
const budget = await this.findRecord(userId, id);
if (!budget.recurrenceGroupId) {
return [await this.toResponse(userId, budget)];
}
const series = await this.repository.findSeries(
userId,
budget.recurrenceGroupId,
);
const summaries = await this.repository.getBatchSpendingSummaries(
userId,
series,
);
return series.map((b) => {
const spending = summaries.get(b.id) ?? {
amount: new Prisma.Decimal(0),
transactionCount: 0,
lastTransactionAt: null,
};
return this.formatBudgetResponse(b, spending);
});
}
async create(userId: string, data: CreateBudgetDto) {
const persistence = await this.resolveCreateData(userId, data);
const budget = await this.repository.create(userId, persistence);
......@@ -79,6 +113,20 @@ export class BudgetService {
return this.toResponse(userId, budget);
}
async toggleAutoRenew(userId: string, id: string, autoRenew: boolean) {
const budget = await this.findRecord(userId, id);
if (!budget.isRecurring) {
throw new AppError(
'Cannot toggle auto-renew on a non-recurring budget',
400,
ERROR_CODE.VALIDATION_ERROR,
);
}
const updated = await this.repository.updateAutoRenew(userId, id, autoRenew);
await this.invalidateReportCache(userId);
return this.toResponse(userId, updated);
}
async archive(userId: string, id: string) {
const current = await this.findRecord(userId, id);
const budget = current.isArchived
......@@ -105,6 +153,228 @@ export class BudgetService {
return this.toResponse(userId, budget);
}
async processDueRenewals(now = new Date()): Promise<number> {
const today = instantToBusinessDate(now);
const dueBudgets = await this.repository.findDueForRenewal(today, 50);
let renewedCount = 0;
for (const parent of dueBudgets) {
try {
let currentParent: BudgetRecord | null = parent;
let iteration = 0;
while (currentParent && iteration < 3) {
iteration++;
const currentParentEndDate = prismaDateToBusinessDate(
currentParent.endDate,
);
if (currentParentEndDate >= today) break;
const nextStartDate = addBusinessDays(currentParentEndDate, 1);
if (
currentParent.autoRenewUntil &&
nextStartDate >
prismaDateToBusinessDate(currentParent.autoRenewUntil)
) {
break;
}
const child = await this.renewSingleBudget(currentParent, today);
if (child) {
renewedCount++;
currentParent = child;
} else {
break;
}
}
} catch (error) {
console.error(`Failed to renew budget ${parent.id}:`, error);
}
}
return renewedCount;
}
private async catchUpDueBudgetsForUser(
userId: string,
today: BusinessDate,
): Promise<void> {
try {
const dueBudgets = await this.repository.findDueForRenewalByUser(
userId,
today,
);
for (const parent of dueBudgets) {
let currentParent: BudgetRecord | null = parent;
let iteration = 0;
while (currentParent && iteration < 3) {
iteration++;
const currentParentEndDate = prismaDateToBusinessDate(
currentParent.endDate,
);
if (currentParentEndDate >= today) break;
const nextStartDate = addBusinessDays(currentParentEndDate, 1);
if (
currentParent.autoRenewUntil &&
nextStartDate >
prismaDateToBusinessDate(currentParent.autoRenewUntil)
) {
break;
}
const child = await this.renewSingleBudget(currentParent, today);
if (child) {
currentParent = child;
} else {
break;
}
}
}
} catch (error) {
console.error(`Failed to catch-up budgets for user ${userId}:`, error);
}
}
private async renewSingleBudget(
parentBudget: BudgetRecord,
today: BusinessDate,
): Promise<BudgetRecord | null> {
// 1. Verify category is still active if category budget
if (parentBudget.categoryId) {
const category = await this.repository.findCategory(
parentBudget.userId,
parentBudget.categoryId,
);
if (!category || category.isArchived) {
await this.repository.updateAutoRenew(
parentBudget.userId,
parentBudget.id,
false,
);
await this.notificationService.create({
userId: parentBudget.userId,
type: NotificationType.SYSTEM,
priority: NotificationPriority.HIGH,
title: 'Tự động gia hạn ngân sách bị tạm dừng',
message: `Ngân sách "${parentBudget.name}" không thể tự động gia hạn vì danh mục chi tiêu đã bị lưu trữ hoặc xóa.`,
sourceType: NotificationSourceType.SYSTEM,
sourceId: parentBudget.id,
actionUrl: `/budgets/${parentBudget.id}`,
data: { budgetId: parentBudget.id, reason: 'CATEGORY_ARCHIVED' },
dedupKey: `budget-renew-failed:${parentBudget.id}:${today}`,
});
return null;
}
}
// 2. Compute date boundaries
const parentEndDate = prismaDateToBusinessDate(parentBudget.endDate);
const newStartDate = addBusinessDays(parentEndDate, 1);
const newEndDate = this.resolveEndDate(parentBudget.period, newStartDate);
// 3. Compute new amount based on rollover policy
const spending = await this.repository.getSpendingSummary(
parentBudget.userId,
parentBudget.categoryId,
parentBudget.startDate,
parentBudget.endDate,
parentBudget.currency,
);
const remaining = parentBudget.amount.minus(spending.amount);
const baseAmount = parentBudget.amount;
let newAmount = baseAmount;
let rolloverAmount = new Prisma.Decimal(0);
switch (parentBudget.rolloverMode) {
case BudgetRolloverMode.ROLLOVER_SURPLUS:
if (remaining.greaterThan(0)) {
newAmount = baseAmount.plus(remaining);
rolloverAmount = remaining;
}
break;
case BudgetRolloverMode.ROLLOVER_DEFICIT:
if (remaining.lessThan(0)) {
newAmount = Prisma.Decimal.max(
new Prisma.Decimal(0),
baseAmount.plus(remaining),
);
rolloverAmount = remaining;
}
break;
case BudgetRolloverMode.ROLLOVER_NET:
newAmount = Prisma.Decimal.max(
new Prisma.Decimal(0),
baseAmount.plus(remaining),
);
rolloverAmount = remaining;
break;
case BudgetRolloverMode.RESET:
default:
newAmount = baseAmount;
rolloverAmount = new Prisma.Decimal(0);
break;
}
// 4. Update name dynamically if pattern "tháng X" or "month X" exists
let newName = parentBudget.name;
const newMonth = parseInt(newStartDate.slice(5, 7), 10);
if (/(tháng\s*)\d+/i.test(newName)) {
newName = newName.replace(/(tháng\s*)\d+/i, `$1${newMonth}`);
} else if (/(month\s*)\d+/i.test(newName)) {
newName = newName.replace(/(month\s*)\d+/i, `$1${newMonth}`);
}
// 5. Build persist data
const persistData: PersistBudgetDto = {
name: newName,
amount: newAmount.toFixed(2),
currency: parentBudget.currency,
type: parentBudget.type,
period: parentBudget.period,
categoryId: parentBudget.categoryId,
startDate: newStartDate,
endDate: newEndDate,
alertThreshold: parentBudget.alertThreshold.toFixed(2),
isRecurring: true,
autoRenew: true,
recurrenceGroupId: parentBudget.recurrenceGroupId,
rolloverMode: parentBudget.rolloverMode,
rolloverAmount: rolloverAmount.toFixed(2),
autoRenewUntil: parentBudget.autoRenewUntil
? prismaDateToBusinessDate(parentBudget.autoRenewUntil)
: null,
parentBudgetId: parentBudget.id,
};
const newBudget = await this.repository.renewBudgetTransaction(
parentBudget,
persistData,
);
// 6. Send in-app notification
await this.notificationService.create({
userId: newBudget.userId,
type: NotificationType.SYSTEM,
priority: NotificationPriority.NORMAL,
title: 'Ngân sách chu kỳ mới đã sẵn sàng',
message: `Ngân sách "${newBudget.name}" đã được tự động kích hoạt cho chu kỳ tiếp theo (${newStartDate} đến ${newEndDate}).`,
sourceType: NotificationSourceType.SYSTEM,
sourceId: newBudget.id,
actionUrl: `/budgets/${newBudget.id}`,
data: {
budgetId: newBudget.id,
recurrenceGroupId: newBudget.recurrenceGroupId,
startDate: newStartDate,
endDate: newEndDate,
rolloverAmount: rolloverAmount.toFixed(2),
},
dedupKey: `budget-renew:${newBudget.recurrenceGroupId}:${newStartDate}`,
});
await this.invalidateReportCache(parentBudget.userId);
return newBudget;
}
private async findRecord(userId: string, id: string) {
const budget = await this.repository.findById(userId, id);
......@@ -129,6 +399,10 @@ export class BudgetService {
data.startDate,
data.endDate,
);
const isRecurring = data.isRecurring ?? false;
const recurrenceGroupId = isRecurring ? randomUUID() : null;
const autoRenew = isRecurring ? (data.autoRenew ?? true) : false;
const rolloverMode = data.rolloverMode ?? BudgetRolloverMode.RESET;
return {
name: data.name,
......@@ -140,6 +414,11 @@ export class BudgetService {
endDate,
alertThreshold: data.alertThreshold,
currency: data.currency,
isRecurring,
autoRenew,
recurrenceGroupId,
rolloverMode,
autoRenewUntil: data.autoRenewUntil ?? null,
};
}
......@@ -150,12 +429,14 @@ export class BudgetService {
): Promise<PersistBudgetDto> {
const type = data.type ?? current.type;
const period = data.period ?? current.period;
const startDate = data.startDate ?? prismaDateToBusinessDate(current.startDate);
const requestedCategoryId = data.categoryId !== undefined
? data.categoryId
: data.type === BudgetType.OVERALL
? null
: current.categoryId;
const startDate =
data.startDate ?? prismaDateToBusinessDate(current.startDate);
const requestedCategoryId =
data.categoryId !== undefined
? data.categoryId
: data.type === BudgetType.OVERALL
? null
: current.categoryId;
const categoryId = await this.resolveCategoryId(
userId,
type,
......@@ -164,9 +445,9 @@ export class BudgetService {
let customEndDate = data.endDate;
if (
period === BudgetPeriod.CUSTOM
&& customEndDate === undefined
&& current.period === BudgetPeriod.CUSTOM
period === BudgetPeriod.CUSTOM &&
customEndDate === undefined &&
current.period === BudgetPeriod.CUSTOM
) {
customEndDate = prismaDateToBusinessDate(current.endDate);
}
......@@ -181,6 +462,25 @@ export class BudgetService {
const endDate = this.resolveEndDate(period, startDate, customEndDate);
const isRecurring =
data.isRecurring !== undefined ? data.isRecurring : current.isRecurring;
let recurrenceGroupId = current.recurrenceGroupId;
if (isRecurring && !recurrenceGroupId) {
recurrenceGroupId = randomUUID();
} else if (!isRecurring) {
recurrenceGroupId = null;
}
const autoRenew = isRecurring
? (data.autoRenew !== undefined ? data.autoRenew : current.autoRenew)
: false;
const rolloverMode = data.rolloverMode ?? current.rolloverMode;
const autoRenewUntil =
data.autoRenewUntil !== undefined
? data.autoRenewUntil
: current.autoRenewUntil
? prismaDateToBusinessDate(current.autoRenewUntil)
: null;
return {
name: data.name ?? current.name,
amount: data.amount ?? current.amount.toFixed(2),
......@@ -191,6 +491,11 @@ export class BudgetService {
endDate,
alertThreshold: data.alertThreshold ?? current.alertThreshold.toFixed(2),
currency: data.currency ?? current.currency,
isRecurring,
autoRenew,
recurrenceGroupId,
rolloverMode,
autoRenewUntil,
};
}
......@@ -309,7 +614,11 @@ export class BudgetService {
...budget,
startDate: prismaDateToBusinessDate(budget.startDate),
endDate: prismaDateToBusinessDate(budget.endDate),
autoRenewUntil: budget.autoRenewUntil
? prismaDateToBusinessDate(budget.autoRenewUntil)
: null,
amount: budget.amount.toFixed(2),
rolloverAmount: budget.rolloverAmount.toFixed(2),
alertThreshold: budget.alertThreshold.toFixed(2),
usage: this.calculateUsage(budget, spending),
};
......
......@@ -41,10 +41,21 @@ const dateSchema = z
}
});
const rolloverModeSchema = z.enum([
'RESET',
'ROLLOVER_SURPLUS',
'ROLLOVER_DEFICIT',
'ROLLOVER_NET',
]);
export const budgetParamsSchema = z.object({
id: z.string().uuid('Invalid budget id'),
});
export const toggleAutoRenewSchema = z.object({
autoRenew: z.boolean({ required_error: 'autoRenew is required' }),
});
export const findBudgetsSchema = z.object({
search: z.string().trim().min(1).max(200).optional(),
type: budgetTypeSchema.optional(),
......@@ -57,6 +68,10 @@ export const findBudgetsSchema = z.object({
.transform((value) => value === 'true')
.optional()
.default('false'),
isRecurring: z
.enum(['true', 'false'])
.transform((value) => value === 'true')
.optional(),
sortBy: z
.enum(['name', 'amount', 'startDate', 'endDate', 'createdAt', 'updatedAt'])
.optional()
......@@ -77,6 +92,10 @@ export const createBudgetSchema = z
startDate: dateSchema,
endDate: dateSchema.optional(),
alertThreshold: alertThresholdSchema.optional().default('80'),
isRecurring: z.boolean().optional().default(false),
autoRenew: z.boolean().optional(),
rolloverMode: rolloverModeSchema.optional().default('RESET'),
autoRenewUntil: dateSchema.nullable().optional(),
})
.superRefine((data, context) => {
if (data.type === 'CATEGORY' && !data.categoryId) {
......@@ -118,6 +137,22 @@ export const createBudgetSchema = z
message: 'endDate must be on or after startDate',
});
}
if (data.isRecurring && data.period === 'CUSTOM') {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['isRecurring'],
message: 'Recurring is only supported for recurring periods (WEEKLY, MONTHLY, YEARLY)',
});
}
if (data.autoRenewUntil && data.autoRenewUntil < data.startDate) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['autoRenewUntil'],
message: 'autoRenewUntil must be on or after startDate',
});
}
});
export const updateBudgetSchema = z
......@@ -131,6 +166,10 @@ export const updateBudgetSchema = z
startDate: dateSchema.optional(),
endDate: dateSchema.optional(),
alertThreshold: alertThresholdSchema.optional(),
isRecurring: z.boolean().optional(),
autoRenew: z.boolean().optional(),
rolloverMode: rolloverModeSchema.optional(),
autoRenewUntil: dateSchema.nullable().optional(),
})
.refine((data) => Object.keys(data).length > 0, {
message: 'At least one field is required',
......@@ -151,4 +190,12 @@ export const updateBudgetSchema = z
message: 'endDate must be on or after startDate',
});
}
if (data.isRecurring && data.period === 'CUSTOM') {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['isRecurring'],
message: 'Recurring is only supported for recurring periods (WEEKLY, MONTHLY, YEARLY)',
});
}
});
......@@ -3,6 +3,7 @@ import { ReminderService } from '../reminders/reminder.service';
import { NotificationDeliveryService } from './notification-delivery.service';
import { NotificationService } from './notification.service';
import { RecurringTransactionService } from '../recurring-transactions/recurring-transaction.service';
import { BudgetService } from '../budgets/budget.service';
import { lockService } from '../../common/services/lock.service';
......@@ -11,6 +12,7 @@ export class NotificationWorker {
private readonly notificationService = new NotificationService();
private readonly deliveryService = new NotificationDeliveryService();
private readonly recurringTransactionService = new RecurringTransactionService();
private readonly budgetService = new BudgetService();
private timer: NodeJS.Timeout | null = null;
private running = false;
private lastFinancialScanAt = 0;
......@@ -51,6 +53,12 @@ export class NotificationWorker {
const now = new Date();
try {
try {
await this.budgetService.processDueRenewals(now);
} catch (error) {
console.error('Notification worker failed to process budget renewals', error);
}
try {
await this.recurringTransactionService.processDue(
now,
......
......@@ -72,6 +72,8 @@ export class TransactionRepository {
try {
return await prisma.$transaction(operation, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 10000,
timeout: 15000,
});
} catch (error) {
const shouldRetry = error instanceof Prisma.PrismaClientKnownRequestError
......
import request from 'supertest';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
import bcrypt from 'bcryptjs';
import { BudgetService } from '../src/modules/budgets/budget.service';
describe('Budget Recurrence & Auto-Renewal Integration Tests', () => {
const testUser = {
email: 'budget-recurrence-test@gmail.com',
password: 'Password@123456',
fullName: 'Budget Recurrence Test User',
};
let userId = '';
let accessToken = '';
let walletId = '';
let categoryId = '';
const budgetService = new BudgetService();
beforeAll(async () => {
const defaultRole = await prisma.role.findUnique({ where: { name: 'USER' } });
const passwordHash = await bcrypt.hash(testUser.password, 10);
const user = await prisma.user.create({
data: {
email: testUser.email,
password: passwordHash,
fullName: testUser.fullName,
roleId: defaultRole!.id,
isActive: true,
},
});
userId = user.id;
const wallet = await prisma.wallet.create({
data: {
userId,
name: 'Ví Recurrence',
balance: 10000000.0,
currency: 'VND',
},
});
walletId = wallet.id;
const category = await prisma.category.create({
data: {
userId,
name: 'Ăn uống Recurrence',
type: 'EXPENSE',
},
});
categoryId = category.id;
const loginRes = await request(app)
.post('/api/v1/auth/login')
.send({ email: testUser.email, password: testUser.password });
accessToken = loginRes.body.data.accessToken;
});
afterAll(async () => {
await prisma.notification.deleteMany({ where: { userId } });
await prisma.transaction.deleteMany({ where: { userId } });
await prisma.budget.deleteMany({ where: { userId } });
await prisma.category.deleteMany({ where: { userId } });
await prisma.wallet.deleteMany({ where: { userId } });
await prisma.refreshToken.deleteMany({ where: { userId } });
await prisma.userDevice.deleteMany({ where: { userId } });
await prisma.user.deleteMany({ where: { id: userId } });
});
it('should create a recurring budget with recurrenceGroupId and autoRenew enabled', async () => {
const res = await request(app)
.post('/api/v1/budgets')
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'Ngân sách ăn uống tháng 8',
amount: '3000000.00',
currency: 'VND',
type: 'CATEGORY',
period: 'MONTHLY',
categoryId,
startDate: '2026-08-01',
alertThreshold: '80.00',
isRecurring: true,
autoRenew: true,
rolloverMode: 'RESET',
});
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.data.isRecurring).toBe(true);
expect(res.body.data.autoRenew).toBe(true);
expect(res.body.data.recurrenceGroupId).toBeTruthy();
expect(res.body.data.rolloverMode).toBe('RESET');
expect(res.body.data.startDate).toBe('2026-08-01');
expect(res.body.data.endDate).toBe('2026-08-31');
});
it('should auto-renew an ended August budget into September when processed', async () => {
// 1. Create recurring August budget
const createRes = await request(app)
.post('/api/v1/budgets')
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'Ăn uống hàng tháng',
amount: '5000000.00',
currency: 'VND',
type: 'CATEGORY',
period: 'MONTHLY',
categoryId,
startDate: '2026-08-01',
isRecurring: true,
autoRenew: true,
rolloverMode: 'RESET',
});
expect(createRes.status).toBe(201);
const parentId = createRes.body.data.id;
const recurrenceGroupId = createRes.body.data.recurrenceGroupId;
// 2. Spend 1.5M in August
await request(app)
.post('/api/v1/transactions')
.set('Authorization', `Bearer ${accessToken}`)
.send({
walletId,
categoryId,
type: 'EXPENSE',
amount: '1500000.00',
date: '2026-08-10',
note: 'Ăn tối',
});
// 3. Trigger renewal as if current date is 2026-09-10
const testNow = new Date('2026-09-10T12:00:00+07:00');
const renewedCount = await budgetService.processDueRenewals(testNow);
expect(renewedCount).toBeGreaterThanOrEqual(1);
// 4. Verify September budget is created
const seriesRes = await request(app)
.get(`/api/v1/budgets/${parentId}/series`)
.set('Authorization', `Bearer ${accessToken}`);
expect(seriesRes.status).toBe(200);
const series = seriesRes.body.data;
expect(series.length).toBe(2);
const septemberBudget = series.find((b: any) => b.startDate === '2026-09-01');
expect(septemberBudget).toBeDefined();
expect(septemberBudget.endDate).toBe('2026-09-30');
expect(septemberBudget.amount).toBe('5000000.00');
expect(septemberBudget.recurrenceGroupId).toBe(recurrenceGroupId);
expect(septemberBudget.parentBudgetId).toBe(parentId);
// 5. Verify parent has renewedAt set
const parentBudget = await prisma.budget.findUnique({ where: { id: parentId } });
expect(parentBudget?.renewedAt).not.toBeNull();
});
it('should correctly calculate rollover surplus in ROLLOVER_SURPLUS mode', async () => {
// Create dedicated category for pocket money
const pocketCategory = await prisma.category.create({
data: {
userId,
name: 'Tiêu vặt Rollover Category',
type: 'EXPENSE',
},
});
// 1. Create parent budget with 2M limit and ROLLOVER_SURPLUS
const createRes = await request(app)
.post('/api/v1/budgets')
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'Tiêu vặt tháng 8',
amount: '2000000.00',
currency: 'VND',
type: 'CATEGORY',
period: 'MONTHLY',
categoryId: pocketCategory.id,
startDate: '2026-08-01',
isRecurring: true,
autoRenew: true,
rolloverMode: 'ROLLOVER_SURPLUS',
});
expect(createRes.status).toBe(201);
const parentId = createRes.body.data.id;
// 2. Spend 500k in August -> Surplus = +1,500,000
await request(app)
.post('/api/v1/transactions')
.set('Authorization', `Bearer ${accessToken}`)
.send({
walletId,
categoryId: pocketCategory.id,
type: 'EXPENSE',
amount: '500000.00',
date: '2026-08-20',
note: 'Mua sách',
});
// 3. Trigger renewal for September
const testNow = new Date('2026-09-10T12:00:00+07:00');
await budgetService.processDueRenewals(testNow);
// 4. Verify September budget has 2M + 1.5M = 3.5M
const seriesRes = await request(app)
.get(`/api/v1/budgets/${parentId}/series`)
.set('Authorization', `Bearer ${accessToken}`);
const septemberBudget = seriesRes.body.data.find((b: any) => b.startDate === '2026-09-01');
expect(septemberBudget).toBeDefined();
expect(septemberBudget.amount).toBe('3500000.00');
expect(septemberBudget.rolloverAmount).toBe('1500000.00');
expect(septemberBudget.name).toBe('Tiêu vặt tháng 9');
});
it('should support toggleAutoRenew to pause and resume auto-renew', async () => {
const createRes = await request(app)
.post('/api/v1/budgets')
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'Ngân sách tạm dừng',
amount: '1000000.00',
currency: 'VND',
type: 'CATEGORY',
period: 'MONTHLY',
categoryId,
startDate: '2026-09-01',
isRecurring: true,
autoRenew: true,
});
expect(createRes.status).toBe(201);
const id = createRes.body.data.id;
// Pause auto-renew
const pauseRes = await request(app)
.patch(`/api/v1/budgets/${id}/auto-renew`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ autoRenew: false });
expect(pauseRes.status).toBe(200);
expect(pauseRes.body.data.autoRenew).toBe(false);
// Resume auto-renew
const resumeRes = await request(app)
.patch(`/api/v1/budgets/${id}/auto-renew`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ autoRenew: true });
expect(resumeRes.status).toBe(200);
expect(resumeRes.body.data.autoRenew).toBe(true);
});
});
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