Commit c9ef2c74 authored by ThinhNC's avatar ThinhNC

feat: implement recurring transactions and subscriptions modules

parent 3cb290f0
......@@ -67,9 +67,10 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
từ biến động tỷ giá ngoại tệ. Message nhắc nhở dùng `currency` thực tế của subscription thay vì
hardcode VND. Worker nền chạy `scanAndNotifyNewDiscoveries()` theo chu kỳ `subscriptionScanIntervalMs`
(mặc định 24h, env: `NOTIFICATION_SUBSCRIPTION_SCAN_INTERVAL_MS`), duyệt user theo cursor batch 50,
chỉ notify các subscription có `confidenceScore >= 0.85` và chưa được link với reminder; dedupKey
theo ngày tránh gửi lặp. Dùng `NotificationType.SYSTEM` — không cần thêm enum/migration mới.
API convert-to-reminder hỗ trợ `remindDaysBefore` (0-30 ngày, mặc định: 2 ngày cho MONTHLY, 7 ngày cho YEARLY, 0 ngày cho WEEKLY/DAILY); ngày kích hoạt thực tế `remindAt``nextTriggerAt` được trừ tương ứng từ ngày gia hạn gốc.
chỉ notify các subscription có `confidenceScore >= 0.85` và chưa được link với recurring schedule hoặc reminder; dedupKey
theo ngày tránh gửi lặp. Notification dẫn về `actionUrl: /recurring-transactions` để người dùng thêm vào lịch tự động.
API discover kiểm tra cả `RecurringTransactionSchedule` (`isLinkedToSchedule`) để không gợi ý lại các khoản đã lên lịch.
API convert-to-reminder tiếp tục hỗ trợ `remindDaysBefore` (0-30 ngày) cho các tác vụ cần tạo lịch nhắc.
- Giao dịch tự động định kỳ lưu template/lịch riêng với `DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`,
hỗ trợ pause/resume, ngày kết thúc và chính sách `SKIP`/`CATCH_UP`. Worker dùng business date
UTC+7, distributed lock và occurrence unique `(scheduleId, scheduledFor)`; bản ghi Transaction
......@@ -133,6 +134,7 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
`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)`.
- Giao dịch tự động định kỳ hỗ trợ thông báo nhắc nhở trước hạn thanh toán qua trường tùy chọn `remindDaysBefore` (0–30 ngày). Nhắc nhở được liên kết nguyên tử với bảng Reminder (`type: RECURRING_PAYMENT`, `actionUrl: /recurring-transactions?id=${scheduleId}&remindDaysBefore=${days}`) không cần migration DB; tự động xóa, cập nhật, tạm dừng hoặc khôi phục đồng bộ theo trạng thái của lịch giao dịch. Thuật toán phát hiện Subscription tự động quét giao dịch định kỳ, phát hiện tăng giá cước (> 5.0%), liên kết trực tiếp vào lịch giao dịch tự động và loại trừ các dịch vụ đã được lên lịch.
Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của auth đã
được commit trước đó.
......
......@@ -24,6 +24,7 @@ export interface CreateRecurringTransactionDto {
endDate?: BusinessDate | null;
missedRunPolicy: RecurringTransactionMissedRunPolicy;
isActive: boolean;
remindDaysBefore?: number | null;
}
export type UpdateRecurringTransactionDto = Partial<Omit<
......
......@@ -47,7 +47,10 @@ export type RecurringTransactionScheduleRecord = Prisma.RecurringTransactionSche
export type RecurringTransactionDbClient = Prisma.TransactionClient;
function toScheduleResponse(record: RecurringTransactionScheduleRecord) {
function toScheduleResponse(
record: RecurringTransactionScheduleRecord,
remindDaysBefore?: number | null,
) {
const { userId: _userId, ...schedule } = record;
return {
...schedule,
......@@ -55,6 +58,7 @@ function toScheduleResponse(record: RecurringTransactionScheduleRecord) {
anchorDate: prismaDateToBusinessDate(schedule.anchorDate),
endDate: schedule.endDate ? prismaDateToBusinessDate(schedule.endDate) : null,
nextRunAt: schedule.nextRunAt ? prismaDateToBusinessDate(schedule.nextRunAt) : null,
remindDaysBefore: remindDaysBefore ?? null,
};
}
......@@ -85,7 +89,7 @@ export class RecurringTransactionRepository {
...(query.isActive !== undefined ? { isActive: query.isActive } : {}),
};
const skip = (query.page - 1) * query.limit;
const [records, total] = await prisma.$transaction([
const [records, total, reminders] = await prisma.$transaction([
prisma.recurringTransactionSchedule.findMany({
where,
select: scheduleSelect,
......@@ -94,10 +98,30 @@ export class RecurringTransactionRepository {
take: query.limit,
}),
prisma.recurringTransactionSchedule.count({ where }),
prisma.reminder.findMany({
where: {
userId,
actionUrl: { startsWith: '/recurring-transactions?id=' },
isActive: true,
},
select: { actionUrl: true },
}),
]);
const reminderMap = new Map<string, number>();
for (const r of reminders) {
if (r.actionUrl) {
const match = r.actionUrl.match(/id=([^&]+)(?:&remindDaysBefore=(\d+))?/);
if (match) {
const id = match[1];
const days = match[2] !== undefined ? parseInt(match[2], 10) : 0;
reminderMap.set(id, days);
}
}
}
return {
data: records.map(toScheduleResponse),
data: records.map((record) => toScheduleResponse(record, reminderMap.get(record.id))),
meta: {
total,
page: query.page,
......@@ -108,11 +132,28 @@ export class RecurringTransactionRepository {
}
async findById(userId: string, id: string) {
const record = await prisma.recurringTransactionSchedule.findFirst({
where: { id, userId, deletedAt: null },
select: scheduleSelect,
});
return record ? toScheduleResponse(record) : null;
const [record, reminder] = await Promise.all([
prisma.recurringTransactionSchedule.findFirst({
where: { id, userId, deletedAt: null },
select: scheduleSelect,
}),
prisma.reminder.findFirst({
where: {
userId,
actionUrl: { startsWith: `/recurring-transactions?id=${id}` },
isActive: true,
},
select: { actionUrl: true },
}),
]);
let remindDaysBefore: number | null = null;
if (reminder?.actionUrl) {
const match = reminder.actionUrl.match(/&remindDaysBefore=(\d+)/);
remindDaysBefore = match ? parseInt(match[1], 10) : 0;
}
return record ? toScheduleResponse(record, remindDaysBefore) : null;
}
async findExistingSubscriptionSchedule(
......
......@@ -3,14 +3,20 @@ import {
NotificationSourceType,
NotificationType,
Prisma,
RecurringTransactionFrequency,
RecurringTransactionMissedRunPolicy,
ReminderFrequency,
ReminderType,
} from '@prisma/client';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import {
addBusinessDays,
businessDateToPrismaDate,
businessWallTimeToInstant,
BusinessDate,
instantToBusinessDate,
instantToBusinessWallTime,
prismaDateToBusinessDate,
} from '../../common/date-time/business-time';
import { cacheService } from '../../common/services/cache.service';
......@@ -76,7 +82,7 @@ export class RecurringTransactionService {
data.type,
transaction,
);
return this.repository.create({
const created = await this.repository.create({
userId,
walletId: data.walletId,
categoryId: data.categoryId,
......@@ -92,6 +98,12 @@ export class RecurringTransactionService {
missedRunPolicy: data.missedRunPolicy,
isActive: data.isActive && nextRunAt !== null,
}, transaction);
if (data.remindDaysBefore !== undefined) {
await this.syncReminder(userId, created.id, created, data.remindDaysBefore, transaction);
}
return created;
});
}
......@@ -141,7 +153,7 @@ export class RecurringTransactionService {
);
}
return this.repository.update(id, {
const updated = await this.repository.update(id, {
walletId,
categoryId,
amount: data.amount ?? current.amount,
......@@ -156,12 +168,25 @@ export class RecurringTransactionService {
missedRunPolicy,
isActive: current.isActive && nextRunAt !== null,
}, transaction);
if (data.remindDaysBefore !== undefined) {
await this.syncReminder(userId, id, updated, data.remindDaysBefore, transaction);
}
return updated;
});
}
async pause(userId: string, id: string) {
return this.repository.runSerializable(async (transaction) => {
await this.findRecord(userId, id, transaction);
await transaction.reminder.updateMany({
where: {
userId,
actionUrl: { startsWith: `/recurring-transactions?id=${id}` },
},
data: { isActive: false },
});
return this.repository.update(id, { isActive: false }, transaction);
});
}
......@@ -206,6 +231,13 @@ export class RecurringTransactionService {
ERROR_CODE.RECURRING_TRANSACTION_SCHEDULE_INVALID,
);
}
await transaction.reminder.updateMany({
where: {
userId,
actionUrl: { startsWith: `/recurring-transactions?id=${id}` },
},
data: { isActive: true },
});
return this.repository.update(id, {
isActive: true,
nextRunAt: businessDateToPrismaDate(nextRunAt),
......@@ -216,6 +248,12 @@ export class RecurringTransactionService {
async remove(userId: string, id: string) {
await this.repository.runSerializable(async (transaction) => {
await this.findRecord(userId, id, transaction);
await transaction.reminder.deleteMany({
where: {
userId,
actionUrl: { startsWith: `/recurring-transactions?id=${id}` },
},
});
await this.repository.archive(id, transaction);
});
return { id };
......@@ -481,4 +519,76 @@ export class RecurringTransactionService {
);
}
}
private async syncReminder(
userId: string,
scheduleId: string,
schedule: {
description?: string | null;
frequency: RecurringTransactionFrequency;
repeatInterval: number;
anchorDate: BusinessDate;
endDate?: BusinessDate | null;
nextRunAt?: BusinessDate | null;
isActive: boolean;
},
remindDaysBefore: number | null | undefined,
transaction: RecurringTransactionDbClient,
) {
await transaction.reminder.deleteMany({
where: {
userId,
actionUrl: { startsWith: `/recurring-transactions?id=${scheduleId}` },
},
});
if (remindDaysBefore === null || remindDaysBefore === undefined) {
return;
}
const nextRunBusinessDate = schedule.nextRunAt ?? schedule.anchorDate;
const reminderBusinessDate = addBusinessDays(nextRunBusinessDate, -remindDaysBefore);
const remindAt = businessWallTimeToInstant(reminderBusinessDate, '09:00:00');
const now = new Date();
let nextTriggerAt: Date | null = remindAt;
if (remindAt <= now) {
nextTriggerAt = now;
}
const endAt = schedule.endDate
? businessWallTimeToInstant(
addBusinessDays(schedule.endDate, -remindDaysBefore),
'23:59:59',
)
: null;
const frequencyMap: Record<RecurringTransactionFrequency, ReminderFrequency> = {
[RecurringTransactionFrequency.DAILY]: ReminderFrequency.DAILY,
[RecurringTransactionFrequency.WEEKLY]: ReminderFrequency.WEEKLY,
[RecurringTransactionFrequency.MONTHLY]: ReminderFrequency.MONTHLY,
[RecurringTransactionFrequency.YEARLY]: ReminderFrequency.YEARLY,
};
const actionUrl = `/recurring-transactions?id=${scheduleId}&remindDaysBefore=${remindDaysBefore}`;
const desc = schedule.description ? ` (${schedule.description})` : '';
await transaction.reminder.create({
data: {
userId,
type: ReminderType.RECURRING_PAYMENT,
title: `Nhắc thanh toán giao dịch định kỳ${desc}`,
message: `Giao dịch định kỳ${desc} sắp đến hạn thực hiện.`,
remindAt,
frequency: frequencyMap[schedule.frequency] ?? ReminderFrequency.MONTHLY,
repeatInterval: schedule.repeatInterval,
endAt,
nextTriggerAt: schedule.isActive ? nextTriggerAt : null,
actionUrl,
isActive: schedule.isActive,
},
});
}
}
......@@ -39,6 +39,7 @@ const scheduleFields = {
anchorDate: dateSchema,
endDate: dateSchema.nullable().optional(),
missedRunPolicy: missedRunPolicySchema.default('SKIP'),
remindDaysBefore: z.coerce.number().int().min(0).max(30).nullable().optional(),
};
export const recurringTransactionParamsSchema = z.object({
......
......@@ -49,6 +49,7 @@ export class SubscriptionDiscoveryEngine {
static discover(
transactions: RawSubscriptionTxn[],
existingReminderTitles: Set<string>,
existingScheduleDescriptions: Set<string> = new Set(),
): DiscoveredSubscriptionDto[] {
// Group transactions by (cleanMerchant, currency)
const groups = new Map<string, RawSubscriptionTxn[]>();
......@@ -132,14 +133,16 @@ export class SubscriptionDiscoveryEngine {
const countBonus = Math.min(1.0, sorted.length / 5);
const confidenceScore = Math.min(0.99, Math.round((regularity * 0.7 + countBonus * 0.3) * 100) / 100);
// Price drift detection (> 8% increase).
// 8% guards against minor currency-conversion fluctuations for foreign-currency
// subscriptions while still catching real plan price increases (typically 10–30%).
// Price drift detection (> 5% increase).
// 5% guards against minor currency-conversion fluctuations for foreign-currency
// subscriptions while catching real plan price increases (typically 7–30%).
const driftPercent = ((latestAmount - avgAmount) / avgAmount) * 100;
const isPriceDrift = driftPercent > 8.0;
const isPriceDrift = driftPercent > 5.0;
// Check if already linked to a user reminder
const isLinkedToReminder = existingReminderTitles.has(cleanMerchant.toLowerCase());
// Check if already linked to a user reminder or recurring schedule
const lowerMerchant = cleanMerchant.toLowerCase();
const isLinkedToReminder = existingReminderTitles.has(lowerMerchant);
const isLinkedToSchedule = existingScheduleDescriptions.has(lowerMerchant);
const sampleTx = sorted[sorted.length - 1];
......@@ -159,6 +162,7 @@ export class SubscriptionDiscoveryEngine {
isPriceDrift,
priceDriftPercentage: isPriceDrift ? Math.round(driftPercent * 10) / 10 : null,
isLinkedToReminder,
isLinkedToSchedule,
});
});
......
......@@ -16,6 +16,7 @@ export interface DiscoveredSubscriptionDto {
isPriceDrift: boolean;
priceDriftPercentage: number | null;
isLinkedToReminder: boolean;
isLinkedToSchedule?: boolean;
}
export interface ConvertSubscriptionToReminderDto {
......
......@@ -58,6 +58,25 @@ export class SubscriptionRepository {
return new Set(reminders.map((r) => r.title.toLowerCase()));
}
async getExistingRecurringScheduleDescriptions(userId: string): Promise<Set<string>> {
const schedules = await prisma.recurringTransactionSchedule.findMany({
where: {
userId,
isActive: true,
deletedAt: null,
},
select: {
description: true,
},
});
return new Set(
schedules
.map((s) => s.description?.toLowerCase().trim())
.filter((desc): desc is string => Boolean(desc)),
);
}
async convertToReminder(userId: string, input: ConvertSubscriptionToReminderDto) {
const defaultDays = input.frequency === 'MONTHLY' ? 2 : input.frequency === 'YEARLY' ? 7 : 0;
const remindDaysBefore = Math.max(0, input.remindDaysBefore ?? defaultDays);
......
......@@ -22,14 +22,16 @@ export class SubscriptionService {
private readonly notificationService = new NotificationService();
async discoverSubscriptions(userId: string): Promise<DiscoveryReportDto> {
const [transactions, existingReminders] = await Promise.all([
const [transactions, existingReminders, existingSchedules] = await Promise.all([
this.repository.getHistoricalExpenseTransactions(userId, SCAN_HISTORY_DAYS),
this.repository.getExistingReminderTitles(userId),
this.repository.getExistingRecurringScheduleDescriptions(userId),
]);
const items = SubscriptionDiscoveryEngine.discover(
transactions,
existingReminders,
existingSchedules,
);
return {
......@@ -81,7 +83,7 @@ export class SubscriptionService {
// Only notify for high-confidence, unlinked subscriptions
const candidates = report.items.filter(
(item) => item.confidenceScore >= 0.85 && !item.isLinkedToReminder,
(item) => item.confidenceScore >= 0.85 && !item.isLinkedToSchedule && !item.isLinkedToReminder,
);
for (const item of candidates) {
......@@ -93,11 +95,11 @@ export class SubscriptionService {
: NotificationPriority.NORMAL,
title: item.isPriceDrift
? `Giá ${item.merchantName} đã thay đổi`
: `Phát hiện gói cước định kỳ: ${item.merchantName}`,
: `Phát hiện giao dịch định kỳ: ${item.merchantName}`,
message: item.isPriceDrift
? `${item.merchantName} tăng giá ${item.priceDriftPercentage?.toFixed(1)}% so vi trung bình. Mun theo dõi không?`
: `${item.merchantName} xut hin ${item.occurrenceCount} ln (${item.frequency.toLowerCase()}). Thêm vào danh sách theo dõi?`,
actionUrl: '/subscriptions',
? `${item.merchantName} tăng giá ${item.priceDriftPercentage?.toFixed(1)}% so vi trung bình. Thêm vào lch t động?`
: `${item.merchantName} xut hin ${item.occurrenceCount} ln (${item.frequency.toLowerCase()}). Thêm vào lch giao dch t động?`,
actionUrl: '/recurring-transactions',
sourceType: NotificationSourceType.SYSTEM,
sourceId: null,
data: {
......
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