Commit ee57c7af authored by ThinhNC's avatar ThinhNC

feat(be): implement 5 advanced financial engineering modules

parent 3b47a518
import {
AnomalyEvaluationResultDto,
AnomalyReasonCode,
AnomalySeverity,
} from './anomaly.dto';
export interface AnomalyMathInput {
amount: number;
historicalCategoryAmounts: number[];
recentWalletTxnCount: number;
walletBalance: number;
hourOfDayVietnam: number; // 0 to 23
}
export class AnomalyMathEngine {
static evaluate(input: AnomalyMathInput): AnomalyEvaluationResultDto {
const {
amount,
historicalCategoryAmounts,
recentWalletTxnCount,
walletBalance,
hourOfDayVietnam,
} = input;
const reasonCodes: AnomalyReasonCode[] = [];
let score = 0.0;
const explanations: string[] = [];
let categoryMedian = 0;
let categoryMad = 0;
let modifiedZScore = 0;
const n = historicalCategoryAmounts.length;
if (n >= 3) {
categoryMedian = this.calculateMedian(historicalCategoryAmounts);
const absDeviations = historicalCategoryAmounts.map((x) => Math.abs(x - categoryMedian));
categoryMad = this.calculateMedian(absDeviations);
const effectiveMad = Math.max(categoryMad, categoryMedian * 0.1, 10000);
modifiedZScore = (0.6745 * (amount - categoryMedian)) / effectiveMad;
if (modifiedZScore >= 3.5) {
reasonCodes.push('SPIKE_VS_CATEGORY_MEDIAN');
const factor = (amount / Math.max(1, categoryMedian)).toFixed(1);
score += Math.min(0.85, 0.70 + (modifiedZScore - 3.5) * 0.03);
explanations.push(`Chi tiêu cao gấp ${factor} lần mức trung vị thông thường (${categoryMedian.toLocaleString()} VND) của danh mục.`);
}
} else if (amount >= 5000000 || (walletBalance > 0 && amount >= walletBalance * 0.5)) {
reasonCodes.push('FIRST_TIME_HIGH_VALUE');
score += 0.55;
explanations.push('Giao dịch giá trị lớn trong danh mục chưa có nhiều lịch sử chi tiêu.');
}
// Velocity Burst check
if (recentWalletTxnCount >= 3) {
reasonCodes.push('VELOCITY_BURST');
score += 0.25;
explanations.push(`Phát hiện ${recentWalletTxnCount} giao dịch diễn ra liên tiếp trong thời gian ngắn.`);
}
// Off-peak time check (2am to 5am)
if (hourOfDayVietnam >= 2 && hourOfDayVietnam <= 5) {
reasonCodes.push('OFF_PEAK_SURGE');
score += 0.15;
explanations.push('Giao dịch được tạo vào khung giờ đêm khuya (02:00 - 05:00).');
}
// High percentage of wallet check
let walletPercent: number | null = null;
if (walletBalance > 0) {
walletPercent = (amount / walletBalance) * 100;
if (walletPercent >= 60 && amount >= 1000000) {
reasonCodes.push('HIGH_PERCENTAGE_OF_WALLET');
score += 0.20;
explanations.push(`Khoản chi chiếm ${walletPercent.toFixed(0)}% tổng số dư hiện tại của ví.`);
}
}
// Clamp score to [0, 1]
const finalScore = Math.min(1.0, Math.max(0.0, Math.round(score * 100) / 100));
let severity: AnomalySeverity = 'NORMAL';
if (finalScore >= 0.85) {
severity = 'CRITICAL';
} else if (finalScore >= 0.70) {
severity = 'HIGH';
} else if (finalScore >= 0.50) {
severity = 'ELEVATED';
}
const isAnomaly = finalScore >= 0.70;
const explanation = explanations.length > 0
? explanations.join(' ')
: 'Giao dịch trong giới hạn chi tiêu bình thường.';
return {
isAnomaly,
anomalyScore: finalScore,
severity,
reasonCodes,
explanation,
metrics: {
categoryMedian: categoryMedian.toFixed(2),
categoryMad: categoryMad.toFixed(2),
modifiedZScore: Math.round(modifiedZScore * 100) / 100,
recentWalletTxnCount,
walletBalancePercent: walletPercent ? Math.round(walletPercent * 100) / 100 : null,
},
};
}
private static calculateMedian(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return (sorted[mid - 1] + sorted[mid]) / 2;
}
return sorted[mid];
}
}
import { NextFunction, Request, Response } from 'express';
import { evaluateAnomalySchema } from './anomaly.validation';
import { AnomalyService } from './anomaly.service';
export class AnomalyController {
private readonly service = new AnomalyService();
evaluate = async (req: Request, res: Response, next: NextFunction) => {
try {
const input = evaluateAnomalySchema.parse(req.body);
const data = await this.service.evaluateTransaction(req.user.id, input);
res.json({ success: true, data });
} catch (error) {
next(error);
}
};
getRecent = async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await this.service.getRecentAnomalies(req.user.id);
res.json({ success: true, data });
} catch (error) {
next(error);
}
};
}
export type AnomalyReasonCode =
| 'SPIKE_VS_CATEGORY_MEDIAN'
| 'VELOCITY_BURST'
| 'OFF_PEAK_SURGE'
| 'HIGH_PERCENTAGE_OF_WALLET'
| 'FIRST_TIME_HIGH_VALUE';
export type AnomalySeverity = 'NORMAL' | 'ELEVATED' | 'HIGH' | 'CRITICAL';
export interface EvaluateAnomalyInputDto {
transactionId?: string;
walletId: string;
categoryId: string;
amount: string;
type: string;
occurredAt?: Date;
}
export interface AnomalyEvaluationResultDto {
isAnomaly: boolean;
anomalyScore: number; // 0.00 to 1.00
severity: AnomalySeverity;
reasonCodes: AnomalyReasonCode[];
explanation: string;
metrics: {
categoryMedian: string;
categoryMad: string;
modifiedZScore: number;
recentWalletTxnCount: number;
walletBalancePercent: number | null;
};
}
export interface FlaggedAnomalyTransactionDto {
transactionId: string;
amount: string;
currency: string;
categoryName: string;
walletName: string;
date: string;
anomalyScore: number;
reasonCodes: AnomalyReasonCode[];
explanation: string;
}
import { TransactionType } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import { prismaDateToBusinessDate } from '../../common/date-time/business-time';
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
const MILLISECONDS_PER_MINUTE = 60 * 1000;
export class AnomalyRepository {
async getCategoryHistory(
userId: string,
categoryId: string,
excludeTxnId?: string,
days = 60,
): Promise<number[]> {
const since = new Date(Date.now() - days * MILLISECONDS_PER_DAY);
const txns = await prisma.transaction.findMany({
where: {
userId,
categoryId,
type: TransactionType.EXPENSE,
date: { gte: since },
...(excludeTxnId ? { id: { not: excludeTxnId } } : {}),
},
select: {
amount: true,
},
orderBy: {
date: 'desc',
},
take: 200,
});
return txns.map((t) => t.amount.toNumber());
}
async getRecentWalletTxnCount(
userId: string,
walletId: string,
windowMinutes = 20,
excludeTxnId?: string,
): Promise<number> {
const since = new Date(Date.now() - windowMinutes * MILLISECONDS_PER_MINUTE);
return prisma.transaction.count({
where: {
userId,
walletId,
createdAt: { gte: since },
...(excludeTxnId ? { id: { not: excludeTxnId } } : {}),
},
});
}
async getWalletBalance(userId: string, walletId: string): Promise<number> {
const wallet = await prisma.wallet.findFirst({
where: { id: walletId, userId },
select: { balance: true },
});
return wallet ? wallet.balance.toNumber() : 0;
}
async getRecentExpenseTransactions(userId: string, limit = 50) {
const txns = await prisma.transaction.findMany({
where: {
userId,
type: TransactionType.EXPENSE,
},
include: {
category: { select: { id: true, name: true } },
wallet: { select: { id: true, name: true, currency: true } },
},
orderBy: {
createdAt: 'desc',
},
take: limit,
});
return txns.map((t) => ({
id: t.id,
amount: t.amount.toFixed(2),
currency: t.wallet.currency,
categoryName: t.category.name,
categoryId: t.categoryId,
walletName: t.wallet.name,
walletId: t.walletId,
date: prismaDateToBusinessDate(t.date),
createdAt: t.createdAt,
}));
}
}
import { Router } from 'express';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { AnomalyController } from './anomaly.controller';
const router = Router();
const controller = new AnomalyController();
router.use(authMiddleware);
router.post('/evaluate', controller.evaluate);
router.get('/recent', controller.getRecent);
export default router;
import {
AnomalyEvaluationResultDto,
EvaluateAnomalyInputDto,
FlaggedAnomalyTransactionDto,
} from './anomaly.dto';
import { AnomalyMathEngine } from './anomaly-math';
import { AnomalyRepository } from './anomaly.repository';
export class AnomalyService {
private readonly repository = new AnomalyRepository();
async evaluateTransaction(
userId: string,
input: EvaluateAnomalyInputDto,
): Promise<AnomalyEvaluationResultDto> {
const amountNum = parseFloat(input.amount);
const dateObj = input.occurredAt ?? new Date();
// Vietnam timezone hour (UTC+7)
const hourOfDayVietnam = (dateObj.getUTCHours() + 7) % 24;
const [categoryHistory, recentTxnCount, walletBalance] = await Promise.all([
this.repository.getCategoryHistory(
userId,
input.categoryId,
input.transactionId,
),
this.repository.getRecentWalletTxnCount(
userId,
input.walletId,
20,
input.transactionId,
),
this.repository.getWalletBalance(userId, input.walletId),
]);
return AnomalyMathEngine.evaluate({
amount: amountNum,
historicalCategoryAmounts: categoryHistory,
recentWalletTxnCount: recentTxnCount,
walletBalance,
hourOfDayVietnam,
});
}
async getRecentAnomalies(userId: string): Promise<FlaggedAnomalyTransactionDto[]> {
const transactions = await this.repository.getRecentExpenseTransactions(userId, 40);
const flagged: FlaggedAnomalyTransactionDto[] = [];
for (const tx of transactions) {
const evaluation = await this.evaluateTransaction(userId, {
transactionId: tx.id,
walletId: tx.walletId,
categoryId: tx.categoryId,
amount: tx.amount,
type: 'EXPENSE',
occurredAt: tx.createdAt,
});
if (evaluation.isAnomaly) {
flagged.push({
transactionId: tx.id,
amount: tx.amount,
currency: tx.currency,
categoryName: tx.categoryName,
walletName: tx.walletName,
date: tx.date,
anomalyScore: evaluation.anomalyScore,
reasonCodes: evaluation.reasonCodes,
explanation: evaluation.explanation,
});
}
}
return flagged;
}
}
import { z } from 'zod';
export const evaluateAnomalySchema = z.object({
transactionId: z.string().uuid().optional(),
walletId: z.string().uuid(),
categoryId: z.string().uuid(),
amount: z.string().regex(/^\d+(\.\d{1,2})?$/, 'Amount must be a valid positive number'),
type: z.enum(['INCOME', 'EXPENSE']).default('EXPENSE'),
occurredAt: z.preprocess(
(val) => (typeof val === 'string' || val instanceof Date ? new Date(val) : undefined),
z.date().optional(),
),
});
import { Prisma } from '@prisma/client';
import { addBusinessDays, BusinessDate } from '../../common/date-time/business-time';
import {
BudgetDepletionItemDto,
BudgetRiskLevel,
DataSufficiency,
ForecastMetricsDto,
ForecastSeriesPointDto,
} from './forecast.dto';
const LAMBDA_DECAY = 0.04; // Exponential weighting factor for recent days
export interface DailyBucket {
date: BusinessDate;
income: Prisma.Decimal;
expense: Prisma.Decimal;
}
export interface CategoryDailyBucket {
categoryId: string;
date: BusinessDate;
amount: Prisma.Decimal;
}
export interface BudgetForecastInput {
id: string;
name: string;
categoryName: string | null;
currency: string;
amount: Prisma.Decimal;
spentAmount: Prisma.Decimal;
startDate: BusinessDate;
endDate: BusinessDate;
alertThreshold: Prisma.Decimal;
}
export class ForecastMathEngine {
/**
* Evaluates data sufficiency based on transaction count and historical span.
*/
static assessDataSufficiency(
transactionCount: number,
distinctDaysWithData: number,
): DataSufficiency {
if (transactionCount < 5 || distinctDaysWithData < 5) {
return 'INSUFFICIENT';
}
if (distinctDaysWithData < 25) {
return 'SPARSE';
}
return 'ROBUST';
}
/**
* Calculates weighted daily burn velocity, income, variance, and projects future runway.
*/
static computeRunway(
currentBalance: Prisma.Decimal,
dailyBuckets: DailyBucket[],
horizonDays: number,
asOfDate: BusinessDate,
totalTransactionCount: number,
): {
metrics: ForecastMetricsDto;
series: ForecastSeriesPointDto[];
dataSufficiency: DataSufficiency;
} {
const dataSufficiency = this.assessDataSufficiency(
totalTransactionCount,
dailyBuckets.length,
);
if (dailyBuckets.length === 0 || currentBalance.isNegative()) {
const series = this.generateFlatSeries(currentBalance, horizonDays, asOfDate);
return {
dataSufficiency,
metrics: {
averageDailyIncome: '0.00',
weightedDailyExpense: '0.00',
netDailyBurnRate: '0.00',
projectedEndBalance: currentBalance.toFixed(2),
runwayDays: currentBalance.lessThanOrEqualTo(0) ? 0 : null,
depletionDate: currentBalance.lessThanOrEqualTo(0) ? asOfDate : null,
isDepletionProjected: currentBalance.lessThanOrEqualTo(0),
},
series,
};
}
// Sort buckets chronologically
const sortedBuckets = [...dailyBuckets].sort((a, b) => a.date.localeCompare(b.date));
const totalDays = sortedBuckets.length;
let sumWeightedExpense = 0;
let sumWeights = 0;
let sumIncome = 0;
sortedBuckets.forEach((bucket, index) => {
// Days from the latest data point (0 for newest, totalDays-1 for oldest)
const age = totalDays - 1 - index;
const weight = Math.exp(-LAMBDA_DECAY * age);
const exp = bucket.expense.toNumber();
sumWeightedExpense += exp * weight;
sumWeights += weight;
sumIncome += bucket.income.toNumber();
});
const weightedDailyExpense = sumWeights > 0 ? sumWeightedExpense / sumWeights : 0;
const averageDailyIncome = totalDays > 0 ? sumIncome / totalDays : 0;
const netDailyFlow = averageDailyIncome - weightedDailyExpense;
// Calculate sample standard deviation of daily expenses for confidence intervals
let sumSquaredDiff = 0;
sortedBuckets.forEach((bucket) => {
const diff = bucket.expense.toNumber() - weightedDailyExpense;
sumSquaredDiff += diff * diff;
});
const sampleVariance = totalDays > 1 ? sumSquaredDiff / (totalDays - 1) : 0;
const sampleStdDev = Math.sqrt(sampleVariance);
// Calculate depletion date
let runwayDays: number | null = null;
let depletionDate: string | null = null;
let isDepletionProjected = false;
if (netDailyFlow < 0 && currentBalance.greaterThan(0)) {
const burnRatePerDay = Math.abs(netDailyFlow);
const daysToZero = Math.floor(currentBalance.toNumber() / burnRatePerDay);
runwayDays = daysToZero;
depletionDate = addBusinessDays(asOfDate, daysToZero);
isDepletionProjected = true;
} else if (currentBalance.lessThanOrEqualTo(0)) {
runwayDays = 0;
depletionDate = asOfDate;
isDepletionProjected = true;
}
// Generate projected forward series with 95% confidence bounds
const series: ForecastSeriesPointDto[] = [];
const initialBalanceNum = currentBalance.toNumber();
for (let h = 1; h <= horizonDays; h++) {
const futureDate = addBusinessDays(asOfDate, h);
const projectedPoint = initialBalanceNum + h * netDailyFlow;
const standardError = sampleStdDev * Math.sqrt(h);
const margin = 1.96 * standardError;
series.push({
date: futureDate,
dayIndex: h,
projectedBalance: projectedPoint.toFixed(2),
lowerBound95: (projectedPoint - margin).toFixed(2),
upperBound95: (projectedPoint + margin).toFixed(2),
});
}
const projectedEndBalance = series.length > 0
? series[series.length - 1].projectedBalance
: currentBalance.toFixed(2);
return {
dataSufficiency,
metrics: {
averageDailyIncome: averageDailyIncome.toFixed(2),
weightedDailyExpense: weightedDailyExpense.toFixed(2),
netDailyBurnRate: netDailyFlow.toFixed(2),
projectedEndBalance,
runwayDays,
depletionDate,
isDepletionProjected,
},
series,
};
}
/**
* Computes budget depletion dates and risk assessments.
*/
static computeBudgetDepletions(
budgets: BudgetForecastInput[],
asOfDate: BusinessDate,
): BudgetDepletionItemDto[] {
return budgets.map((budget) => {
const budgetAmountNum = budget.amount.toNumber();
const spentAmountNum = budget.spentAmount.toNumber();
const remainingAmountNum = Math.max(0, budgetAmountNum - spentAmountNum);
const totalDays = Math.max(1, this.daysBetween(budget.startDate, budget.endDate) + 1);
const daysElapsed = Math.min(
totalDays,
Math.max(1, this.daysBetween(budget.startDate, asOfDate) + 1),
);
const daysRemaining = Math.max(0, totalDays - daysElapsed);
const currentDailyBurn = spentAmountNum / daysElapsed;
const recommendedDailySpend = daysRemaining > 0
? remainingAmountNum / daysRemaining
: 0;
const projectedTotalSpend = spentAmountNum + daysRemaining * currentDailyBurn;
let projectedExhaustionDate: string | null = null;
let isExhaustionProjected = false;
let daysEarly: number | null = null;
if (spentAmountNum >= budgetAmountNum) {
projectedExhaustionDate = asOfDate;
isExhaustionProjected = true;
daysEarly = daysRemaining;
} else if (currentDailyBurn > 0 && remainingAmountNum > 0) {
const daysToExhaust = Math.floor(remainingAmountNum / currentDailyBurn);
if (daysToExhaust < daysRemaining) {
projectedExhaustionDate = addBusinessDays(asOfDate, daysToExhaust);
isExhaustionProjected = true;
daysEarly = daysRemaining - daysToExhaust;
}
}
// Risk level determination
let riskLevel: BudgetRiskLevel = 'LOW';
if (spentAmountNum >= budgetAmountNum || (daysEarly !== null && daysEarly > 5)) {
riskLevel = 'CRITICAL';
} else if (isExhaustionProjected) {
riskLevel = 'HIGH';
} else if (projectedTotalSpend > budgetAmountNum * 0.85) {
riskLevel = 'MEDIUM';
}
return {
budgetId: budget.id,
budgetName: budget.name,
categoryName: budget.categoryName,
currency: budget.currency,
budgetAmount: budget.amount.toFixed(2),
spentAmount: budget.spentAmount.toFixed(2),
remainingAmount: remainingAmountNum.toFixed(2),
startDate: budget.startDate,
endDate: budget.endDate,
totalDays,
daysElapsed,
daysRemaining,
currentDailyBurn: currentDailyBurn.toFixed(2),
recommendedDailySpend: recommendedDailySpend.toFixed(2),
projectedTotalSpend: projectedTotalSpend.toFixed(2),
projectedExhaustionDate,
isExhaustionProjected,
daysEarly,
riskLevel,
};
});
}
private static generateFlatSeries(
balance: Prisma.Decimal,
horizonDays: number,
asOfDate: BusinessDate,
): ForecastSeriesPointDto[] {
const series: ForecastSeriesPointDto[] = [];
const formatted = balance.toFixed(2);
for (let h = 1; h <= horizonDays; h++) {
series.push({
date: addBusinessDays(asOfDate, h),
dayIndex: h,
projectedBalance: formatted,
lowerBound95: formatted,
upperBound95: formatted,
});
}
return series;
}
private static daysBetween(start: BusinessDate, end: BusinessDate): number {
const msPerDay = 24 * 60 * 60 * 1000;
const [y1, m1, d1] = start.split('-').map(Number);
const [y2, m2, d2] = end.split('-').map(Number);
const date1 = Date.UTC(y1, m1 - 1, d1);
const date2 = Date.UTC(y2, m2 - 1, d2);
return Math.round((date2 - date1) / msPerDay);
}
}
import { NextFunction, Request, Response } from 'express';
import {
budgetDepletionQuerySchema,
forecastQuerySchema,
} from './forecast.validation';
import { ForecastService } from './forecast.service';
export class ForecastController {
private readonly service = new ForecastService();
getRunway = async (req: Request, res: Response, next: NextFunction) => {
try {
const query = forecastQuerySchema.parse(req.query);
const data = await this.service.getRunway(req.user.id, query);
res.json({ success: true, data });
} catch (error) {
next(error);
}
};
getBudgetDepletion = async (req: Request, res: Response, next: NextFunction) => {
try {
const query = budgetDepletionQuerySchema.parse(req.query);
const data = await this.service.getBudgetDepletion(req.user.id, query);
res.json({ success: true, data });
} catch (error) {
next(error);
}
};
}
export type DataSufficiency = 'INSUFFICIENT' | 'SPARSE' | 'ROBUST';
export type BudgetRiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
export interface ForecastQueryDto {
walletId?: string;
currency?: string;
horizonDays?: number;
}
export interface ForecastSeriesPointDto {
date: string;
dayIndex: number;
projectedBalance: string;
lowerBound95: string;
upperBound95: string;
}
export interface ForecastMetricsDto {
averageDailyIncome: string;
weightedDailyExpense: string;
netDailyBurnRate: string;
projectedEndBalance: string;
runwayDays: number | null;
depletionDate: string | null;
isDepletionProjected: boolean;
}
export interface ForecastRunwayDto {
currency: string;
walletId: string | null;
currentBalance: string;
horizonDays: number;
dataSufficiency: DataSufficiency;
historicalDaysAnalyzed: number;
historicalTransactionCount: number;
metrics: ForecastMetricsDto;
series: ForecastSeriesPointDto[];
}
export interface BudgetDepletionItemDto {
budgetId: string;
budgetName: string;
categoryName: string | null;
currency: string;
budgetAmount: string;
spentAmount: string;
remainingAmount: string;
startDate: string;
endDate: string;
totalDays: number;
daysElapsed: number;
daysRemaining: number;
currentDailyBurn: string;
recommendedDailySpend: string;
projectedTotalSpend: string;
projectedExhaustionDate: string | null;
isExhaustionProjected: boolean;
daysEarly: number | null;
riskLevel: BudgetRiskLevel;
}
export interface BudgetDepletionReportDto {
asOfDate: string;
currency: string | null;
items: BudgetDepletionItemDto[];
}
import { Prisma, TransactionType } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import {
businessWallTimeToInstant,
instantToBusinessDate,
prismaDateToBusinessDate,
} from '../../common/date-time/business-time';
import { BudgetForecastInput, DailyBucket } from './forecast-math';
export interface ForecastWalletRecord {
id: string;
name: string;
balance: Prisma.Decimal;
currency: string;
isArchived: boolean;
}
export class ForecastRepository {
async findWallets(
userId: string,
walletId?: string,
currency?: string,
): Promise<ForecastWalletRecord[]> {
return prisma.wallet.findMany({
where: {
userId,
isArchived: false,
...(walletId ? { id: walletId } : {}),
...(currency ? { currency } : {}),
},
select: {
id: true,
name: true,
balance: true,
currency: true,
isArchived: true,
},
});
}
async findHistoricalDailyBuckets(
userId: string,
from: Date,
to: Date,
walletId?: string,
currency?: string,
): Promise<{ dailyBuckets: DailyBucket[]; totalTransactionCount: number }> {
const transactions = await prisma.transaction.findMany({
where: {
userId,
date: {
gte: from,
lt: to,
},
...(walletId ? { walletId } : {}),
...(currency ? { wallet: { currency } } : {}),
},
select: {
id: true,
amount: true,
type: true,
date: true,
},
});
const bucketMap = new Map<
string,
{ income: Prisma.Decimal; expense: Prisma.Decimal }
>();
transactions.forEach((tx) => {
const dateKey = prismaDateToBusinessDate(tx.date);
const current = bucketMap.get(dateKey) ?? {
income: new Prisma.Decimal(0),
expense: new Prisma.Decimal(0),
};
if (tx.type === TransactionType.INCOME) {
current.income = current.income.plus(tx.amount);
} else {
current.expense = current.expense.plus(tx.amount);
}
bucketMap.set(dateKey, current);
});
const dailyBuckets: DailyBucket[] = Array.from(bucketMap.entries()).map(
([date, flows]) => ({
date: date as `${number}-${number}-${number}`,
income: flows.income,
expense: flows.expense,
}),
);
return {
dailyBuckets,
totalTransactionCount: transactions.length,
};
}
async findActiveBudgets(
userId: string,
asOfDateInstant: Date,
currency?: string,
): Promise<BudgetForecastInput[]> {
const budgets = await prisma.budget.findMany({
where: {
userId,
isArchived: false,
startDate: { lte: asOfDateInstant },
endDate: { gte: asOfDateInstant },
...(currency ? { currency } : {}),
},
include: {
category: {
select: {
name: true,
},
},
},
orderBy: {
startDate: 'asc',
},
});
if (budgets.length === 0) {
return [];
}
const results: BudgetForecastInput[] = [];
for (const budget of budgets) {
const startInstant = budget.startDate;
const endBusinessDate = prismaDateToBusinessDate(budget.endDate);
const nextDay = businessWallTimeToInstant(
instantToBusinessDate(new Date(budget.endDate.getTime() + 24 * 60 * 60 * 1000)),
);
const aggregate = await prisma.transaction.aggregate({
where: {
userId,
type: TransactionType.EXPENSE,
wallet: {
currency: budget.currency,
},
date: {
gte: startInstant,
lt: nextDay,
},
...(budget.categoryId ? { categoryId: budget.categoryId } : {}),
},
_sum: {
amount: true,
},
});
results.push({
id: budget.id,
name: budget.name,
categoryName: budget.category?.name ?? null,
currency: budget.currency,
amount: budget.amount,
spentAmount: aggregate._sum.amount ?? new Prisma.Decimal(0),
startDate: prismaDateToBusinessDate(budget.startDate) as `${number}-${number}-${number}`,
endDate: endBusinessDate as `${number}-${number}-${number}`,
alertThreshold: budget.alertThreshold,
});
}
return results;
}
}
import { Router } from 'express';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { ForecastController } from './forecast.controller';
const router = Router();
const controller = new ForecastController();
router.use(authMiddleware);
router.get('/runway', controller.getRunway);
router.get('/budget-depletion', controller.getBudgetDepletion);
export default router;
import { Prisma } from '@prisma/client';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { cacheService } from '../../common/services/cache.service';
import {
businessWallTimeToInstant,
instantToBusinessDate,
} from '../../common/date-time/business-time';
import {
BudgetDepletionReportDto,
ForecastQueryDto,
ForecastRunwayDto,
} from './forecast.dto';
import { ForecastMathEngine } from './forecast-math';
import { ForecastRepository } from './forecast.repository';
const DEFAULT_HORIZON_DAYS = 30;
const HISTORICAL_WINDOW_DAYS = 90;
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
const CACHE_TTL_SECONDS = 300; // 5 minutes
export class ForecastService {
private readonly repository = new ForecastRepository();
async getRunway(userId: string, query: ForecastQueryDto): Promise<ForecastRunwayDto> {
const cacheKey = `finwise:cache:forecast:${userId}:runway:${JSON.stringify(query)}`;
const cached = await cacheService.get<ForecastRunwayDto>(cacheKey);
if (cached) {
return cached;
}
const horizonDays = query.horizonDays ?? DEFAULT_HORIZON_DAYS;
const now = new Date();
const asOfDate = instantToBusinessDate(now);
const wallets = await this.repository.findWallets(
userId,
query.walletId,
query.currency,
);
if (query.walletId && wallets.length === 0) {
throw new AppError('Wallet not found or archived', 404, ERROR_CODE.NOT_FOUND);
}
// Determine target currency
const targetCurrency = query.currency
?? (wallets.length > 0 ? wallets[0].currency : 'VND');
// Aggregate starting balance for all matching active wallets with target currency
const targetWallets = wallets.filter((w) => w.currency === targetCurrency);
const initialBalance = targetWallets.reduce(
(sum, wallet) => sum.plus(wallet.balance),
new Prisma.Decimal(0),
);
// Historical window: [now - 90 days, now)
const historyFromInstant = new Date(now.getTime() - HISTORICAL_WINDOW_DAYS * MILLISECONDS_PER_DAY);
const historyFromDate = instantToBusinessDate(historyFromInstant);
const fromBoundary = businessWallTimeToInstant(historyFromDate);
const toBoundary = businessWallTimeToInstant(asOfDate);
const { dailyBuckets, totalTransactionCount } = await this.repository.findHistoricalDailyBuckets(
userId,
fromBoundary,
new Date(toBoundary.getTime() + MILLISECONDS_PER_DAY),
query.walletId,
targetCurrency,
);
const forecastResult = ForecastMathEngine.computeRunway(
initialBalance,
dailyBuckets,
horizonDays,
asOfDate,
totalTransactionCount,
);
const result: ForecastRunwayDto = {
currency: targetCurrency,
walletId: query.walletId ?? null,
currentBalance: initialBalance.toFixed(2),
horizonDays,
dataSufficiency: forecastResult.dataSufficiency,
historicalDaysAnalyzed: HISTORICAL_WINDOW_DAYS,
historicalTransactionCount: totalTransactionCount,
metrics: forecastResult.metrics,
series: forecastResult.series,
};
await cacheService.set(cacheKey, result, CACHE_TTL_SECONDS);
return result;
}
async getBudgetDepletion(
userId: string,
query: { currency?: string },
): Promise<BudgetDepletionReportDto> {
const cacheKey = `finwise:cache:forecast:${userId}:budget-depletion:${JSON.stringify(query)}`;
const cached = await cacheService.get<BudgetDepletionReportDto>(cacheKey);
if (cached) {
return cached;
}
const now = new Date();
const asOfDate = instantToBusinessDate(now);
const asOfInstant = businessWallTimeToInstant(asOfDate);
const budgets = await this.repository.findActiveBudgets(
userId,
asOfInstant,
query.currency,
);
const items = ForecastMathEngine.computeBudgetDepletions(budgets, asOfDate);
const result: BudgetDepletionReportDto = {
asOfDate,
currency: query.currency ?? null,
items,
};
await cacheService.set(cacheKey, result, CACHE_TTL_SECONDS);
return result;
}
static async invalidateForecastCache(userId: string): Promise<void> {
await cacheService.clearPattern(`finwise:cache:forecast:${userId}:*`);
}
}
import { z } from 'zod';
export const forecastQuerySchema = z.object({
walletId: z.string().uuid().optional(),
currency: z
.string()
.trim()
.length(3)
.toUpperCase()
.optional(),
horizonDays: z
.preprocess(
(val) => (val === undefined || val === '' ? undefined : Number(val)),
z.number().int().min(7).max(90).optional(),
),
});
export const budgetDepletionQuerySchema = z.object({
currency: z
.string()
.trim()
.length(3)
.toUpperCase()
.optional(),
});
......@@ -27,14 +27,15 @@ import {
SavingGoalAlertCandidate,
} from './notification.repository';
import { AnomalyService } from '../anomalies/anomaly.service';
const GOAL_NEAR_TARGET_PERCENT = new Prisma.Decimal(80);
const GOAL_DUE_SOON_DAYS = 7;
const ANOMALY_MIN_HISTORY_COUNT = 5;
const ANOMALY_AVERAGE_MULTIPLIER = new Prisma.Decimal(3);
interface TransactionForAnomalyCheck {
id: string;
walletId: string;
categoryId?: string;
amount: string;
type: TransactionType;
date: BusinessDate;
......@@ -43,6 +44,7 @@ interface TransactionForAnomalyCheck {
export class NotificationService {
private readonly repository = new NotificationRepository();
private readonly anomalyService = new AnomalyService();
findAll(userId: string, query: NotificationQueryDto) {
return this.repository.findAll(userId, query);
......@@ -219,32 +221,29 @@ export class NotificationService {
}
async detectUnusualTransaction(userId: string, transaction: TransactionForAnomalyCheck) {
if (transaction.type !== TransactionType.EXPENSE) {
if (transaction.type !== TransactionType.EXPENSE || !transaction.categoryId) {
return;
}
try {
const baseline = await this.repository.getExpenseBaseline(
userId,
transaction.id,
transaction.walletId,
transaction.date,
);
const amount = new Prisma.Decimal(transaction.amount);
if (
baseline.count < ANOMALY_MIN_HISTORY_COUNT
|| baseline.average.lessThanOrEqualTo(0)
|| amount.lessThan(baseline.average.times(ANOMALY_AVERAGE_MULTIPLIER))
) {
const evaluation = await this.anomalyService.evaluateTransaction(userId, {
transactionId: transaction.id,
walletId: transaction.walletId,
categoryId: transaction.categoryId,
amount: transaction.amount,
type: transaction.type,
});
if (!evaluation.isAnomaly) {
return;
}
await this.create({
userId,
type: NotificationType.UNUSUAL_TRANSACTION,
priority: NotificationPriority.CRITICAL,
priority: evaluation.severity === 'CRITICAL' ? NotificationPriority.CRITICAL : NotificationPriority.HIGH,
title: 'Unusual transaction detected',
message: 'This expense is significantly higher than your recent spending in the same wallet.',
message: evaluation.explanation,
sourceType: NotificationSourceType.TRANSACTION,
sourceId: transaction.id,
actionUrl: `/transactions/${transaction.id}`,
......@@ -252,7 +251,10 @@ export class NotificationService {
transactionId: transaction.id,
amount: transaction.amount,
currency: transaction.wallet.currency,
recentAverage: baseline.average.toFixed(2),
anomalyScore: evaluation.anomalyScore,
primaryReason: evaluation.reasonCodes[0] ?? null,
modifiedZScore: evaluation.metrics.modifiedZScore,
categoryMedian: evaluation.metrics.categoryMedian,
},
dedupKey: `transaction:${transaction.id}:unusual`,
});
......
import { Prisma, TransactionType } from '@prisma/client';
import {
addBusinessDays,
BusinessDate,
businessDateToPrismaDate,
instantToBusinessDate,
prismaDateToBusinessDate,
} from '../../common/date-time/business-time';
import { prisma } from '../../database/prisma.client';
import {
ExecuteQueryResultDto,
QueryAST,
QueryGroupResultDto,
QueryTimeRangeType,
QueryTransactionItemDto,
} from './query.dto';
export class QueryCompiler {
static resolveDateRange(timeRange: { type: QueryTimeRangeType; dateFrom?: string; dateTo?: string }): {
from: BusinessDate;
to: BusinessDate;
description: string;
} {
const today = instantToBusinessDate(new Date());
const [yearStr, monthStr, dayStr] = today.split('-');
const year = parseInt(yearStr, 10);
const month = parseInt(monthStr, 10);
const day = parseInt(dayStr, 10);
switch (timeRange.type) {
case 'TODAY':
return { from: today, to: today, description: 'Hôm nay' };
case 'THIS_WEEK': {
const d = new Date(Date.UTC(year, month - 1, day));
const dayOfWeek = d.getUTCDay(); // 0 is Sun, 1 is Mon
const diffToMon = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = addBusinessDays(today, diffToMon);
const sunday = addBusinessDays(monday, 6);
return { from: monday, to: sunday, description: 'Tuần này' };
}
case 'LAST_WEEK': {
const d = new Date(Date.UTC(year, month - 1, day));
const dayOfWeek = d.getUTCDay();
const diffToMon = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const thisMonday = addBusinessDays(today, diffToMon);
const lastMonday = addBusinessDays(thisMonday, -7);
const lastSunday = addBusinessDays(lastMonday, 6);
return { from: lastMonday, to: lastSunday, description: 'Tuần trước' };
}
case 'THIS_MONTH': {
const firstDay = `${yearStr}-${monthStr}-01` as BusinessDate;
const lastDayNum = new Date(year, month, 0).getDate();
const lastDay = `${yearStr}-${monthStr}-${String(lastDayNum).padStart(2, '0')}` as BusinessDate;
return { from: firstDay, to: lastDay, description: `Tháng ${month}/${year}` };
}
case 'LAST_MONTH': {
const prevMonthDate = new Date(year, month - 2, 1);
const pYear = prevMonthDate.getFullYear();
const pMonth = prevMonthDate.getMonth() + 1;
const pMonthStr = String(pMonth).padStart(2, '0');
const firstDay = `${pYear}-${pMonthStr}-01` as BusinessDate;
const lastDayNum = new Date(pYear, pMonth, 0).getDate();
const lastDay = `${pYear}-${pMonthStr}-${String(lastDayNum).padStart(2, '0')}` as BusinessDate;
return { from: firstDay, to: lastDay, description: `Tháng ${pMonth}/${pYear}` };
}
case 'THIS_YEAR': {
const firstDay = `${yearStr}-01-01` as BusinessDate;
const lastDay = `${yearStr}-12-31` as BusinessDate;
return { from: firstDay, to: lastDay, description: `Năm ${year}` };
}
case 'LAST_7_DAYS': {
const from = addBusinessDays(today, -6);
return { from, to: today, description: '7 ngày qua' };
}
case 'LAST_30_DAYS': {
const from = addBusinessDays(today, -29);
return { from, to: today, description: '30 ngày qua' };
}
case 'CUSTOM':
default: {
const from = (timeRange.dateFrom as BusinessDate) || today;
const to = (timeRange.dateTo as BusinessDate) || today;
return { from, to, description: `Từ ${from} đến ${to}` };
}
}
}
static async execute(userId: string, ast: QueryAST): Promise<ExecuteQueryResultDto> {
const { from, to, description: timeRangeDesc } = this.resolveDateRange(ast.timeRange);
const fromDatePrisma = businessDateToPrismaDate(from);
const toDatePrisma = businessDateToPrismaDate(to);
const where: Prisma.TransactionWhereInput = {
userId,
date: {
gte: fromDatePrisma,
lte: toDatePrisma,
},
...(ast.transactionType !== 'ALL'
? { type: ast.transactionType as TransactionType }
: {}),
...(ast.categoryIds && ast.categoryIds.length > 0
? { categoryId: { in: ast.categoryIds } }
: {}),
...(ast.walletIds && ast.walletIds.length > 0
? { walletId: { in: ast.walletIds } }
: {}),
...(ast.amountFilter?.minAmount || ast.amountFilter?.maxAmount
? {
amount: {
...(ast.amountFilter.minAmount ? { gte: ast.amountFilter.minAmount } : {}),
...(ast.amountFilter.maxAmount ? { lte: ast.amountFilter.maxAmount } : {}),
},
}
: {}),
};
// Query aggregate
const agg = await prisma.transaction.aggregate({
where,
_sum: { amount: true },
_count: { id: true },
_avg: { amount: true },
_min: { amount: true },
_max: { amount: true },
});
const totalVal = agg._sum.amount ? agg._sum.amount.toNumber() : 0;
const count = agg._count.id || 0;
const avgVal = agg._avg.amount ? agg._avg.amount.toNumber() : 0;
const minVal = agg._min.amount ? agg._min.amount.toNumber() : null;
const maxVal = agg._max.amount ? agg._max.amount.toNumber() : null;
let groups: QueryGroupResultDto[] | undefined;
let items: QueryTransactionItemDto[] | undefined;
// Handle GroupBy
if (ast.groupBy === 'CATEGORY') {
const grouped = await prisma.transaction.groupBy({
by: ['categoryId'],
where,
_sum: { amount: true },
_count: { id: true },
orderBy: { _sum: { amount: 'desc' } },
});
const categories = await prisma.category.findMany({
where: { id: { in: grouped.map((g) => g.categoryId) } },
select: { id: true, name: true },
});
const catMap = new Map(categories.map((c) => [c.id, c.name]));
groups = grouped.map((g) => ({
key: g.categoryId,
label: catMap.get(g.categoryId) || 'Khác',
total: g._sum.amount ? g._sum.amount.toFixed(2) : '0.00',
count: g._count.id,
}));
} else if (ast.groupBy === 'WALLET') {
const grouped = await prisma.transaction.groupBy({
by: ['walletId'],
where,
_sum: { amount: true },
_count: { id: true },
orderBy: { _sum: { amount: 'desc' } },
});
const wallets = await prisma.wallet.findMany({
where: { id: { in: grouped.map((g) => g.walletId) } },
select: { id: true, name: true },
});
const walletMap = new Map(wallets.map((w) => [w.id, w.name]));
groups = grouped.map((g) => ({
key: g.walletId,
label: walletMap.get(g.walletId) || 'Ví',
total: g._sum.amount ? g._sum.amount.toFixed(2) : '0.00',
count: g._count.id,
}));
}
// Handle Item Listing
if (ast.aggregation === 'LIST' || (!groups && count > 0)) {
const records = await prisma.transaction.findMany({
where,
include: {
category: { select: { name: true } },
wallet: { select: { name: true } },
},
orderBy: { date: 'desc' },
take: ast.limit || 30,
});
items = records.map((r) => ({
id: r.id,
date: prismaDateToBusinessDate(r.date),
amount: r.amount.toFixed(2),
type: r.type,
description: r.description,
categoryName: r.category.name,
walletName: r.wallet.name,
}));
}
// Generate Natural Language Summary
const typeLabel =
ast.transactionType === 'INCOME'
? 'thu nhập'
: ast.transactionType === 'TRANSFER'
? 'chuyển khoản'
: 'chi tiêu';
const entityLabel = ast.categoryNames?.length
? `danh mục ${ast.categoryNames.join(', ')}`
: ast.walletNames?.length
? `ví ${ast.walletNames.join(', ')}`
: '';
const summary = count === 0
? `Không tìm thấy giao dịch ${typeLabel} nào ${entityLabel ? `thuộc ${entityLabel} ` : ''}trong khoảng thời gian ${timeRangeDesc}.`
: `Tổng ${typeLabel} ${entityLabel ? `thuộc ${entityLabel} ` : ''}trong ${timeRangeDesc}${totalVal.toLocaleString('vi-VN')} VND qua ${count} giao dịch (bình quân: ${Math.round(avgVal).toLocaleString('vi-VN')} VND/giao dịch).`;
return {
summary,
timeRangeDescription: timeRangeDesc,
aggregation: ast.aggregation,
totalValue: totalVal.toFixed(2),
count,
average: avgVal.toFixed(2),
minValue: minVal !== null ? minVal.toFixed(2) : null,
maxValue: maxVal !== null ? maxVal.toFixed(2) : null,
currency: 'VND',
groups,
items,
};
}
}
import {
QueryAggregationType,
QueryAST,
QueryGroupByType,
QueryTimeRangeType,
} from './query.dto';
export interface UserEntityContext {
categories: Array<{ id: string; name: string }>;
wallets: Array<{ id: string; name: string }>;
}
export class QueryParser {
static parse(queryText: string, context: UserEntityContext): QueryAST {
const raw = queryText.trim();
const lower = raw.toLowerCase();
// 1. Time Range Resolution
const timeRangeType = this.extractTimeRange(lower);
// 2. Transaction Type Resolution
const transactionType = this.extractTransactionType(lower);
// 3. Aggregation Resolution
const aggregation = this.extractAggregation(lower);
// 4. GroupBy Resolution
const groupBy = this.extractGroupBy(lower);
// 5. Amount Filter Resolution
const amountFilter = this.extractAmountFilter(lower);
// 6. Entity Matching (Categories & Wallets)
const matchedCategories = context.categories.filter((cat) =>
lower.includes(cat.name.toLowerCase()),
);
const matchedWallets = context.wallets.filter((w) =>
lower.includes(w.name.toLowerCase()),
);
return {
rawQuery: raw,
timeRange: {
type: timeRangeType,
},
transactionType,
categoryIds: matchedCategories.length > 0 ? matchedCategories.map((c) => c.id) : undefined,
categoryNames: matchedCategories.length > 0 ? matchedCategories.map((c) => c.name) : undefined,
walletIds: matchedWallets.length > 0 ? matchedWallets.map((w) => w.id) : undefined,
walletNames: matchedWallets.length > 0 ? matchedWallets.map((w) => w.name) : undefined,
amountFilter: amountFilter || undefined,
aggregation,
groupBy,
limit: 30,
};
}
private static extractTimeRange(text: string): QueryTimeRangeType {
if (text.includes('hôm nay') || text.includes('today')) return 'TODAY';
if (text.includes('tuần trước') || text.includes('last week')) return 'LAST_WEEK';
if (text.includes('tuần này') || text.includes('this week')) return 'THIS_WEEK';
if (text.includes('tháng trước') || text.includes('last month')) return 'LAST_MONTH';
if (text.includes('tháng này') || text.includes('this month')) return 'THIS_MONTH';
if (text.includes('năm nay') || text.includes('this year')) return 'THIS_YEAR';
if (text.includes('7 ngày') || text.includes('7 days')) return 'LAST_7_DAYS';
if (text.includes('30 ngày') || text.includes('30 days')) return 'LAST_30_DAYS';
return 'THIS_MONTH';
}
private static extractTransactionType(text: string): 'INCOME' | 'EXPENSE' | 'TRANSFER' | 'ALL' {
if (text.includes('thu nhập') || text.includes('tiền thu') || text.includes('income')) {
return 'INCOME';
}
if (text.includes('chuyển khoản') || text.includes('chuyển tiền') || text.includes('transfer')) {
return 'TRANSFER';
}
if (text.includes('tất cả') || text.includes('all')) {
return 'ALL';
}
return 'EXPENSE';
}
private static extractAggregation(text: string): QueryAggregationType {
if (text.includes('đếm') || text.includes('số lượng') || text.includes('bao nhiêu lần') || text.includes('count')) {
return 'COUNT';
}
if (text.includes('trung bình') || text.includes('bình quân') || text.includes('average') || text.includes('avg')) {
return 'AVERAGE';
}
if (text.includes('lớn nhất') || text.includes('cao nhất') || text.includes('nhiều nhất') || text.includes('max')) {
return 'MAX';
}
if (text.includes('nhỏ nhất') || text.includes('ít nhất') || text.includes('thấp nhất') || text.includes('min')) {
return 'MIN';
}
if (text.includes('danh sách') || text.includes('liệt kê') || text.includes('xem các') || text.includes('list') || text.includes('show')) {
return 'LIST';
}
return 'SUM';
}
private static extractGroupBy(text: string): QueryGroupByType {
if (text.includes('theo danh mục') || text.includes('theo loại') || text.includes('by category')) {
return 'CATEGORY';
}
if (text.includes('theo ví') || text.includes('by wallet')) {
return 'WALLET';
}
if (text.includes('theo ngày') || text.includes('by day') || text.includes('từng ngày')) {
return 'DAY';
}
if (text.includes('theo tháng') || text.includes('by month') || text.includes('từng tháng')) {
return 'MONTH';
}
return 'NONE';
}
private static extractAmountFilter(text: string): { minAmount?: number; maxAmount?: number } | null {
// Regex for: trên / > / lớn hơn X
const gtMatch = text.match(/(?:trên|>|lớn hơn|nhiều hơn)\s*(\d+(?:[.,]\d+)?)\s*(k|nghìn|ngàn|tr|triệu|m|vnd|đ)?/i);
if (gtMatch) {
const minVal = this.parseNumericAmount(gtMatch[1], gtMatch[2]);
if (minVal > 0) return { minAmount: minVal };
}
// Regex for: dưới / < / nhỏ hơn X
const ltMatch = text.match(/(?:dưới|<|nhỏ hơn|ít hơn)\s*(\d+(?:[.,]\d+)?)\s*(k|nghìn|ngàn|tr|triệu|m|vnd|đ)?/i);
if (ltMatch) {
const maxVal = this.parseNumericAmount(ltMatch[1], ltMatch[2]);
if (maxVal > 0) return { maxAmount: maxVal };
}
return null;
}
private static parseNumericAmount(numStr: string, unitStr?: string): number {
const rawNum = parseFloat(numStr.replace(',', '.'));
if (isNaN(rawNum)) return 0;
const unit = (unitStr || '').toLowerCase();
if (unit === 'k' || unit === 'nghìn' || unit === 'ngàn') {
return rawNum * 1000;
}
if (unit === 'tr' || unit === 'triệu' || unit === 'm') {
return rawNum * 1000000;
}
return rawNum;
}
}
import { NextFunction, Request, Response } from 'express';
import { executeQuerySchema, parseQuerySchema } from './query.validation';
import { QueryService } from './query.service';
export class QueryController {
private readonly service = new QueryService();
parse = async (req: Request, res: Response, next: NextFunction) => {
try {
const { query } = parseQuerySchema.parse(req.body);
const data = await this.service.parseQuery(req.user.id, query);
res.json({ success: true, data });
} catch (error) {
next(error);
}
};
execute = async (req: Request, res: Response, next: NextFunction) => {
try {
const input = executeQuerySchema.parse(req.body);
const data = await this.service.executeQuery(
req.user.id,
input.query,
input.ast,
);
res.json({ success: true, data });
} catch (error) {
next(error);
}
};
}
export type QueryTimeRangeType =
| 'TODAY'
| 'THIS_WEEK'
| 'LAST_WEEK'
| 'THIS_MONTH'
| 'LAST_MONTH'
| 'THIS_YEAR'
| 'LAST_7_DAYS'
| 'LAST_30_DAYS'
| 'CUSTOM';
export type QueryAggregationType = 'SUM' | 'COUNT' | 'AVERAGE' | 'MIN' | 'MAX' | 'LIST';
export type QueryGroupByType = 'CATEGORY' | 'WALLET' | 'DAY' | 'MONTH' | 'NONE';
export interface QueryAST {
rawQuery: string;
timeRange: {
type: QueryTimeRangeType;
dateFrom?: string; // YYYY-MM-DD
dateTo?: string; // YYYY-MM-DD
};
transactionType: 'INCOME' | 'EXPENSE' | 'TRANSFER' | 'ALL';
categoryIds?: string[];
categoryNames?: string[];
walletIds?: string[];
walletNames?: string[];
amountFilter?: {
minAmount?: number;
maxAmount?: number;
};
aggregation: QueryAggregationType;
groupBy: QueryGroupByType;
limit?: number;
}
export interface ParseQueryResponseDto {
ast: QueryAST;
interpretedDescription: string;
}
export interface QueryGroupResultDto {
key: string;
label: string;
total: string;
count: number;
}
export interface QueryTransactionItemDto {
id: string;
date: string;
amount: string;
type: string;
description: string | null;
categoryName: string;
walletName: string;
}
export interface ExecuteQueryResultDto {
summary: string;
timeRangeDescription: string;
aggregation: QueryAggregationType;
totalValue: string;
count: number;
average: string;
minValue: string | null;
maxValue: string | null;
currency: string;
groups?: QueryGroupResultDto[];
items?: QueryTransactionItemDto[];
}
import { prisma } from '../../database/prisma.client';
import { UserEntityContext } from './query-parser';
export class QueryRepository {
async getUserContext(userId: string): Promise<UserEntityContext> {
const [categories, wallets] = await Promise.all([
prisma.category.findMany({
where: {
OR: [{ userId }, { userId: null, isSystem: true }],
isArchived: false,
},
select: { id: true, name: true },
}),
prisma.wallet.findMany({
where: { userId, isArchived: false },
select: { id: true, name: true },
}),
]);
return {
categories,
wallets,
};
}
}
import { Router } from 'express';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { QueryController } from './query.controller';
const router = Router();
const controller = new QueryController();
router.use(authMiddleware);
router.post('/parse', controller.parse);
router.post('/execute', controller.execute);
export default router;
import {
ExecuteQueryResultDto,
ParseQueryResponseDto,
QueryAST,
} from './query.dto';
import { QueryCompiler } from './query-compiler';
import { QueryParser } from './query-parser';
import { QueryRepository } from './query.repository';
export class QueryService {
private readonly repository = new QueryRepository();
async parseQuery(userId: string, queryText: string): Promise<ParseQueryResponseDto> {
const context = await this.repository.getUserContext(userId);
const ast = QueryParser.parse(queryText, context);
const { description: timeRangeDesc } = QueryCompiler.resolveDateRange(ast.timeRange);
const typeLabel =
ast.transactionType === 'INCOME'
? 'Thu nhập'
: ast.transactionType === 'TRANSFER'
? 'Chuyển khoản'
: 'Chi tiêu';
const entityDesc = [
ast.categoryNames?.length ? `Danh mục: ${ast.categoryNames.join(', ')}` : null,
ast.walletNames?.length ? `Ví: ${ast.walletNames.join(', ')}` : null,
ast.amountFilter?.minAmount ? `> ${ast.amountFilter.minAmount.toLocaleString()} VND` : null,
ast.amountFilter?.maxAmount ? `< ${ast.amountFilter.maxAmount.toLocaleString()} VND` : null,
]
.filter(Boolean)
.join(' | ');
const interpretedDescription = `${typeLabel} trong ${timeRangeDesc}${entityDesc ? ` (${entityDesc})` : ''} - Tổng hợp: ${ast.aggregation}${ast.groupBy !== 'NONE' ? `, Nhóm theo: ${ast.groupBy}` : ''}`;
return {
ast,
interpretedDescription,
};
}
async executeQuery(
userId: string,
queryText?: string,
astInput?: QueryAST,
): Promise<ExecuteQueryResultDto> {
let ast = astInput;
if (!ast && queryText) {
const parsed = await this.parseQuery(userId, queryText);
ast = parsed.ast;
}
if (!ast) {
throw new Error('No query or AST provided for execution');
}
return QueryCompiler.execute(userId, ast);
}
}
import { z } from 'zod';
export const parseQuerySchema = z.object({
query: z.string().min(1, 'Query text is required').max(300),
});
export const executeQuerySchema = z.object({
query: z.string().max(300).optional(),
ast: z
.object({
rawQuery: z.string().optional().default(''),
timeRange: z.object({
type: z.enum([
'TODAY',
'THIS_WEEK',
'LAST_WEEK',
'THIS_MONTH',
'LAST_MONTH',
'THIS_YEAR',
'LAST_7_DAYS',
'LAST_30_DAYS',
'CUSTOM',
]),
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
}),
transactionType: z.enum(['INCOME', 'EXPENSE', 'TRANSFER', 'ALL']).default('EXPENSE'),
categoryIds: z.array(z.string().uuid()).optional(),
categoryNames: z.array(z.string()).optional(),
walletIds: z.array(z.string().uuid()).optional(),
walletNames: z.array(z.string()).optional(),
amountFilter: z
.object({
minAmount: z.number().nonnegative().optional(),
maxAmount: z.number().positive().optional(),
})
.optional(),
aggregation: z.enum(['SUM', 'COUNT', 'AVERAGE', 'MIN', 'MAX', 'LIST']).default('SUM'),
groupBy: z.enum(['CATEGORY', 'WALLET', 'DAY', 'MONTH', 'NONE']).default('NONE'),
limit: z.number().int().min(1).max(100).optional().default(30),
})
.optional(),
}).refine((data) => data.query || data.ast, {
message: 'Either query string or ast must be provided',
});
import { Prisma } from '@prisma/client';
import {
GoalImpactDto,
MonthlyComparisonPointDto,
PerturbationDto,
SimulationResultDto,
SimulationRiskAssessment,
} from './simulation.dto';
export interface SimulationBaselineInput {
startingBalance: Prisma.Decimal;
dailyIncome: number;
dailyExpense: number;
categoryDailyExpenses: Map<string, number>;
savingGoals: {
id: string;
name: string;
targetAmount: Prisma.Decimal;
savedAmount: Prisma.Decimal;
targetDate: string; // YYYY-MM-DD
}[];
startYear: number;
startMonth: number; // 1-12
}
export class SimulationEngine {
static run(
baseline: SimulationBaselineInput,
perturbations: PerturbationDto[],
horizonMonths: number,
currency: string,
): SimulationResultDto {
const daysInMonthAvg = 30.4375;
const baseMonthlyIncome = baseline.dailyIncome * daysInMonthAvg;
const baseMonthlyExpense = baseline.dailyExpense * daysInMonthAvg;
const startingBalanceNum = baseline.startingBalance.toNumber();
let currentBaseBalance = startingBalanceNum;
let currentSimBalance = startingBalanceNum;
const monthlyComparison: MonthlyComparisonPointDto[] = [];
let minSimBalance = currentSimBalance;
let minSimMonth = 1;
for (let m = 1; m <= horizonMonths; m++) {
const monthDate = this.formatMonthDate(baseline.startYear, baseline.startMonth, m);
// Baseline monthly flow
const baseNetFlow = baseMonthlyIncome - baseMonthlyExpense;
currentBaseBalance += baseNetFlow;
// Simulated monthly flow
let simIncome = baseMonthlyIncome;
let simExpense = baseMonthlyExpense;
for (const p of perturbations) {
const amountNum = p.amount ? parseFloat(p.amount) : 0;
if (p.type === 'RECURRING_EXPENSE') {
const start = p.startMonth ?? 1;
const duration = p.durationMonths ?? horizonMonths;
if (m >= start && m < start + duration) {
simExpense += amountNum;
}
} else if (p.type === 'RECURRING_INCOME') {
const start = p.startMonth ?? 1;
const duration = p.durationMonths ?? horizonMonths;
if (m >= start && m < start + duration) {
simIncome += amountNum;
}
} else if (p.type === 'ONE_OFF_EXPENSE') {
const target = p.targetMonth ?? 1;
if (m === target) {
simExpense += amountNum;
}
} else if (p.type === 'ONE_OFF_INCOME') {
const target = p.targetMonth ?? 1;
if (m === target) {
simIncome += amountNum;
}
} else if (p.type === 'CATEGORY_ADJUSTMENT' && p.categoryId && p.percentageDelta !== undefined) {
const catDaily = baseline.categoryDailyExpenses.get(p.categoryId) ?? 0;
const catMonthly = catDaily * daysInMonthAvg;
const adjustment = catMonthly * (p.percentageDelta / 100);
simExpense += adjustment; // e.g. -20% reduces expense
}
}
const simNetFlow = simIncome - simExpense;
currentSimBalance += simNetFlow;
if (currentSimBalance < minSimBalance) {
minSimBalance = currentSimBalance;
minSimMonth = m;
}
monthlyComparison.push({
monthIndex: m,
monthDate,
baselineBalance: currentBaseBalance.toFixed(2),
simulatedBalance: currentSimBalance.toFixed(2),
monthlyDelta: (currentSimBalance - currentBaseBalance).toFixed(2),
baselineNetFlow: baseNetFlow.toFixed(2),
simulatedNetFlow: simNetFlow.toFixed(2),
});
}
const netDeltaNum = currentSimBalance - currentBaseBalance;
const isDeficitProjected = minSimBalance < 0;
let riskAssessment: SimulationRiskAssessment = 'LOW_IMPACT';
if (isDeficitProjected) {
riskAssessment = 'HIGH_DEFICIT_RISK';
} else if (netDeltaNum < -0.15 * Math.max(1, startingBalanceNum)) {
riskAssessment = 'MODERATE_IMPACT';
}
// Evaluate Goal Impacts
const goalImpacts: GoalImpactDto[] = baseline.savingGoals.map((goal) => {
const targetAmountNum = goal.targetAmount.toNumber();
const currentSavedNum = goal.savedAmount.toNumber();
const remainingTarget = Math.max(0, targetAmountNum - currentSavedNum);
// Assume 20% of net positive cash flow is allocated towards goal
const baseMonthlyAlloc = Math.max(0, (baseMonthlyIncome - baseMonthlyExpense) * 0.2);
const simAvgNet = Math.max(
0,
(currentSimBalance - startingBalanceNum) / Math.max(1, horizonMonths) * 0.2,
);
const baseMonthsNeeded = baseMonthlyAlloc > 0 ? Math.ceil(remainingTarget / baseMonthlyAlloc) : null;
const simMonthsNeeded = simAvgNet > 0 ? Math.ceil(remainingTarget / simAvgNet) : null;
let delayMonths: number | null = null;
let status: GoalImpactDto['status'] = 'ON_TRACK';
if (baseMonthsNeeded !== null && simMonthsNeeded !== null) {
delayMonths = simMonthsNeeded - baseMonthsNeeded;
if (delayMonths > 0) {
status = 'DELAYED';
} else if (delayMonths < 0) {
status = 'ACCELERATED';
}
} else if (simMonthsNeeded === null && remainingTarget > 0) {
status = 'UNACHIEVABLE';
}
const baselineEstimatedMonth = baseMonthsNeeded
? this.formatMonthDate(baseline.startYear, baseline.startMonth, baseMonthsNeeded)
: null;
const simulatedEstimatedMonth = simMonthsNeeded
? this.formatMonthDate(baseline.startYear, baseline.startMonth, simMonthsNeeded)
: null;
return {
goalId: goal.id,
goalName: goal.name,
targetAmount: goal.targetAmount.toFixed(2),
currentSaved: goal.savedAmount.toFixed(2),
targetDate: goal.targetDate,
baselineEstimatedMonth,
simulatedEstimatedMonth,
delayMonths,
status,
};
});
return {
currency,
horizonMonths,
startingBalance: baseline.startingBalance.toFixed(2),
summary: {
baselineEndBalance: currentBaseBalance.toFixed(2),
simulatedEndBalance: currentSimBalance.toFixed(2),
netDelta: netDeltaNum.toFixed(2),
minimumSimulatedBalance: minSimBalance.toFixed(2),
minimumBalanceMonth: minSimMonth,
isDeficitProjected,
riskAssessment,
},
monthlyComparison,
goalImpacts,
};
}
private static formatMonthDate(startYear: number, startMonth: number, offsetMonths: number): string {
const totalMonths = startYear * 12 + (startMonth - 1) + offsetMonths;
const year = Math.floor(totalMonths / 12);
const month = (totalMonths % 12) + 1;
return `${year}-${String(month).padStart(2, '0')}`;
}
}
import { NextFunction, Request, Response } from 'express';
import { runSimulationSchema } from './simulation.validation';
import { SimulationService } from './simulation.service';
export class SimulationController {
private readonly service = new SimulationService();
run = async (req: Request, res: Response, next: NextFunction) => {
try {
const input = runSimulationSchema.parse(req.body);
const data = await this.service.runSimulation(req.user.id, input);
res.json({ success: true, data });
} catch (error) {
next(error);
}
};
presets = (_req: Request, res: Response, next: NextFunction) => {
try {
const data = this.service.getPresets();
res.json({ success: true, data });
} catch (error) {
next(error);
}
};
}
export type PerturbationType =
| 'RECURRING_EXPENSE'
| 'RECURRING_INCOME'
| 'ONE_OFF_EXPENSE'
| 'ONE_OFF_INCOME'
| 'CATEGORY_ADJUSTMENT';
export type GoalImpactStatus = 'ON_TRACK' | 'ACCELERATED' | 'DELAYED' | 'UNACHIEVABLE';
export type SimulationRiskAssessment = 'LOW_IMPACT' | 'MODERATE_IMPACT' | 'HIGH_DEFICIT_RISK';
export interface PerturbationDto {
type: PerturbationType;
name: string;
amount?: string;
percentageDelta?: number; // e.g. -20 for 20% reduction
categoryId?: string;
startMonth?: number; // 1-indexed relative to horizon
durationMonths?: number; // for recurring
targetMonth?: number; // for one-off
}
export interface RunSimulationInputDto {
currency?: string;
horizonMonths?: number; // default 12, min 3, max 36
perturbations: PerturbationDto[];
}
export interface MonthlyComparisonPointDto {
monthIndex: number;
monthDate: string; // YYYY-MM
baselineBalance: string;
simulatedBalance: string;
monthlyDelta: string;
baselineNetFlow: string;
simulatedNetFlow: string;
}
export interface GoalImpactDto {
goalId: string;
goalName: string;
targetAmount: string;
currentSaved: string;
targetDate: string;
baselineEstimatedMonth: string | null;
simulatedEstimatedMonth: string | null;
delayMonths: number | null;
status: GoalImpactStatus;
}
export interface SimulationSummaryDto {
baselineEndBalance: string;
simulatedEndBalance: string;
netDelta: string;
minimumSimulatedBalance: string;
minimumBalanceMonth: number;
isDeficitProjected: boolean;
riskAssessment: SimulationRiskAssessment;
}
export interface SimulationResultDto {
currency: string;
horizonMonths: number;
startingBalance: string;
summary: SimulationSummaryDto;
monthlyComparison: MonthlyComparisonPointDto[];
goalImpacts: GoalImpactDto[];
}
export interface SimulationPresetDto {
id: string;
title: string;
description: string;
perturbations: PerturbationDto[];
}
import { Router } from 'express';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { SimulationController } from './simulation.controller';
const router = Router();
const controller = new SimulationController();
router.use(authMiddleware);
router.post('/run', controller.run);
router.get('/presets', controller.presets);
export default router;
import { Prisma, TransactionType } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import {
businessWallTimeToInstant,
instantToBusinessDate,
prismaDateToBusinessDate,
} from '../../common/date-time/business-time';
import {
RunSimulationInputDto,
SimulationPresetDto,
SimulationResultDto,
} from './simulation.dto';
import { SimulationBaselineInput, SimulationEngine } from './simulation-engine';
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
const BASELINE_WINDOW_DAYS = 90;
export class SimulationService {
async runSimulation(
userId: string,
input: RunSimulationInputDto,
): Promise<SimulationResultDto> {
const now = new Date();
const asOfDate = instantToBusinessDate(now);
const [currentYear, currentMonth] = asOfDate.split('-').map(Number);
// 1. Wallets & Starting Balance
const wallets = await prisma.wallet.findMany({
where: {
userId,
isArchived: false,
...(input.currency ? { currency: input.currency } : {}),
},
select: {
balance: true,
currency: true,
},
});
const targetCurrency = input.currency
?? (wallets.length > 0 ? wallets[0].currency : 'VND');
const targetWallets = wallets.filter((w) => w.currency === targetCurrency);
const startingBalance = targetWallets.reduce(
(sum, w) => sum.plus(w.balance),
new Prisma.Decimal(0),
);
// 2. 90-day Historical Cash Flow
const fromBoundary = new Date(now.getTime() - BASELINE_WINDOW_DAYS * MILLISECONDS_PER_DAY);
const toBoundary = businessWallTimeToInstant(asOfDate);
const transactions = await prisma.transaction.findMany({
where: {
userId,
wallet: { currency: targetCurrency },
date: {
gte: fromBoundary,
lt: new Date(toBoundary.getTime() + MILLISECONDS_PER_DAY),
},
},
select: {
amount: true,
type: true,
categoryId: true,
},
});
let totalIncome = 0;
let totalExpense = 0;
const categoryTotals = new Map<string, number>();
transactions.forEach((tx) => {
const amt = tx.amount.toNumber();
if (tx.type === TransactionType.INCOME) {
totalIncome += amt;
} else {
totalExpense += amt;
const currentCat = categoryTotals.get(tx.categoryId) ?? 0;
categoryTotals.set(tx.categoryId, currentCat + amt);
}
});
const dailyIncome = totalIncome / BASELINE_WINDOW_DAYS;
const dailyExpense = totalExpense / BASELINE_WINDOW_DAYS;
const categoryDailyExpenses = new Map<string, number>();
categoryTotals.forEach((val, catId) => {
categoryDailyExpenses.set(catId, val / BASELINE_WINDOW_DAYS);
});
// 3. Active Saving Goals with lifetime contributions
const goals = await prisma.savingGoal.findMany({
where: {
userId,
isArchived: false,
status: 'ACTIVE',
currency: targetCurrency,
},
include: {
contributions: {
select: {
amount: true,
},
},
},
});
const savingGoals = goals.map((goal) => {
const savedAmount = goal.contributions.reduce(
(sum, c) => sum.plus(c.amount),
new Prisma.Decimal(0),
);
return {
id: goal.id,
name: goal.name,
targetAmount: goal.targetAmount,
savedAmount,
targetDate: prismaDateToBusinessDate(goal.targetDate),
};
});
const baseline: SimulationBaselineInput = {
startingBalance,
dailyIncome,
dailyExpense,
categoryDailyExpenses,
savingGoals,
startYear: currentYear,
startMonth: currentMonth,
};
return SimulationEngine.run(
baseline,
input.perturbations,
input.horizonMonths ?? 12,
targetCurrency,
);
}
getPresets(): SimulationPresetDto[] {
return [
{
id: 'installment-loan',
title: 'Mua hàng trả góp',
description: 'Mô phỏng khoản thanh toán trả góp hàng tháng (VD: mua điện thoại, xe máy)',
perturbations: [
{
type: 'RECURRING_EXPENSE',
name: 'Khoản trả góp',
amount: '3000000.00',
startMonth: 1,
durationMonths: 6,
},
],
},
{
id: 'salary-increase',
title: 'Tăng thu nhập định kỳ',
description: 'Mô phỏng khi được tăng lương hoặc có thêm nguồn thu nhập phụ',
perturbations: [
{
type: 'RECURRING_INCOME',
name: 'Tăng lương',
amount: '5000000.00',
startMonth: 1,
durationMonths: 12,
},
],
},
{
id: 'frugal-budget-cut',
title: 'Thắt chặt chi tiêu',
description: 'Giảm 20% chi tiêu cho các danh mục không thiết yếu',
perturbations: [
{
type: 'CATEGORY_ADJUSTMENT',
name: 'Cắt giảm chi tiêu',
percentageDelta: -20,
},
],
},
{
id: 'one-time-purchase',
title: 'Khoản chi lớn đột xuất',
description: 'Mô phỏng khoản chi tiêu một lần trong các tháng tới (VD: du lịch, đóng học phí)',
perturbations: [
{
type: 'ONE_OFF_EXPENSE',
name: 'Chi phí lớn',
amount: '15000000.00',
targetMonth: 3,
},
],
},
];
}
}
import { z } from 'zod';
export const perturbationSchema = z.object({
type: z.enum([
'RECURRING_EXPENSE',
'RECURRING_INCOME',
'ONE_OFF_EXPENSE',
'ONE_OFF_INCOME',
'CATEGORY_ADJUSTMENT',
]),
name: z.string().min(1).max(100),
amount: z
.string()
.regex(/^\d+(\.\d{1,2})?$/, 'Amount must be a valid non-negative number')
.optional(),
percentageDelta: z.number().min(-100).max(500).optional(),
categoryId: z.string().uuid().optional(),
startMonth: z.number().int().min(1).max(36).optional(),
durationMonths: z.number().int().min(1).max(36).optional(),
targetMonth: z.number().int().min(1).max(36).optional(),
});
export const runSimulationSchema = z.object({
currency: z
.string()
.trim()
.length(3)
.toUpperCase()
.optional(),
horizonMonths: z.number().int().min(3).max(36).default(12),
perturbations: z.array(perturbationSchema).min(1).max(20),
});
import { ReminderFrequency } from '@prisma/client';
import { addBusinessDays, BusinessDate } from '../../common/date-time/business-time';
import { DiscoveredSubscriptionDto } from './subscription.dto';
export interface RawSubscriptionTxn {
id: string;
description: string;
amount: number;
currency: string;
categoryId: string;
categoryName: string;
date: BusinessDate;
}
export class SubscriptionDiscoveryEngine {
static normalizeMerchant(description: string): string {
if (!description) return 'UNKNOWN';
let clean = description.toUpperCase().trim();
// Remove common banking/payment noise words
const noisePrefixes = [
/^NAP\s+TIEN\s+/i,
/^THANH\s+TOAN\s+/i,
/^CHUYEN\s+TIEN\s+/i,
/^GD\s+/i,
/^QR\s+/i,
/^CK\s+/i,
/^PAYMENT\s+TO\s+/i,
/^PAYMENT\s+/i,
];
for (const prefix of noisePrefixes) {
clean = clean.replace(prefix, '');
}
// Remove invoice/order numbers and hashes: e.g. #1234, *192839, - 291823, or standalone numbers
clean = clean.replace(/[#*_-]\s*\d+/g, '');
clean = clean.replace(/\b\d+\b/g, '');
clean = clean.replace(/[^\w\s]/gi, ' ').trim(); // Replace punctuation with space
clean = clean.replace(/\s+/g, ' '); // Collapse multiple spaces
// Extract first 1-3 prominent words
const words = clean.split(' ').filter((w) => w.length > 1);
if (words.length === 0) return 'UNKNOWN';
return words.slice(0, 3).join(' ');
}
static discover(
transactions: RawSubscriptionTxn[],
existingReminderTitles: Set<string>,
): DiscoveredSubscriptionDto[] {
// Group transactions by (cleanMerchant, currency)
const groups = new Map<string, RawSubscriptionTxn[]>();
transactions.forEach((tx) => {
const cleanMerchant = this.normalizeMerchant(tx.description);
if (cleanMerchant === 'UNKNOWN' || cleanMerchant.length < 2) {
return;
}
const key = `${cleanMerchant}:${tx.currency}`;
const list = groups.get(key) ?? [];
list.push(tx);
groups.set(key, list);
});
const discovered: DiscoveredSubscriptionDto[] = [];
groups.forEach((txns, key) => {
if (txns.length < 3) {
return; // Need at least 3 occurrences to form a recurring pattern
}
// Sort by date ascending
const sorted = [...txns].sort((a, b) => a.date.localeCompare(b.date));
const [cleanMerchant, currency] = key.split(':');
// Check amount variance
const amounts = sorted.map((t) => t.amount);
const avgAmount = amounts.reduce((sum, a) => sum + a, 0) / amounts.length;
const latestAmount = amounts[amounts.length - 1];
const maxAmountDeviation = Math.max(...amounts.map((a) => Math.abs(a - avgAmount)));
if (maxAmountDeviation / avgAmount > 0.18) {
return; // Amounts fluctuate too wildly to be a fixed subscription
}
// Compute intervals between consecutive transactions
const intervals: number[] = [];
for (let i = 0; i < sorted.length - 1; i++) {
const intervalDays = this.daysBetween(sorted[i].date, sorted[i + 1].date);
if (intervalDays > 0) {
intervals.push(intervalDays);
}
}
if (intervals.length < 2) {
return;
}
const meanInterval = intervals.reduce((sum, val) => sum + val, 0) / intervals.length;
const variance =
intervals.reduce((sum, val) => sum + Math.pow(val - meanInterval, 2), 0) /
Math.max(1, intervals.length - 1);
const stdDev = Math.sqrt(variance);
const regularity = Math.max(0, 1 - stdDev / Math.max(1, meanInterval));
// Must have high regularity (R >= 0.75)
if (regularity < 0.75) {
return;
}
// Map to standard frequency
let frequency: ReminderFrequency | null = null;
if (meanInterval >= 6 && meanInterval <= 8) {
frequency = 'WEEKLY';
} else if (meanInterval >= 26 && meanInterval <= 35) {
frequency = 'MONTHLY';
} else if (meanInterval >= 345 && meanInterval <= 380) {
frequency = 'YEARLY';
}
if (!frequency) {
return; // Non-standard recurrence period
}
const lastDate = sorted[sorted.length - 1].date;
const nextExpectedAt = addBusinessDays(lastDate, Math.round(meanInterval));
// Calculate confidence score (0.75 to 0.99)
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 (> 3% increase)
const driftPercent = ((latestAmount - avgAmount) / avgAmount) * 100;
const isPriceDrift = driftPercent > 3.0;
// Check if already linked to a user reminder
const isLinkedToReminder = existingReminderTitles.has(cleanMerchant.toLowerCase());
const sampleTx = sorted[sorted.length - 1];
discovered.push({
merchantName: cleanMerchant,
categoryName: sampleTx.categoryName,
categoryId: sampleTx.categoryId,
currency,
averageAmount: avgAmount.toFixed(2),
latestAmount: latestAmount.toFixed(2),
frequency,
occurrenceCount: sorted.length,
firstObservedAt: sorted[0].date,
lastObservedAt: lastDate,
nextExpectedAt,
confidenceScore,
isPriceDrift,
priceDriftPercentage: isPriceDrift ? Math.round(driftPercent * 10) / 10 : null,
isLinkedToReminder,
});
});
return discovered.sort((a, b) => b.confidenceScore - a.confidenceScore);
}
private static daysBetween(start: BusinessDate, end: BusinessDate): number {
const msPerDay = 24 * 60 * 60 * 1000;
const [y1, m1, d1] = start.split('-').map(Number);
const [y2, m2, d2] = end.split('-').map(Number);
const date1 = Date.UTC(y1, m1 - 1, d1);
const date2 = Date.UTC(y2, m2 - 1, d2);
return Math.round((date2 - date1) / msPerDay);
}
}
import { NextFunction, Request, Response } from 'express';
import { convertSubscriptionToReminderSchema } from './subscription.validation';
import { SubscriptionService } from './subscription.service';
export class SubscriptionController {
private readonly service = new SubscriptionService();
discover = async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await this.service.discoverSubscriptions(req.user.id);
res.json({ success: true, data });
} catch (error) {
next(error);
}
};
convertToReminder = async (req: Request, res: Response, next: NextFunction) => {
try {
const input = convertSubscriptionToReminderSchema.parse(req.body);
const data = await this.service.convertToReminder(req.user.id, input);
res.status(201).json({ success: true, data });
} catch (error) {
next(error);
}
};
}
import { ReminderFrequency } from '@prisma/client';
export interface DiscoveredSubscriptionDto {
merchantName: string;
categoryName: string;
categoryId: string;
currency: string;
averageAmount: string;
latestAmount: string;
frequency: ReminderFrequency;
occurrenceCount: number;
firstObservedAt: string;
lastObservedAt: string;
nextExpectedAt: string;
confidenceScore: number; // 0.00 to 1.00
isPriceDrift: boolean;
priceDriftPercentage: number | null;
isLinkedToReminder: boolean;
}
export interface ConvertSubscriptionToReminderDto {
merchantName: string;
amount: string;
frequency: ReminderFrequency;
remindAt: string; // ISO date string
categoryId?: string;
}
export interface DiscoveryReportDto {
totalDiscovered: number;
items: DiscoveredSubscriptionDto[];
}
import { ReminderType, TransactionType } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import {
prismaDateToBusinessDate,
} from '../../common/date-time/business-time';
import { ConvertSubscriptionToReminderDto } from './subscription.dto';
import { RawSubscriptionTxn } from './subscription-engine';
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
export class SubscriptionRepository {
async getHistoricalExpenseTransactions(
userId: string,
days = 180,
): Promise<RawSubscriptionTxn[]> {
const since = new Date(Date.now() - days * MILLISECONDS_PER_DAY);
const txns = await prisma.transaction.findMany({
where: {
userId,
type: TransactionType.EXPENSE,
date: { gte: since },
},
include: {
category: { select: { id: true, name: true } },
wallet: { select: { currency: true } },
},
orderBy: {
date: 'asc',
},
});
return txns.map((t) => ({
id: t.id,
description: t.description || t.category.name,
amount: t.amount.toNumber(),
currency: t.wallet.currency,
categoryId: t.categoryId,
categoryName: t.category.name,
date: prismaDateToBusinessDate(t.date),
}));
}
async getExistingReminderTitles(userId: string): Promise<Set<string>> {
const reminders = await prisma.reminder.findMany({
where: {
userId,
isActive: true,
},
select: {
title: true,
},
});
return new Set(reminders.map((r) => r.title.toLowerCase()));
}
async convertToReminder(userId: string, input: ConvertSubscriptionToReminderDto) {
const remindAtDate = new Date(input.remindAt);
return prisma.reminder.create({
data: {
userId,
title: input.merchantName,
message: `Thanh toán gói cước định kỳ: ${input.merchantName} (${parseFloat(input.amount).toLocaleString()} VND)`,
type: ReminderType.RECURRING_PAYMENT,
frequency: input.frequency,
repeatInterval: 1,
remindAt: remindAtDate,
nextTriggerAt: remindAtDate,
isActive: true,
},
});
}
}
import { Router } from 'express';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { SubscriptionController } from './subscription.controller';
const router = Router();
const controller = new SubscriptionController();
router.use(authMiddleware);
router.get('/discover', controller.discover);
router.post('/convert-to-reminder', controller.convertToReminder);
export default router;
import {
ConvertSubscriptionToReminderDto,
DiscoveryReportDto,
} from './subscription.dto';
import { SubscriptionDiscoveryEngine } from './subscription-engine';
import { SubscriptionRepository } from './subscription.repository';
export class SubscriptionService {
private readonly repository = new SubscriptionRepository();
async discoverSubscriptions(userId: string): Promise<DiscoveryReportDto> {
const [transactions, existingReminders] = await Promise.all([
this.repository.getHistoricalExpenseTransactions(userId, 180),
this.repository.getExistingReminderTitles(userId),
]);
const items = SubscriptionDiscoveryEngine.discover(
transactions,
existingReminders,
);
return {
totalDiscovered: items.length,
items,
};
}
async convertToReminder(
userId: string,
input: ConvertSubscriptionToReminderDto,
) {
return this.repository.convertToReminder(userId, input);
}
}
import { z } from 'zod';
export const convertSubscriptionToReminderSchema = z.object({
merchantName: z.string().min(1).max(100),
amount: z.string().regex(/^\d+(\.\d{1,2})?$/, 'Amount must be a valid positive number'),
frequency: z.enum(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']),
remindAt: z.string().datetime(),
categoryId: z.string().uuid().optional(),
});
......@@ -246,6 +246,9 @@ export class TransactionService {
}
private async invalidateReportCache(userId: string): Promise<void> {
await cacheService.clearPattern(`finwise:cache:reports:${userId}:*`);
await Promise.all([
cacheService.clearPattern(`finwise:cache:reports:${userId}:*`),
cacheService.clearPattern(`finwise:cache:forecast:${userId}:*`),
]);
}
}
......@@ -12,6 +12,11 @@ import notificationRoute from '../modules/notifications/notification.route';
import reminderRoute from '../modules/reminders/reminder.route';
import aiAssistantRoute from '../modules/ai-assistant/ai-assistant.route';
import uploadRoute from '../modules/uploads/upload.route';
import forecastRoute from '../modules/forecast/forecast.route';
import simulationRoute from '../modules/simulations/simulation.route';
import anomalyRoute from '../modules/anomalies/anomaly.route';
import subscriptionRoute from '../modules/subscriptions/subscription.route';
import queryRoute from '../modules/query/query.route';
import { healthCheck } from './health.controller';
......@@ -28,6 +33,11 @@ router.use('/transfers', transferRoute);
router.use('/budgets', budgetRoute);
router.use('/saving-goals', savingGoalRoute);
router.use('/reports', reportRoute);
router.use('/forecast', forecastRoute);
router.use('/simulations', simulationRoute);
router.use('/anomalies', anomalyRoute);
router.use('/subscriptions', subscriptionRoute);
router.use('/query', queryRoute);
router.use('/notifications', notificationRoute);
router.use('/reminders', reminderRoute);
router.use('/ai-assistant', aiAssistantRoute);
......
import request from 'supertest';
import bcrypt from 'bcryptjs';
import { Prisma, TransactionType } from '@prisma/client';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
import { AnomalyMathEngine } from '../src/modules/anomalies/anomaly-math';
describe('Upgrade 3: Multi-dimensional Anomaly Detection', () => {
describe('AnomalyMathEngine Unit Tests', () => {
it('should detect category spending spikes using Modified Z-score (MAD)', () => {
// Normal coffee spending is 35k - 45k
const historicalCategoryAmounts = [35000, 40000, 42000, 38000, 45000, 39000, 41000];
// A sudden 350k coffee transaction
const evaluation = AnomalyMathEngine.evaluate({
amount: 350000,
historicalCategoryAmounts,
recentWalletTxnCount: 0,
walletBalance: 10000000,
hourOfDayVietnam: 14,
});
expect(evaluation.isAnomaly).toBe(true);
expect(evaluation.reasonCodes).toContain('SPIKE_VS_CATEGORY_MEDIAN');
expect(evaluation.anomalyScore).toBeGreaterThanOrEqual(0.70);
expect(evaluation.metrics.modifiedZScore).toBeGreaterThan(3.5);
});
it('should identify velocity burst and off-peak anomalies', () => {
const evaluation = AnomalyMathEngine.evaluate({
amount: 150000,
historicalCategoryAmounts: [140000, 150000, 160000],
recentWalletTxnCount: 4, // 4 txns in 20 minutes
walletBalance: 2000000,
hourOfDayVietnam: 3, // 3am
});
expect(evaluation.reasonCodes).toContain('VELOCITY_BURST');
expect(evaluation.reasonCodes).toContain('OFF_PEAK_SURGE');
});
it('should classify normal transactions as NORMAL with isAnomaly=false', () => {
const evaluation = AnomalyMathEngine.evaluate({
amount: 42000,
historicalCategoryAmounts: [35000, 40000, 42000, 38000, 45000],
recentWalletTxnCount: 1,
walletBalance: 5000000,
hourOfDayVietnam: 12,
});
expect(evaluation.isAnomaly).toBe(false);
expect(evaluation.severity).toBe('NORMAL');
expect(evaluation.reasonCodes).toHaveLength(0);
});
});
describe('Anomaly API Integration Tests', () => {
let testUserId: string;
let testWalletId: string;
let testCategoryId: string;
let authHeader: string;
beforeAll(async () => {
const defaultRole = await prisma.role.findUnique({ where: { name: 'USER' } });
const password = 'Password@123456';
const passwordHash = await bcrypt.hash(password, 10);
const email = `anomaly.test.${Date.now()}@example.com`;
const user = await prisma.user.create({
data: {
email,
password: passwordHash,
fullName: 'Anomaly Test User',
isActive: true,
roleId: defaultRole!.id,
},
});
testUserId = user.id;
const wallet = await prisma.wallet.create({
data: {
userId: testUserId,
name: 'Anomaly Test Wallet',
currency: 'VND',
balance: new Prisma.Decimal(5000000),
isDefault: true,
},
});
testWalletId = wallet.id;
const category = await prisma.category.create({
data: {
userId: testUserId,
name: 'Cafe & Drinks',
type: TransactionType.EXPENSE,
},
});
testCategoryId = category.id;
// Populate typical historical transactions (~40k)
const now = new Date();
for (let i = 1; i <= 6; i++) {
await prisma.transaction.create({
data: {
userId: testUserId,
walletId: testWalletId,
categoryId: testCategoryId,
amount: new Prisma.Decimal(40000),
type: TransactionType.EXPENSE,
description: `Cafe ${i}`,
date: now,
},
});
}
// Login
const loginRes = await request(app)
.post('/api/v1/auth/login')
.send({ email, password });
authHeader = `Bearer ${loginRes.body.data.accessToken}`;
});
afterAll(async () => {
await prisma.notification.deleteMany({ where: { userId: testUserId } });
await prisma.transaction.deleteMany({ where: { userId: testUserId } });
await prisma.category.deleteMany({ where: { userId: testUserId } });
await prisma.wallet.deleteMany({ where: { userId: testUserId } });
await prisma.user.deleteMany({ where: { id: testUserId } });
});
it('POST /api/v1/anomalies/evaluate should return anomaly score and reason codes for spikes', async () => {
const res = await request(app)
.post('/api/v1/anomalies/evaluate')
.set('Authorization', authHeader)
.send({
walletId: testWalletId,
categoryId: testCategoryId,
amount: '500000.00', // 12x normal 40k cafe
type: 'EXPENSE',
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.isAnomaly).toBe(true);
expect(res.body.data.anomalyScore).toBeGreaterThanOrEqual(0.70);
expect(res.body.data.reasonCodes).toContain('SPIKE_VS_CATEGORY_MEDIAN');
});
it('POST /api/v1/transactions with anomaly amount should trigger UNUSUAL_TRANSACTION notification', async () => {
const createRes = await request(app)
.post('/api/v1/transactions')
.set('Authorization', authHeader)
.send({
walletId: testWalletId,
categoryId: testCategoryId,
amount: '800000.00',
type: 'EXPENSE',
description: 'Expensive cafe group treat',
date: '2026-08-19',
});
expect(createRes.status).toBe(201);
// Check that a notification was created
const notifRes = await request(app)
.get('/api/v1/notifications')
.set('Authorization', authHeader);
expect(notifRes.status).toBe(200);
const notifications = Array.isArray(notifRes.body.data) ? notifRes.body.data : [];
expect(notifications.length).toBeGreaterThanOrEqual(1);
const unusualNotif = notifications.find(
(n: { type: string }) => n.type === 'UNUSUAL_TRANSACTION',
);
expect(unusualNotif).toBeDefined();
});
});
});
import request from 'supertest';
import bcrypt from 'bcryptjs';
import { Prisma, TransactionType } from '@prisma/client';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
import { QueryParser, UserEntityContext } from '../src/modules/query/query-parser';
describe('Upgrade 5: Hybrid Natural Language to Deterministic DSL Query Engine', () => {
describe('QueryParser Unit Tests', () => {
const mockContext: UserEntityContext = {
categories: [
{ id: 'cat-1', name: 'Ăn uống' },
{ id: 'cat-2', name: 'Mua sắm' },
{ id: 'cat-3', name: 'Tiền nhà' },
],
wallets: [
{ id: 'wal-1', name: 'Ví Tiền Mặt' },
{ id: 'wal-2', name: 'Techcombank' },
],
};
it('should parse natural Vietnamese queries into deterministic AST with category matching', () => {
const ast = QueryParser.parse('Tổng chi tiêu ăn uống tháng này', mockContext);
expect(ast.timeRange.type).toBe('THIS_MONTH');
expect(ast.transactionType).toBe('EXPENSE');
expect(ast.aggregation).toBe('SUM');
expect(ast.categoryNames).toContain('Ăn uống');
expect(ast.categoryIds).toContain('cat-1');
});
it('should parse temporal bounds, amount filters, and grouping', () => {
const ast = QueryParser.parse(
'Liệt kê chi tiêu trên 500k theo danh mục trong 30 ngày qua',
mockContext,
);
expect(ast.timeRange.type).toBe('LAST_30_DAYS');
expect(ast.aggregation).toBe('LIST');
expect(ast.groupBy).toBe('CATEGORY');
expect(ast.amountFilter?.minAmount).toBe(500000);
});
it('should parse income queries with specific wallet matching', () => {
const ast = QueryParser.parse('Tổng thu nhập vào Techcombank tháng trước', mockContext);
expect(ast.timeRange.type).toBe('LAST_MONTH');
expect(ast.transactionType).toBe('INCOME');
expect(ast.walletNames).toContain('Techcombank');
expect(ast.walletIds).toContain('wal-2');
});
});
describe('Query API Integration Tests', () => {
let testUserId: string;
let testWalletId: string;
let testCategoryId: string;
let authHeader: string;
beforeAll(async () => {
const defaultRole = await prisma.role.findUnique({ where: { name: 'USER' } });
const password = 'Password@123456';
const passwordHash = await bcrypt.hash(password, 10);
const email = `dsl.query.test.${Date.now()}@example.com`;
const user = await prisma.user.create({
data: {
email,
password: passwordHash,
fullName: 'DSL Query Test User',
isActive: true,
roleId: defaultRole!.id,
},
});
testUserId = user.id;
const wallet = await prisma.wallet.create({
data: {
userId: testUserId,
name: 'Techcombank DSL',
currency: 'VND',
balance: new Prisma.Decimal(10000000),
isDefault: true,
},
});
testWalletId = wallet.id;
const category = await prisma.category.create({
data: {
userId: testUserId,
name: 'Ăn uống DSL',
type: TransactionType.EXPENSE,
},
});
testCategoryId = category.id;
const now = new Date();
// Create 3 expense transactions in current month
await prisma.transaction.createMany({
data: [
{
userId: testUserId,
walletId: testWalletId,
categoryId: testCategoryId,
amount: new Prisma.Decimal(150000),
type: TransactionType.EXPENSE,
description: 'Bữa trưa phở',
date: now,
},
{
userId: testUserId,
walletId: testWalletId,
categoryId: testCategoryId,
amount: new Prisma.Decimal(250000),
type: TransactionType.EXPENSE,
description: 'Bữa tối lẩu',
date: now,
},
{
userId: testUserId,
walletId: testWalletId,
categoryId: testCategoryId,
amount: new Prisma.Decimal(50000),
type: TransactionType.EXPENSE,
description: 'Cafe chiều',
date: now,
},
],
});
// Login
const loginRes = await request(app)
.post('/api/v1/auth/login')
.send({ email, password });
authHeader = `Bearer ${loginRes.body.data.accessToken}`;
});
afterAll(async () => {
await prisma.transaction.deleteMany({ where: { userId: testUserId } });
await prisma.category.deleteMany({ where: { userId: testUserId } });
await prisma.wallet.deleteMany({ where: { userId: testUserId } });
await prisma.user.deleteMany({ where: { id: testUserId } });
});
it('POST /api/v1/query/parse should return deterministic AST', async () => {
const res = await request(app)
.post('/api/v1/query/parse')
.set('Authorization', authHeader)
.send({
query: 'Tổng chi tiêu Ăn uống DSL tháng này',
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.ast.transactionType).toBe('EXPENSE');
expect(res.body.data.ast.categoryNames).toContain('Ăn uống DSL');
});
it('POST /api/v1/query/execute should compute deterministic summary metrics', async () => {
const res = await request(app)
.post('/api/v1/query/execute')
.set('Authorization', authHeader)
.send({
query: 'Tổng chi tiêu Ăn uống DSL tháng này',
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.count).toBe(3);
expect(parseFloat(res.body.data.totalValue)).toBeCloseTo(450000, 0);
expect(parseFloat(res.body.data.average)).toBeCloseTo(150000, 0);
expect(res.body.data.summary).toContain('450.000 VND');
});
});
});
import request from 'supertest';
import bcrypt from 'bcryptjs';
import { Prisma, TransactionType } from '@prisma/client';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
import { ForecastMathEngine } from '../src/modules/forecast/forecast-math';
import {
BusinessDate,
businessWallTimeToInstant,
instantToBusinessDate,
} from '../src/common/date-time/business-time';
describe('Upgrade 1: Cash Flow Runway & Budget Forecaster', () => {
describe('ForecastMathEngine Unit Tests', () => {
const asOfDate: BusinessDate = '2026-08-19';
it('should return INSUFFICIENT data sufficiency when transaction count is low', () => {
const result = ForecastMathEngine.computeRunway(
new Prisma.Decimal(10000000),
[],
30,
asOfDate,
2,
);
expect(result.dataSufficiency).toBe('INSUFFICIENT');
expect(result.metrics.averageDailyIncome).toBe('0.00');
expect(result.metrics.weightedDailyExpense).toBe('0.00');
expect(result.metrics.projectedEndBalance).toBe('10000000.00');
expect(result.series).toHaveLength(30);
});
it('should calculate weighted daily burn rate and predict depletion date', () => {
const dailyBuckets = [
{ date: '2026-08-10' as BusinessDate, income: new Prisma.Decimal(0), expense: new Prisma.Decimal(500000) },
{ date: '2026-08-11' as BusinessDate, income: new Prisma.Decimal(0), expense: new Prisma.Decimal(500000) },
{ date: '2026-08-12' as BusinessDate, income: new Prisma.Decimal(0), expense: new Prisma.Decimal(500000) },
{ date: '2026-08-13' as BusinessDate, income: new Prisma.Decimal(0), expense: new Prisma.Decimal(500000) },
{ date: '2026-08-14' as BusinessDate, income: new Prisma.Decimal(0), expense: new Prisma.Decimal(500000) },
{ date: '2026-08-15' as BusinessDate, income: new Prisma.Decimal(0), expense: new Prisma.Decimal(500000) },
];
const currentBalance = new Prisma.Decimal(5000000); // 5M balance with 500k/day burn rate => 10 days runway
const result = ForecastMathEngine.computeRunway(
currentBalance,
dailyBuckets,
30,
asOfDate,
15,
);
expect(result.dataSufficiency).toBe('SPARSE');
expect(parseFloat(result.metrics.weightedDailyExpense)).toBeCloseTo(500000, 0);
expect(result.metrics.runwayDays).toBe(10);
expect(result.metrics.isDepletionProjected).toBe(true);
expect(result.metrics.depletionDate).toBe('2026-08-29');
// Confidence bounds should expand as horizon h grows
const day1Spread =
parseFloat(result.series[0].upperBound95) - parseFloat(result.series[0].lowerBound95);
const day30Spread =
parseFloat(result.series[29].upperBound95) - parseFloat(result.series[29].lowerBound95);
expect(day30Spread).toBeGreaterThanOrEqual(day1Spread);
});
it('should correctly predict budget exhaustion date and risk level', () => {
const budgets = [
{
id: 'budget-1',
name: 'Dining August',
categoryName: 'Dining',
currency: 'VND',
amount: new Prisma.Decimal(3000000), // 3M budget
spentAmount: new Prisma.Decimal(2400000), // Spent 2.4M in first 19 days => ~126k/day
startDate: '2026-08-01' as BusinessDate,
endDate: '2026-08-31' as BusinessDate,
alertThreshold: new Prisma.Decimal(80),
},
];
const results = ForecastMathEngine.computeBudgetDepletions(budgets, '2026-08-19' as BusinessDate);
expect(results).toHaveLength(1);
const item = results[0];
expect(item.daysElapsed).toBe(19);
expect(item.daysRemaining).toBe(12);
expect(item.isExhaustionProjected).toBe(true);
expect(item.daysEarly).toBeGreaterThan(0);
expect(['HIGH', 'CRITICAL']).toContain(item.riskLevel);
});
});
describe('Forecast API Integration Tests', () => {
let testUserId: string;
let testWalletId: string;
let testCategoryId: string;
let authHeader: string;
beforeAll(async () => {
const defaultRole = await prisma.role.findUnique({ where: { name: 'USER' } });
const password = 'Password@123456';
const passwordHash = await bcrypt.hash(password, 10);
const email = `forecast.test.${Date.now()}@example.com`;
const user = await prisma.user.create({
data: {
email,
password: passwordHash,
fullName: 'Forecast Test User',
isActive: true,
roleId: defaultRole!.id,
},
});
testUserId = user.id;
// Create test wallet with 10M VND
const wallet = await prisma.wallet.create({
data: {
userId: testUserId,
name: 'Forecast Test Wallet',
currency: 'VND',
balance: new Prisma.Decimal(10000000),
isDefault: true,
},
});
testWalletId = wallet.id;
// Create test category
const category = await prisma.category.create({
data: {
userId: testUserId,
name: 'Food & Dining',
type: TransactionType.EXPENSE,
},
});
testCategoryId = category.id;
// Login to get token
const loginRes = await request(app)
.post('/api/v1/auth/login')
.send({ email, password });
authHeader = `Bearer ${loginRes.body.data.accessToken}`;
// Populate 10 days of expense transactions
const now = new Date();
const today = instantToBusinessDate(now);
for (let i = 1; i <= 10; i++) {
const txDate = businessWallTimeToInstant(today);
txDate.setUTCDate(txDate.getUTCDate() - i);
await prisma.transaction.create({
data: {
userId: testUserId,
walletId: testWalletId,
categoryId: testCategoryId,
amount: new Prisma.Decimal(300000),
type: TransactionType.EXPENSE,
description: `Lunch day -${i}`,
date: txDate,
},
});
}
// Create active budget for current month
const startOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const endOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0));
await prisma.budget.create({
data: {
userId: testUserId,
categoryId: testCategoryId,
name: 'Monthly Dining Forecast',
amount: new Prisma.Decimal(5000000),
currency: 'VND',
startDate: startOfMonth,
endDate: endOfMonth,
},
});
});
afterAll(async () => {
// Clean up test data
await prisma.transaction.deleteMany({ where: { userId: testUserId } });
await prisma.budget.deleteMany({ where: { userId: testUserId } });
await prisma.category.deleteMany({ where: { userId: testUserId } });
await prisma.wallet.deleteMany({ where: { userId: testUserId } });
await prisma.user.deleteMany({ where: { id: testUserId } });
});
it('GET /api/v1/forecast/runway should return valid forecast trajectory', async () => {
const res = await request(app)
.get('/api/v1/forecast/runway')
.set('Authorization', authHeader)
.query({ horizonDays: 30, currency: 'VND' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.currency).toBe('VND');
expect(res.body.data.series).toHaveLength(30);
expect(res.body.data.metrics).toBeDefined();
expect(parseFloat(res.body.data.metrics.weightedDailyExpense)).toBeGreaterThan(0);
});
it('GET /api/v1/forecast/budget-depletion should return active budget pace analysis', async () => {
const res = await request(app)
.get('/api/v1/forecast/budget-depletion')
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data.items)).toBe(true);
expect(res.body.data.items.length).toBeGreaterThanOrEqual(1);
const item = res.body.data.items.find(
(b: { budgetName: string }) => b.budgetName === 'Monthly Dining Forecast',
);
expect(item).toBeDefined();
expect(item.currentDailyBurn).toBeDefined();
expect(item.riskLevel).toBeDefined();
});
});
});
import request from 'supertest';
import bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
import { SimulationEngine } from '../src/modules/simulations/simulation-engine';
describe('Upgrade 2: What-if Financial Simulation Sandbox', () => {
describe('SimulationEngine Unit Tests', () => {
it('should compute monthly baseline and simulated trajectory with recurring perturbation', () => {
const baseline = {
startingBalance: new Prisma.Decimal(20000000), // 20M
dailyIncome: 1000000, // ~30.4M/mo
dailyExpense: 600000, // ~18.2M/mo => net ~ +12.2M/mo
categoryDailyExpenses: new Map<string, number>([['cat-dining', 200000]]),
savingGoals: [
{
id: 'goal-1',
name: 'Emergency Fund',
targetAmount: new Prisma.Decimal(50000000),
savedAmount: new Prisma.Decimal(10000000),
targetDate: '2027-12-31',
},
],
startYear: 2026,
startMonth: 8,
};
const perturbations = [
{
type: 'RECURRING_EXPENSE' as const,
name: 'Loan Installment',
amount: '5000000.00', // 5M/mo
startMonth: 1,
durationMonths: 6,
},
];
const result = SimulationEngine.run(baseline, perturbations, 12, 'VND');
expect(result.horizonMonths).toBe(12);
expect(result.monthlyComparison).toHaveLength(12);
// Month 1 should reflect 5M expense delta
expect(parseFloat(result.monthlyComparison[0].monthlyDelta)).toBeCloseTo(-5000000, 0);
// Total 6 months of 5M = 30M delta
const netDelta = parseFloat(result.summary.netDelta);
expect(netDelta).toBeCloseTo(-30000000, 0);
expect(result.summary.isDeficitProjected).toBe(false);
expect(result.goalImpacts).toHaveLength(1);
});
it('should identify when a scenario drives the balance into a projected deficit', () => {
const baseline = {
startingBalance: new Prisma.Decimal(5000000), // 5M
dailyIncome: 0,
dailyExpense: 200000, // 6M/mo burn rate
categoryDailyExpenses: new Map<string, number>(),
savingGoals: [],
startYear: 2026,
startMonth: 8,
};
const perturbations = [
{
type: 'ONE_OFF_EXPENSE' as const,
name: 'Big purchase',
amount: '10000000.00', // 10M at month 1 => deficit!
targetMonth: 1,
},
];
const result = SimulationEngine.run(baseline, perturbations, 6, 'VND');
expect(result.summary.isDeficitProjected).toBe(true);
expect(result.summary.riskAssessment).toBe('HIGH_DEFICIT_RISK');
});
});
describe('Simulation API Integration Tests', () => {
let testUserId: string;
let authHeader: string;
beforeAll(async () => {
const defaultRole = await prisma.role.findUnique({ where: { name: 'USER' } });
const password = 'Password@123456';
const passwordHash = await bcrypt.hash(password, 10);
const email = `sim.test.${Date.now()}@example.com`;
const user = await prisma.user.create({
data: {
email,
password: passwordHash,
fullName: 'Simulation Test User',
isActive: true,
roleId: defaultRole!.id,
},
});
testUserId = user.id;
// Create test wallet
await prisma.wallet.create({
data: {
userId: testUserId,
name: 'Sim Main Wallet',
currency: 'VND',
balance: new Prisma.Decimal(30000000),
isDefault: true,
},
});
// Login
const loginRes = await request(app)
.post('/api/v1/auth/login')
.send({ email, password });
authHeader = `Bearer ${loginRes.body.data.accessToken}`;
});
afterAll(async () => {
await prisma.wallet.deleteMany({ where: { userId: testUserId } });
await prisma.user.deleteMany({ where: { id: testUserId } });
});
it('GET /api/v1/simulations/presets should return default simulation templates', async () => {
const res = await request(app)
.get('/api/v1/simulations/presets')
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
expect(res.body.data.length).toBeGreaterThanOrEqual(2);
});
it('POST /api/v1/simulations/run should return comparative simulation metrics', async () => {
const res = await request(app)
.post('/api/v1/simulations/run')
.set('Authorization', authHeader)
.send({
currency: 'VND',
horizonMonths: 12,
perturbations: [
{
type: 'RECURRING_EXPENSE',
name: 'Car Installment',
amount: '4000000.00',
startMonth: 1,
durationMonths: 12,
},
],
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.currency).toBe('VND');
expect(res.body.data.monthlyComparison).toHaveLength(12);
expect(res.body.data.summary).toBeDefined();
expect(parseFloat(res.body.data.summary.netDelta)).toBeCloseTo(-48000000, 0);
});
});
});
import request from 'supertest';
import bcrypt from 'bcryptjs';
import { Prisma, TransactionType } from '@prisma/client';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
import { RawSubscriptionTxn, SubscriptionDiscoveryEngine } from '../src/modules/subscriptions/subscription-engine';
import { BusinessDate } from '../src/common/date-time/business-time';
describe('Upgrade 4: Auto Subscription Discovery', () => {
describe('SubscriptionDiscoveryEngine Unit Tests', () => {
it('should normalize raw payment description strings', () => {
expect(SubscriptionDiscoveryEngine.normalizeMerchant('NAP TIEN NETFLIX*198293')).toBe('NETFLIX');
expect(SubscriptionDiscoveryEngine.normalizeMerchant('THANH TOAN SPOTIFY PREMIUM')).toBe('SPOTIFY PREMIUM');
expect(SubscriptionDiscoveryEngine.normalizeMerchant('GD 91823 ICLOUD STORAGE')).toBe('ICLOUD STORAGE');
});
it('should discover monthly recurring subscriptions and detect price drift', () => {
const transactions: RawSubscriptionTxn[] = [
{
id: 'tx-1',
description: 'NETFLIX PREMIUM',
amount: 260000,
currency: 'VND',
categoryId: 'cat-1',
categoryName: 'Entertainment',
date: '2026-05-05' as BusinessDate,
},
{
id: 'tx-2',
description: 'NETFLIX PREMIUM',
amount: 260000,
currency: 'VND',
categoryId: 'cat-1',
categoryName: 'Entertainment',
date: '2026-06-05' as BusinessDate,
},
{
id: 'tx-3',
description: 'NETFLIX PREMIUM',
amount: 260000,
currency: 'VND',
categoryId: 'cat-1',
categoryName: 'Entertainment',
date: '2026-07-05' as BusinessDate,
},
{
id: 'tx-4',
description: 'NETFLIX PREMIUM',
amount: 280000, // Price hike!
currency: 'VND',
categoryId: 'cat-1',
categoryName: 'Entertainment',
date: '2026-08-05' as BusinessDate,
},
];
const discovered = SubscriptionDiscoveryEngine.discover(transactions, new Set());
expect(discovered).toHaveLength(1);
const sub = discovered[0];
expect(sub.merchantName).toBe('NETFLIX PREMIUM');
expect(sub.frequency).toBe('MONTHLY');
expect(sub.occurrenceCount).toBe(4);
expect(sub.confidenceScore).toBeGreaterThanOrEqual(0.85);
expect(sub.isPriceDrift).toBe(true);
expect(sub.nextExpectedAt).toBe('2026-09-05');
});
it('should ignore irregular one-off purchases', () => {
const transactions: RawSubscriptionTxn[] = [
{
id: 'tx-1',
description: 'SHOPEE ORDER',
amount: 150000,
currency: 'VND',
categoryId: 'cat-2',
categoryName: 'Shopping',
date: '2026-05-01' as BusinessDate,
},
{
id: 'tx-2',
description: 'SHOPEE ORDER',
amount: 800000,
currency: 'VND',
categoryId: 'cat-2',
categoryName: 'Shopping',
date: '2026-05-03' as BusinessDate,
},
];
const discovered = SubscriptionDiscoveryEngine.discover(transactions, new Set());
expect(discovered).toHaveLength(0);
});
});
describe('Subscription API Integration Tests', () => {
let testUserId: string;
let testWalletId: string;
let testCategoryId: string;
let authHeader: string;
beforeAll(async () => {
const defaultRole = await prisma.role.findUnique({ where: { name: 'USER' } });
const password = 'Password@123456';
const passwordHash = await bcrypt.hash(password, 10);
const email = `sub.test.${Date.now()}@example.com`;
const user = await prisma.user.create({
data: {
email,
password: passwordHash,
fullName: 'Subscription Test User',
isActive: true,
roleId: defaultRole!.id,
},
});
testUserId = user.id;
const wallet = await prisma.wallet.create({
data: {
userId: testUserId,
name: 'Sub Test Wallet',
currency: 'VND',
balance: new Prisma.Decimal(5000000),
isDefault: true,
},
});
testWalletId = wallet.id;
const category = await prisma.category.create({
data: {
userId: testUserId,
name: 'Subscriptions & Bills',
type: TransactionType.EXPENSE,
},
});
testCategoryId = category.id;
// Seed 4 monthly Spotify transactions
const dates = [
new Date('2026-05-10T00:00:00.000Z'),
new Date('2026-06-10T00:00:00.000Z'),
new Date('2026-07-10T00:00:00.000Z'),
new Date('2026-08-10T00:00:00.000Z'),
];
for (const d of dates) {
await prisma.transaction.create({
data: {
userId: testUserId,
walletId: testWalletId,
categoryId: testCategoryId,
amount: new Prisma.Decimal(59000),
type: TransactionType.EXPENSE,
description: 'SPOTIFY PREMIUM MONTHLY',
date: d,
},
});
}
// Login
const loginRes = await request(app)
.post('/api/v1/auth/login')
.send({ email, password });
authHeader = `Bearer ${loginRes.body.data.accessToken}`;
});
afterAll(async () => {
await prisma.reminder.deleteMany({ where: { userId: testUserId } });
await prisma.transaction.deleteMany({ where: { userId: testUserId } });
await prisma.category.deleteMany({ where: { userId: testUserId } });
await prisma.wallet.deleteMany({ where: { userId: testUserId } });
await prisma.user.deleteMany({ where: { id: testUserId } });
});
it('GET /api/v1/subscriptions/discover should identify Spotify recurring subscription', async () => {
const res = await request(app)
.get('/api/v1/subscriptions/discover')
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.totalDiscovered).toBeGreaterThanOrEqual(1);
const spotify = res.body.data.items.find(
(i: { merchantName: string }) => i.merchantName.includes('SPOTIFY'),
);
expect(spotify).toBeDefined();
expect(spotify.frequency).toBe('MONTHLY');
expect(parseFloat(spotify.averageAmount)).toBeCloseTo(59000, 0);
});
it('POST /api/v1/subscriptions/convert-to-reminder should create an active reminder', async () => {
const res = await request(app)
.post('/api/v1/subscriptions/convert-to-reminder')
.set('Authorization', authHeader)
.send({
merchantName: 'Spotify Premium',
amount: '59000.00',
frequency: 'MONTHLY',
remindAt: '2026-09-10T02:00:00.000Z',
});
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.data.id).toBeDefined();
expect(res.body.data.title).toBe('Spotify Premium');
});
});
});
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