Commit 3cb290f0 authored by ThinhNC's avatar ThinhNC

feat: implement subscription management module with automated discovery engine...

feat: implement subscription management module with automated discovery engine and background worker
parent a2887c4d
...@@ -62,6 +62,14 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê ...@@ -62,6 +62,14 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
- Reminder hỗ trợ `ONCE`, `DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`, có khoảng lặp và ngày kết - Reminder hỗ trợ `ONCE`, `DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`, có khoảng lặp và ngày kết
thúc. Worker nền trong process xử lý reminder, cảnh báo ngân sách/mục tiêu và retry delivery; thúc. Worker nền trong process xử lý reminder, cảnh báo ngân sách/mục tiêu và retry delivery;
có thể tắt hoặc chỉnh chu kỳ bằng các biến `NOTIFICATION_*`. có thể tắt hoặc chỉnh chu kỳ bằng các biến `NOTIFICATION_*`.
- Subscription discovery dùng `SubscriptionDiscoveryEngine` phân tích 180 ngày giao dịch EXPENSE để
phát hiện gói cước định kỳ. Threshold `isPriceDrift` là 8% (không phải 3%) để tránh false positive
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.
- Giao dịch tự động định kỳ lưu template/lịch riêng với `DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`, - 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 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 UTC+7, distributed lock và occurrence unique `(scheduleId, scheduledFor)`; bản ghi Transaction
......
...@@ -51,6 +51,8 @@ R2_AVATAR_MAX_FILE_SIZE_MB=5 ...@@ -51,6 +51,8 @@ R2_AVATAR_MAX_FILE_SIZE_MB=5
NOTIFICATION_WORKER_ENABLED=true NOTIFICATION_WORKER_ENABLED=true
NOTIFICATION_WORKER_INTERVAL_MS=60000 NOTIFICATION_WORKER_INTERVAL_MS=60000
NOTIFICATION_FINANCIAL_SCAN_INTERVAL_MS=300000 NOTIFICATION_FINANCIAL_SCAN_INTERVAL_MS=300000
# How often (ms) the worker scans users for newly discovered subscriptions. Default: 86400000 (24h). Range: 3600000–86400000.
NOTIFICATION_SUBSCRIPTION_SCAN_INTERVAL_MS=86400000
RECURRING_TRANSACTION_BATCH_LIMIT=100 RECURRING_TRANSACTION_BATCH_LIMIT=100
AI_PROVIDER=gemini AI_PROVIDER=gemini
......
...@@ -92,6 +92,17 @@ export const envConfig = { ...@@ -92,6 +92,17 @@ export const envConfig = {
const value = parseInt(process.env.NOTIFICATION_BACKOFF_BASE_DELAY_MS || '60000', 10); const value = parseInt(process.env.NOTIFICATION_BACKOFF_BASE_DELAY_MS || '60000', 10);
return Number.isFinite(value) && value >= 1000 && value <= 600000 ? value : 60000; return Number.isFinite(value) && value >= 1000 && value <= 600000 ? value : 60000;
})(), })(),
// How often (ms) the worker scans all active users for newly discovered subscriptions.
// Defaults to once every 24 hours. Set to 0 to disable subscription scanning.
subscriptionScanIntervalMs: (() => {
const value = parseInt(
process.env.NOTIFICATION_SUBSCRIPTION_SCAN_INTERVAL_MS || '86400000',
10,
);
return Number.isFinite(value) && value >= 3_600_000 && value <= 86_400_000
? value
: 86_400_000;
})(),
}, },
webhooks: { webhooks: {
concurrency: (() => { concurrency: (() => {
......
...@@ -4,6 +4,7 @@ import { NotificationDeliveryService } from './notification-delivery.service'; ...@@ -4,6 +4,7 @@ import { NotificationDeliveryService } from './notification-delivery.service';
import { NotificationService } from './notification.service'; import { NotificationService } from './notification.service';
import { RecurringTransactionService } from '../recurring-transactions/recurring-transaction.service'; import { RecurringTransactionService } from '../recurring-transactions/recurring-transaction.service';
import { BudgetService } from '../budgets/budget.service'; import { BudgetService } from '../budgets/budget.service';
import { SubscriptionService } from '../subscriptions/subscription.service';
import { lockService } from '../../common/services/lock.service'; import { lockService } from '../../common/services/lock.service';
...@@ -13,9 +14,11 @@ export class NotificationWorker { ...@@ -13,9 +14,11 @@ export class NotificationWorker {
private readonly deliveryService = new NotificationDeliveryService(); private readonly deliveryService = new NotificationDeliveryService();
private readonly recurringTransactionService = new RecurringTransactionService(); private readonly recurringTransactionService = new RecurringTransactionService();
private readonly budgetService = new BudgetService(); private readonly budgetService = new BudgetService();
private readonly subscriptionService = new SubscriptionService();
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
private running = false; private running = false;
private lastFinancialScanAt = 0; private lastFinancialScanAt = 0;
private lastSubscriptionScanAt = 0;
start() { start() {
if (!envConfig.notifications.workerEnabled || this.timer) { if (!envConfig.notifications.workerEnabled || this.timer) {
...@@ -91,6 +94,23 @@ export class NotificationWorker { ...@@ -91,6 +94,23 @@ export class NotificationWorker {
console.error('Notification worker failed to scan financial alerts', error); console.error('Notification worker failed to scan financial alerts', error);
} }
} }
if (
now.getTime() - this.lastSubscriptionScanAt
>= envConfig.notifications.subscriptionScanIntervalMs
) {
try {
const result = await this.subscriptionService.scanAndNotifyNewDiscoveries();
this.lastSubscriptionScanAt = now.getTime();
if (result.notificationsSent > 0) {
console.info(
`[NotificationWorker] Subscription scan: ${result.usersScanned} users, ${result.notificationsSent} notifications sent`,
);
}
} catch (error) {
console.error('Notification worker failed to scan subscriptions', error);
}
}
} finally { } finally {
this.running = false; this.running = false;
await lockService.release(lockKey, lockToken); await lockService.release(lockKey, lockToken);
......
...@@ -132,9 +132,11 @@ export class SubscriptionDiscoveryEngine { ...@@ -132,9 +132,11 @@ export class SubscriptionDiscoveryEngine {
const countBonus = Math.min(1.0, sorted.length / 5); 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); const confidenceScore = Math.min(0.99, Math.round((regularity * 0.7 + countBonus * 0.3) * 100) / 100);
// Price drift detection (> 3% increase) // 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%).
const driftPercent = ((latestAmount - avgAmount) / avgAmount) * 100; const driftPercent = ((latestAmount - avgAmount) / avgAmount) * 100;
const isPriceDrift = driftPercent > 3.0; const isPriceDrift = driftPercent > 8.0;
// Check if already linked to a user reminder // Check if already linked to a user reminder
const isLinkedToReminder = existingReminderTitles.has(cleanMerchant.toLowerCase()); const isLinkedToReminder = existingReminderTitles.has(cleanMerchant.toLowerCase());
......
...@@ -21,8 +21,11 @@ export interface DiscoveredSubscriptionDto { ...@@ -21,8 +21,11 @@ export interface DiscoveredSubscriptionDto {
export interface ConvertSubscriptionToReminderDto { export interface ConvertSubscriptionToReminderDto {
merchantName: string; merchantName: string;
amount: string; amount: string;
currency?: string;
frequency: ReminderFrequency; frequency: ReminderFrequency;
remindAt: string; // ISO date string /** Renewal date at 09:00 Vietnam time (ISO). Actual trigger = remindAt − remindDaysBefore days. */
remindAt: string;
remindDaysBefore?: number; // 0 = same day, default: 2 for MONTHLY, 7 for YEARLY, 0 otherwise
categoryId?: string; categoryId?: string;
} }
......
import { ReminderType, TransactionType } from '@prisma/client'; import { ReminderType, TransactionType } from '@prisma/client';
import { prisma } from '../../database/prisma.client'; import { prisma } from '../../database/prisma.client';
import { import {
addBusinessDays,
businessWallTimeToInstant,
instantToBusinessWallTime,
prismaDateToBusinessDate, prismaDateToBusinessDate,
} from '../../common/date-time/business-time'; } from '../../common/date-time/business-time';
import { ConvertSubscriptionToReminderDto } from './subscription.dto'; import { ConvertSubscriptionToReminderDto } from './subscription.dto';
...@@ -56,13 +59,17 @@ export class SubscriptionRepository { ...@@ -56,13 +59,17 @@ export class SubscriptionRepository {
} }
async convertToReminder(userId: string, input: ConvertSubscriptionToReminderDto) { async convertToReminder(userId: string, input: ConvertSubscriptionToReminderDto) {
const remindAtDate = new Date(input.remindAt); const defaultDays = input.frequency === 'MONTHLY' ? 2 : input.frequency === 'YEARLY' ? 7 : 0;
const remindDaysBefore = Math.max(0, input.remindDaysBefore ?? defaultDays);
const renewalWall = instantToBusinessWallTime(new Date(input.remindAt));
const triggerDateStr = addBusinessDays(renewalWall.date, -remindDaysBefore);
const remindAtDate = businessWallTimeToInstant(triggerDateStr, renewalWall.time);
return prisma.reminder.create({ return prisma.reminder.create({
data: { data: {
userId, userId,
title: input.merchantName, title: input.merchantName,
message: `Thanh toán gói cước định kỳ: ${input.merchantName} (${parseFloat(input.amount).toLocaleString()} VND)`, message: `Thanh toán gói cước định kỳ: ${input.merchantName} (${parseFloat(input.amount).toLocaleString('vi-VN')} ${input.currency ?? 'VND'})`,
type: ReminderType.RECURRING_PAYMENT, type: ReminderType.RECURRING_PAYMENT,
frequency: input.frequency, frequency: input.frequency,
repeatInterval: 1, repeatInterval: 1,
...@@ -72,4 +79,32 @@ export class SubscriptionRepository { ...@@ -72,4 +79,32 @@ export class SubscriptionRepository {
}, },
}); });
} }
/**
* Return a batch of user IDs who have had at least one EXPENSE transaction
* in the last `days` days. Uses cursor-based pagination so the caller can
* iterate without loading all users into memory at once.
*/
async findActiveUserIdsBatch(
days: number,
cursor: string | undefined,
batchSize: number,
): Promise<{ userIds: string[]; nextCursor: string | undefined }> {
const since = new Date(Date.now() - days * MILLISECONDS_PER_DAY);
const rows = await prisma.transaction.groupBy({
by: ['userId'],
where: {
type: TransactionType.EXPENSE,
date: { gte: since },
...(cursor ? { userId: { gt: cursor } } : {}),
},
orderBy: { userId: 'asc' },
take: batchSize,
});
const userIds = rows.map((r) => r.userId);
const nextCursor = userIds.length === batchSize ? userIds[userIds.length - 1] : undefined;
return { userIds, nextCursor };
}
} }
import {
NotificationPriority,
NotificationSourceType,
NotificationType,
} from '@prisma/client';
import { import {
ConvertSubscriptionToReminderDto, ConvertSubscriptionToReminderDto,
DiscoveryReportDto, DiscoveryReportDto,
...@@ -6,14 +11,19 @@ import { SubscriptionDiscoveryEngine } from './subscription-engine'; ...@@ -6,14 +11,19 @@ import { SubscriptionDiscoveryEngine } from './subscription-engine';
import { SubscriptionRepository } from './subscription.repository'; import { SubscriptionRepository } from './subscription.repository';
import { RecurringTransactionService } from '../recurring-transactions/recurring-transaction.service'; import { RecurringTransactionService } from '../recurring-transactions/recurring-transaction.service';
import { ConvertSubscriptionToRecurringTransactionDto } from '../recurring-transactions/recurring-transaction.dto'; import { ConvertSubscriptionToRecurringTransactionDto } from '../recurring-transactions/recurring-transaction.dto';
import { NotificationService } from '../notifications/notification.service';
const SCAN_BATCH_SIZE = 50;
const SCAN_HISTORY_DAYS = 180;
export class SubscriptionService { export class SubscriptionService {
private readonly repository = new SubscriptionRepository(); private readonly repository = new SubscriptionRepository();
private readonly recurringTransactionService = new RecurringTransactionService(); private readonly recurringTransactionService = new RecurringTransactionService();
private readonly notificationService = new NotificationService();
async discoverSubscriptions(userId: string): Promise<DiscoveryReportDto> { async discoverSubscriptions(userId: string): Promise<DiscoveryReportDto> {
const [transactions, existingReminders] = await Promise.all([ const [transactions, existingReminders] = await Promise.all([
this.repository.getHistoricalExpenseTransactions(userId, 180), this.repository.getHistoricalExpenseTransactions(userId, SCAN_HISTORY_DAYS),
this.repository.getExistingReminderTitles(userId), this.repository.getExistingReminderTitles(userId),
]); ]);
...@@ -41,4 +51,80 @@ export class SubscriptionService { ...@@ -41,4 +51,80 @@ export class SubscriptionService {
) { ) {
return this.recurringTransactionService.convertSubscription(userId, input); return this.recurringTransactionService.convertSubscription(userId, input);
} }
/**
* Proactive background scan: iterates over all active users in cursor batches,
* runs subscription discovery for each, and creates an IN_APP notification for
* any high-confidence subscriptions that are not yet linked to a reminder.
*
* Uses dedupKey to prevent re-notifying the same merchant within a 24-hour window,
* so this method is safe to call on every worker tick as long as
* the caller gates it with `subscriptionScanIntervalMs`.
*/
async scanAndNotifyNewDiscoveries(): Promise<{ usersScanned: number; notificationsSent: number }> {
let cursor: string | undefined;
let usersScanned = 0;
let notificationsSent = 0;
const scanDate = new Date().toISOString().slice(0, 10); // YYYY-MM-DD, resets dedup daily
do {
const { userIds, nextCursor } = await this.repository.findActiveUserIdsBatch(
SCAN_HISTORY_DAYS,
cursor,
SCAN_BATCH_SIZE,
);
for (const userId of userIds) {
try {
const report = await this.discoverSubscriptions(userId);
usersScanned += 1;
// Only notify for high-confidence, unlinked subscriptions
const candidates = report.items.filter(
(item) => item.confidenceScore >= 0.85 && !item.isLinkedToReminder,
);
for (const item of candidates) {
const notification = await this.notificationService.create({
userId,
type: NotificationType.SYSTEM,
priority: item.isPriceDrift
? NotificationPriority.HIGH
: NotificationPriority.NORMAL,
title: item.isPriceDrift
? `Giá ${item.merchantName} đã thay đổi`
: `Phát hiện gói cước đị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',
sourceType: NotificationSourceType.SYSTEM,
sourceId: null,
data: {
merchantName: item.merchantName,
averageAmount: item.averageAmount,
currency: item.currency,
frequency: item.frequency,
confidenceScore: item.confidenceScore,
isPriceDrift: item.isPriceDrift,
},
// Daily dedup key — one notification per merchant per user per day
dedupKey: `subscription:discovered:${userId}:${item.merchantName}:${item.currency}:${scanDate}`,
});
if (notification) {
notificationsSent += 1;
}
}
} catch (error) {
// Per-user errors must not abort the whole scan
console.error(`[SubscriptionService] scanAndNotify failed for user ${userId}:`, error);
}
}
cursor = nextCursor;
} while (cursor);
return { usersScanned, notificationsSent };
}
} }
...@@ -4,7 +4,10 @@ export { convertSubscriptionToRecurringTransactionSchema } from '../recurring-tr ...@@ -4,7 +4,10 @@ export { convertSubscriptionToRecurringTransactionSchema } from '../recurring-tr
export const convertSubscriptionToReminderSchema = z.object({ export const convertSubscriptionToReminderSchema = z.object({
merchantName: z.string().min(1).max(100), merchantName: z.string().min(1).max(100),
amount: z.string().regex(/^\d+(\.\d{1,2})?$/, 'Amount must be a valid positive number'), amount: z.string().regex(/^\d+(\.\d{1,2})?$/, 'Amount must be a valid positive number'),
currency: z.string().min(3).max(10).optional(),
frequency: z.enum(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']), frequency: z.enum(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']),
remindAt: z.string().datetime(), remindAt: z.string().datetime(),
remindDaysBefore: z.number().int().min(0).max(30).optional(),
categoryId: z.string().uuid().optional(), categoryId: z.string().uuid().optional(),
}); });
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