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ê ...@@ -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ì 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` 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, (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 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. Dùng `NotificationType.SYSTEM` — không cần thêm enum/migration mới. 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 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. 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`, - 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
...@@ -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ê ...@@ -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`, `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ệ `autoRenew`, `recurrenceGroupId`, `rolloverMode`, `rolloverAmount`, `autoRenewUntil`), quan hệ phả hệ
chu kỳ (`parentBudgetId`), enum `BudgetRolloverMode` và ràng buộc duy nhất `(recurrenceGroupId, startDate)`. 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 đã Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của auth đã
được commit trước đó. được commit trước đó.
......
...@@ -24,6 +24,7 @@ export interface CreateRecurringTransactionDto { ...@@ -24,6 +24,7 @@ export interface CreateRecurringTransactionDto {
endDate?: BusinessDate | null; endDate?: BusinessDate | null;
missedRunPolicy: RecurringTransactionMissedRunPolicy; missedRunPolicy: RecurringTransactionMissedRunPolicy;
isActive: boolean; isActive: boolean;
remindDaysBefore?: number | null;
} }
export type UpdateRecurringTransactionDto = Partial<Omit< export type UpdateRecurringTransactionDto = Partial<Omit<
......
...@@ -47,7 +47,10 @@ export type RecurringTransactionScheduleRecord = Prisma.RecurringTransactionSche ...@@ -47,7 +47,10 @@ export type RecurringTransactionScheduleRecord = Prisma.RecurringTransactionSche
export type RecurringTransactionDbClient = Prisma.TransactionClient; export type RecurringTransactionDbClient = Prisma.TransactionClient;
function toScheduleResponse(record: RecurringTransactionScheduleRecord) { function toScheduleResponse(
record: RecurringTransactionScheduleRecord,
remindDaysBefore?: number | null,
) {
const { userId: _userId, ...schedule } = record; const { userId: _userId, ...schedule } = record;
return { return {
...schedule, ...schedule,
...@@ -55,6 +58,7 @@ function toScheduleResponse(record: RecurringTransactionScheduleRecord) { ...@@ -55,6 +58,7 @@ function toScheduleResponse(record: RecurringTransactionScheduleRecord) {
anchorDate: prismaDateToBusinessDate(schedule.anchorDate), anchorDate: prismaDateToBusinessDate(schedule.anchorDate),
endDate: schedule.endDate ? prismaDateToBusinessDate(schedule.endDate) : null, endDate: schedule.endDate ? prismaDateToBusinessDate(schedule.endDate) : null,
nextRunAt: schedule.nextRunAt ? prismaDateToBusinessDate(schedule.nextRunAt) : null, nextRunAt: schedule.nextRunAt ? prismaDateToBusinessDate(schedule.nextRunAt) : null,
remindDaysBefore: remindDaysBefore ?? null,
}; };
} }
...@@ -85,7 +89,7 @@ export class RecurringTransactionRepository { ...@@ -85,7 +89,7 @@ export class RecurringTransactionRepository {
...(query.isActive !== undefined ? { isActive: query.isActive } : {}), ...(query.isActive !== undefined ? { isActive: query.isActive } : {}),
}; };
const skip = (query.page - 1) * query.limit; const skip = (query.page - 1) * query.limit;
const [records, total] = await prisma.$transaction([ const [records, total, reminders] = await prisma.$transaction([
prisma.recurringTransactionSchedule.findMany({ prisma.recurringTransactionSchedule.findMany({
where, where,
select: scheduleSelect, select: scheduleSelect,
...@@ -94,10 +98,30 @@ export class RecurringTransactionRepository { ...@@ -94,10 +98,30 @@ export class RecurringTransactionRepository {
take: query.limit, take: query.limit,
}), }),
prisma.recurringTransactionSchedule.count({ where }), 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 { return {
data: records.map(toScheduleResponse), data: records.map((record) => toScheduleResponse(record, reminderMap.get(record.id))),
meta: { meta: {
total, total,
page: query.page, page: query.page,
...@@ -108,11 +132,28 @@ export class RecurringTransactionRepository { ...@@ -108,11 +132,28 @@ export class RecurringTransactionRepository {
} }
async findById(userId: string, id: string) { async findById(userId: string, id: string) {
const record = await prisma.recurringTransactionSchedule.findFirst({ const [record, reminder] = await Promise.all([
prisma.recurringTransactionSchedule.findFirst({
where: { id, userId, deletedAt: null }, where: { id, userId, deletedAt: null },
select: scheduleSelect, select: scheduleSelect,
}); }),
return record ? toScheduleResponse(record) : null; 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( async findExistingSubscriptionSchedule(
......
...@@ -3,14 +3,20 @@ import { ...@@ -3,14 +3,20 @@ import {
NotificationSourceType, NotificationSourceType,
NotificationType, NotificationType,
Prisma, Prisma,
RecurringTransactionFrequency,
RecurringTransactionMissedRunPolicy, RecurringTransactionMissedRunPolicy,
ReminderFrequency,
ReminderType,
} from '@prisma/client'; } from '@prisma/client';
import { AppError } from '../../common/errors/app-error'; import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from '../../common/errors/error-code';
import { import {
addBusinessDays,
businessDateToPrismaDate, businessDateToPrismaDate,
businessWallTimeToInstant,
BusinessDate, BusinessDate,
instantToBusinessDate, instantToBusinessDate,
instantToBusinessWallTime,
prismaDateToBusinessDate, prismaDateToBusinessDate,
} from '../../common/date-time/business-time'; } from '../../common/date-time/business-time';
import { cacheService } from '../../common/services/cache.service'; import { cacheService } from '../../common/services/cache.service';
...@@ -76,7 +82,7 @@ export class RecurringTransactionService { ...@@ -76,7 +82,7 @@ export class RecurringTransactionService {
data.type, data.type,
transaction, transaction,
); );
return this.repository.create({ const created = await this.repository.create({
userId, userId,
walletId: data.walletId, walletId: data.walletId,
categoryId: data.categoryId, categoryId: data.categoryId,
...@@ -92,6 +98,12 @@ export class RecurringTransactionService { ...@@ -92,6 +98,12 @@ export class RecurringTransactionService {
missedRunPolicy: data.missedRunPolicy, missedRunPolicy: data.missedRunPolicy,
isActive: data.isActive && nextRunAt !== null, isActive: data.isActive && nextRunAt !== null,
}, transaction); }, transaction);
if (data.remindDaysBefore !== undefined) {
await this.syncReminder(userId, created.id, created, data.remindDaysBefore, transaction);
}
return created;
}); });
} }
...@@ -141,7 +153,7 @@ export class RecurringTransactionService { ...@@ -141,7 +153,7 @@ export class RecurringTransactionService {
); );
} }
return this.repository.update(id, { const updated = await this.repository.update(id, {
walletId, walletId,
categoryId, categoryId,
amount: data.amount ?? current.amount, amount: data.amount ?? current.amount,
...@@ -156,12 +168,25 @@ export class RecurringTransactionService { ...@@ -156,12 +168,25 @@ export class RecurringTransactionService {
missedRunPolicy, missedRunPolicy,
isActive: current.isActive && nextRunAt !== null, isActive: current.isActive && nextRunAt !== null,
}, transaction); }, transaction);
if (data.remindDaysBefore !== undefined) {
await this.syncReminder(userId, id, updated, data.remindDaysBefore, transaction);
}
return updated;
}); });
} }
async pause(userId: string, id: string) { async pause(userId: string, id: string) {
return this.repository.runSerializable(async (transaction) => { return this.repository.runSerializable(async (transaction) => {
await this.findRecord(userId, id, 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); return this.repository.update(id, { isActive: false }, transaction);
}); });
} }
...@@ -206,6 +231,13 @@ export class RecurringTransactionService { ...@@ -206,6 +231,13 @@ export class RecurringTransactionService {
ERROR_CODE.RECURRING_TRANSACTION_SCHEDULE_INVALID, 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, { return this.repository.update(id, {
isActive: true, isActive: true,
nextRunAt: businessDateToPrismaDate(nextRunAt), nextRunAt: businessDateToPrismaDate(nextRunAt),
...@@ -216,6 +248,12 @@ export class RecurringTransactionService { ...@@ -216,6 +248,12 @@ export class RecurringTransactionService {
async remove(userId: string, id: string) { async remove(userId: string, id: string) {
await this.repository.runSerializable(async (transaction) => { await this.repository.runSerializable(async (transaction) => {
await this.findRecord(userId, id, 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); await this.repository.archive(id, transaction);
}); });
return { id }; return { id };
...@@ -481,4 +519,76 @@ export class RecurringTransactionService { ...@@ -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 = { ...@@ -39,6 +39,7 @@ const scheduleFields = {
anchorDate: dateSchema, anchorDate: dateSchema,
endDate: dateSchema.nullable().optional(), endDate: dateSchema.nullable().optional(),
missedRunPolicy: missedRunPolicySchema.default('SKIP'), missedRunPolicy: missedRunPolicySchema.default('SKIP'),
remindDaysBefore: z.coerce.number().int().min(0).max(30).nullable().optional(),
}; };
export const recurringTransactionParamsSchema = z.object({ export const recurringTransactionParamsSchema = z.object({
......
...@@ -49,6 +49,7 @@ export class SubscriptionDiscoveryEngine { ...@@ -49,6 +49,7 @@ export class SubscriptionDiscoveryEngine {
static discover( static discover(
transactions: RawSubscriptionTxn[], transactions: RawSubscriptionTxn[],
existingReminderTitles: Set<string>, existingReminderTitles: Set<string>,
existingScheduleDescriptions: Set<string> = new Set(),
): DiscoveredSubscriptionDto[] { ): DiscoveredSubscriptionDto[] {
// Group transactions by (cleanMerchant, currency) // Group transactions by (cleanMerchant, currency)
const groups = new Map<string, RawSubscriptionTxn[]>(); const groups = new Map<string, RawSubscriptionTxn[]>();
...@@ -132,14 +133,16 @@ export class SubscriptionDiscoveryEngine { ...@@ -132,14 +133,16 @@ 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 (> 8% increase). // Price drift detection (> 5% increase).
// 8% guards against minor currency-conversion fluctuations for foreign-currency // 5% guards against minor currency-conversion fluctuations for foreign-currency
// subscriptions while still catching real plan price increases (typically 10–30%). // subscriptions while catching real plan price increases (typically 7–30%).
const driftPercent = ((latestAmount - avgAmount) / avgAmount) * 100; const driftPercent = ((latestAmount - avgAmount) / avgAmount) * 100;
const isPriceDrift = driftPercent > 8.0; const isPriceDrift = driftPercent > 5.0;
// Check if already linked to a user reminder // Check if already linked to a user reminder or recurring schedule
const isLinkedToReminder = existingReminderTitles.has(cleanMerchant.toLowerCase()); const lowerMerchant = cleanMerchant.toLowerCase();
const isLinkedToReminder = existingReminderTitles.has(lowerMerchant);
const isLinkedToSchedule = existingScheduleDescriptions.has(lowerMerchant);
const sampleTx = sorted[sorted.length - 1]; const sampleTx = sorted[sorted.length - 1];
...@@ -159,6 +162,7 @@ export class SubscriptionDiscoveryEngine { ...@@ -159,6 +162,7 @@ export class SubscriptionDiscoveryEngine {
isPriceDrift, isPriceDrift,
priceDriftPercentage: isPriceDrift ? Math.round(driftPercent * 10) / 10 : null, priceDriftPercentage: isPriceDrift ? Math.round(driftPercent * 10) / 10 : null,
isLinkedToReminder, isLinkedToReminder,
isLinkedToSchedule,
}); });
}); });
......
...@@ -16,6 +16,7 @@ export interface DiscoveredSubscriptionDto { ...@@ -16,6 +16,7 @@ export interface DiscoveredSubscriptionDto {
isPriceDrift: boolean; isPriceDrift: boolean;
priceDriftPercentage: number | null; priceDriftPercentage: number | null;
isLinkedToReminder: boolean; isLinkedToReminder: boolean;
isLinkedToSchedule?: boolean;
} }
export interface ConvertSubscriptionToReminderDto { export interface ConvertSubscriptionToReminderDto {
......
...@@ -58,6 +58,25 @@ export class SubscriptionRepository { ...@@ -58,6 +58,25 @@ export class SubscriptionRepository {
return new Set(reminders.map((r) => r.title.toLowerCase())); 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) { async convertToReminder(userId: string, input: ConvertSubscriptionToReminderDto) {
const defaultDays = input.frequency === 'MONTHLY' ? 2 : input.frequency === 'YEARLY' ? 7 : 0; const defaultDays = input.frequency === 'MONTHLY' ? 2 : input.frequency === 'YEARLY' ? 7 : 0;
const remindDaysBefore = Math.max(0, input.remindDaysBefore ?? defaultDays); const remindDaysBefore = Math.max(0, input.remindDaysBefore ?? defaultDays);
......
...@@ -22,14 +22,16 @@ export class SubscriptionService { ...@@ -22,14 +22,16 @@ export class SubscriptionService {
private readonly notificationService = new NotificationService(); 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, existingSchedules] = await Promise.all([
this.repository.getHistoricalExpenseTransactions(userId, SCAN_HISTORY_DAYS), this.repository.getHistoricalExpenseTransactions(userId, SCAN_HISTORY_DAYS),
this.repository.getExistingReminderTitles(userId), this.repository.getExistingReminderTitles(userId),
this.repository.getExistingRecurringScheduleDescriptions(userId),
]); ]);
const items = SubscriptionDiscoveryEngine.discover( const items = SubscriptionDiscoveryEngine.discover(
transactions, transactions,
existingReminders, existingReminders,
existingSchedules,
); );
return { return {
...@@ -81,7 +83,7 @@ export class SubscriptionService { ...@@ -81,7 +83,7 @@ export class SubscriptionService {
// Only notify for high-confidence, unlinked subscriptions // Only notify for high-confidence, unlinked subscriptions
const candidates = report.items.filter( 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) { for (const item of candidates) {
...@@ -93,11 +95,11 @@ export class SubscriptionService { ...@@ -93,11 +95,11 @@ export class SubscriptionService {
: NotificationPriority.NORMAL, : NotificationPriority.NORMAL,
title: item.isPriceDrift title: item.isPriceDrift
? `Giá ${item.merchantName} đã thay đổi` ? `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 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} 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 danh sách theo dõi?`, : `${item.merchantName} xut hin ${item.occurrenceCount} ln (${item.frequency.toLowerCase()}). Thêm vào lch giao dch t động?`,
actionUrl: '/subscriptions', actionUrl: '/recurring-transactions',
sourceType: NotificationSourceType.SYSTEM, sourceType: NotificationSourceType.SYSTEM,
sourceId: null, sourceId: null,
data: { 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