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),
......
This diff is collapsed.
......@@ -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