Skip to content

Commit bbc6535

Browse files
authored
Merge pull request #79 from splitly25/feat/mock-report
Feat/mock report
2 parents 89d467e + 174aeab commit bbc6535

14 files changed

Lines changed: 2631 additions & 9 deletions

File tree

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
/**
2+
* Report Controller
3+
* Provides analytics and reporting data for user spending
4+
*/
5+
6+
import { StatusCodes } from 'http-status-codes'
7+
import { ObjectId } from 'mongodb'
8+
import { userModel, billModel } from '~/models/index.js'
9+
import ApiError from '~/utils/APIError.js'
10+
11+
/**
12+
* Get report data for a specific user and month
13+
* @route GET /api/v1/reports/:userId
14+
* @query year - Year (YYYY)
15+
* @query month - Month (1-12)
16+
*/
17+
const getMonthlyReport = async (req, res, next) => {
18+
try {
19+
const { userId } = req.params
20+
const { year, month } = req.query
21+
22+
// Security check: Verify that the authenticated user is requesting their own data
23+
if (req.jwtDecoded._id !== userId) {
24+
throw new ApiError(StatusCodes.FORBIDDEN, 'You can only access your own report data')
25+
}
26+
27+
// Validate required parameters
28+
if (!year || !month) {
29+
throw new ApiError(StatusCodes.BAD_REQUEST, 'Year and month parameters are required')
30+
}
31+
32+
const yearNum = parseInt(year)
33+
const monthNum = parseInt(month)
34+
35+
// Validate year and month ranges
36+
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2100) {
37+
throw new ApiError(StatusCodes.BAD_REQUEST, 'Invalid year parameter')
38+
}
39+
40+
if (isNaN(monthNum) || monthNum < 1 || monthNum > 12) {
41+
throw new ApiError(StatusCodes.BAD_REQUEST, 'Invalid month parameter (must be 1-12)')
42+
}
43+
44+
// Convert userId to ObjectId
45+
const userIdObj = new ObjectId(userId)
46+
47+
// Validate user exists
48+
const user = await userModel.findOneById(userIdObj)
49+
if (!user) {
50+
throw new ApiError(StatusCodes.NOT_FOUND, 'User not found')
51+
}
52+
53+
// Get all user bills
54+
const userBills = await billModel.getBillsByUser(userIdObj)
55+
56+
// Calculate report metrics
57+
const reportData = await calculateReportMetrics(userBills, userIdObj, yearNum, monthNum)
58+
59+
res.status(StatusCodes.OK).json(reportData)
60+
} catch (error) {
61+
next(error)
62+
}
63+
}
64+
65+
/**
66+
* Calculate comprehensive report metrics for a user in a specific month
67+
*/
68+
const calculateReportMetrics = async (bills, userId, year, month) => {
69+
// Get date range for current month
70+
const startDate = new Date(year, month - 1, 1) // month is 0-indexed in Date
71+
const endDate = new Date(year, month, 0, 23, 59, 59, 999) // Last day of month
72+
73+
// Get date range for previous month
74+
const prevMonth = month === 1 ? 12 : month - 1
75+
const prevYear = month === 1 ? year - 1 : year
76+
const prevStartDate = new Date(prevYear, prevMonth - 1, 1)
77+
const prevEndDate = new Date(prevYear, prevMonth, 0, 23, 59, 59, 999)
78+
79+
// Filter bills for current month
80+
const currentMonthBills = bills.filter((bill) => {
81+
const billDate = new Date(bill.createdAt)
82+
return billDate >= startDate && billDate <= endDate
83+
})
84+
85+
// Filter bills for previous month
86+
const previousMonthBills = bills.filter((bill) => {
87+
const billDate = new Date(bill.createdAt)
88+
return billDate >= prevStartDate && billDate <= prevEndDate
89+
})
90+
91+
// Calculate metrics
92+
const totalSpending = calculateTotalSpending(currentMonthBills, userId)
93+
const previousTotalSpending = calculateTotalSpending(previousMonthBills, userId)
94+
const billCount = currentMonthBills.length
95+
const previousBillCount = previousMonthBills.length
96+
const overdueBills = calculateOverdueBills(currentMonthBills, userId)
97+
const unpaidDebt = calculateUnpaidDebt(currentMonthBills, userId)
98+
const spendingTrend = calculateDailySpendingTrend(currentMonthBills, userId, year, month)
99+
const categoryBreakdown = calculateCategoryBreakdown(currentMonthBills, userId)
100+
const aiInsights = generateAIInsights(
101+
totalSpending,
102+
previousTotalSpending,
103+
billCount,
104+
previousBillCount,
105+
categoryBreakdown,
106+
spendingTrend
107+
)
108+
109+
// Calculate percentage changes
110+
const spendingChange = calculatePercentageChange(totalSpending, previousTotalSpending)
111+
const billCountChange = calculatePercentageChange(billCount, previousBillCount)
112+
113+
return {
114+
period: {
115+
year,
116+
month,
117+
startDate: startDate.toISOString(),
118+
endDate: endDate.toISOString(),
119+
},
120+
metrics: {
121+
totalSpending: {
122+
amount: totalSpending,
123+
change: spendingChange,
124+
previousAmount: previousTotalSpending,
125+
},
126+
billCount: {
127+
count: billCount,
128+
change: billCountChange,
129+
previousCount: previousBillCount,
130+
},
131+
overdueBills: {
132+
count: overdueBills.count,
133+
amount: overdueBills.amount,
134+
},
135+
unpaidDebt: {
136+
amount: unpaidDebt,
137+
},
138+
},
139+
spendingTrend,
140+
categoryBreakdown,
141+
aiInsights,
142+
}
143+
}
144+
145+
/**
146+
* Calculate total spending for bills in a period
147+
*/
148+
const calculateTotalSpending = (bills, userId) => {
149+
let total = 0
150+
151+
bills.forEach((bill) => {
152+
const userPaymentStatus = bill.paymentStatus.find((status) => status.userId.equals(userId))
153+
if (userPaymentStatus) {
154+
total += userPaymentStatus.amountOwed
155+
}
156+
})
157+
158+
return total
159+
}
160+
161+
/**
162+
* Calculate overdue bills
163+
*/
164+
const calculateOverdueBills = (bills, userId) => {
165+
const now = new Date()
166+
let count = 0
167+
let amount = 0
168+
169+
bills.forEach((bill) => {
170+
const userPaymentStatus = bill.paymentStatus.find((status) => status.userId.equals(userId))
171+
172+
if (userPaymentStatus && !userPaymentStatus.isPaid) {
173+
const deadline = new Date(bill.paymentDeadline)
174+
if (deadline < now) {
175+
count++
176+
amount += userPaymentStatus.amountOwed - (userPaymentStatus.amountPaid || 0)
177+
}
178+
}
179+
})
180+
181+
return { count, amount }
182+
}
183+
184+
/**
185+
* Calculate unpaid debt
186+
*/
187+
const calculateUnpaidDebt = (bills, userId) => {
188+
let unpaid = 0
189+
190+
bills.forEach((bill) => {
191+
const userPaymentStatus = bill.paymentStatus.find((status) => status.userId.equals(userId))
192+
193+
if (userPaymentStatus && !userPaymentStatus.isPaid) {
194+
unpaid += userPaymentStatus.amountOwed - (userPaymentStatus.amountPaid || 0)
195+
}
196+
})
197+
198+
return unpaid
199+
}
200+
201+
/**
202+
* Calculate daily spending trend for the month
203+
*/
204+
const calculateDailySpendingTrend = (bills, userId, year, month) => {
205+
const daysInMonth = new Date(year, month, 0).getDate()
206+
const dailyData = Array.from({ length: daysInMonth }, (_, index) => ({
207+
day: index + 1,
208+
amount: 0,
209+
count: 0,
210+
date: new Date(year, month - 1, index + 1).toISOString(),
211+
}))
212+
213+
bills.forEach((bill) => {
214+
const billDate = new Date(bill.createdAt)
215+
const day = billDate.getDate()
216+
217+
const userPaymentStatus = bill.paymentStatus.find((status) => status.userId.equals(userId))
218+
if (userPaymentStatus) {
219+
dailyData[day - 1].amount += userPaymentStatus.amountOwed
220+
dailyData[day - 1].count += 1
221+
}
222+
})
223+
224+
return dailyData
225+
}
226+
227+
/**
228+
* Calculate spending breakdown by category
229+
*/
230+
const calculateCategoryBreakdown = (bills, userId) => {
231+
const categoryTotals = {}
232+
233+
bills.forEach((bill) => {
234+
const category = bill.category || 'Khác'
235+
const userPaymentStatus = bill.paymentStatus.find((status) => status.userId.equals(userId))
236+
237+
if (userPaymentStatus) {
238+
if (!categoryTotals[category]) {
239+
categoryTotals[category] = {
240+
amount: 0,
241+
count: 0,
242+
}
243+
}
244+
categoryTotals[category].amount += userPaymentStatus.amountOwed
245+
categoryTotals[category].count += 1
246+
}
247+
})
248+
249+
// Convert to array and sort by amount
250+
const categoryArray = Object.entries(categoryTotals)
251+
.map(([category, data]) => ({
252+
category,
253+
amount: data.amount,
254+
count: data.count,
255+
}))
256+
.sort((a, b) => b.amount - a.amount)
257+
258+
return categoryArray
259+
}
260+
261+
/**
262+
* Calculate percentage change between two values
263+
*/
264+
const calculatePercentageChange = (current, previous) => {
265+
if (previous === 0) {
266+
return current > 0 ? 100 : 0
267+
}
268+
return Math.round(((current - previous) / previous) * 1000) / 10 // Round to 1 decimal
269+
}
270+
271+
/**
272+
* Generate AI insights based on spending data
273+
*/
274+
const generateAIInsights = (
275+
totalSpending,
276+
previousTotalSpending,
277+
billCount,
278+
previousBillCount,
279+
categoryBreakdown,
280+
spendingTrend
281+
) => {
282+
const insights = []
283+
284+
// Average transaction analysis
285+
const avgTransaction = billCount > 0 ? totalSpending / billCount : 0
286+
const prevAvgTransaction = previousBillCount > 0 ? previousTotalSpending / previousBillCount : 0
287+
const avgChange = calculatePercentageChange(avgTransaction, prevAvgTransaction)
288+
289+
insights.push({
290+
title: 'Phân tích chi tiêu trung bình',
291+
description: `Trung bình bạn chi ${formatCurrency(avgTransaction)} cho mỗi giao dịch. ${
292+
billCount > previousBillCount
293+
? `Số giao dịch tăng ${Math.abs(
294+
calculatePercentageChange(billCount, previousBillCount)
295+
)}% so với tháng trước.`
296+
: billCount < previousBillCount
297+
? `Số giao dịch giảm ${Math.abs(
298+
calculatePercentageChange(billCount, previousBillCount)
299+
)}% so với tháng trước.`
300+
: 'Số giao dịch không đổi so với tháng trước.'
301+
}`,
302+
suggestion:
303+
avgChange > 20
304+
? 'Giá trị mỗi giao dịch tăng cao. Hãy xem xét các khoản chi tiêu lớn có thực sự cần thiết.'
305+
: avgChange < -20
306+
? 'Bạn đang chi tiêu hiệu quả hơn cho mỗi giao dịch!'
307+
: 'Duy trì mức chi tiêu ổn định này.',
308+
})
309+
310+
// Category spending analysis
311+
if (categoryBreakdown.length > 0) {
312+
const topCategory = categoryBreakdown[0]
313+
const topCategoryPercent = Math.round((topCategory.amount / totalSpending) * 100)
314+
315+
insights.push({
316+
title: 'Chi tiêu theo danh mục',
317+
description: `Danh mục "${topCategory.category}" chiếm ${topCategoryPercent}% tổng chi tiêu với ${formatCurrency(topCategory.amount)}.`,
318+
suggestion:
319+
topCategoryPercent > 50
320+
? `Danh mục này chiếm phần lớn chi tiêu của bạn. Hãy xem xét có thể tối ưu hóa không.`
321+
: 'Chi tiêu của bạn được phân bổ cân đối giữa các danh mục.',
322+
})
323+
}
324+
325+
// Spending trend analysis
326+
const nonZeroDays = spendingTrend.filter((day) => day.amount > 0).length
327+
if (nonZeroDays > 0) {
328+
insights.push({
329+
title: 'Tần suất chi tiêu',
330+
description: `Bạn có chi tiêu trong ${nonZeroDays} ngày trong tháng này.`,
331+
suggestion:
332+
nonZeroDays > 20
333+
? 'Bạn chi tiêu khá thường xuyên. Hãy thử lập kế hoạch mua sắm để giảm tần suất.'
334+
: 'Tần suất chi tiêu của bạn khá hợp lý.',
335+
})
336+
}
337+
338+
return insights
339+
}
340+
341+
/**
342+
* Helper function to format currency
343+
*/
344+
const formatCurrency = (amount) => {
345+
return `${Math.round(amount).toLocaleString('vi-VN')} ₫`
346+
}
347+
348+
export const reportController = {
349+
getMonthlyReport,
350+
}

api/src/routes/v1/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { groupRoute } from './groupRoute'
1111
import { paymentConfirmationRoute } from './paymentConfirmationRoute'
1212
import { testRoute } from './testRoute'
1313
import { activityRoute } from './activityRoute'
14+
import { reportRoute } from './reportRoute'
1415

1516
const Router = express.Router()
1617

@@ -46,6 +47,9 @@ Router.use('/payment-confirmation', paymentConfirmationRoute)
4647
// Activity API routes
4748
Router.use('/activities', activityRoute)
4849

50+
// Report API routes
51+
Router.use('/reports', reportRoute)
52+
4953
// Test API routes (for debugging SMTP and other services)
5054
Router.use('/test', testRoute)
5155

api/src/routes/v1/reportRoute.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Report API Routes
3+
* Handles report and analytics endpoints
4+
*/
5+
6+
import express from 'express'
7+
import { reportController } from '~/controllers/reportController.js'
8+
import { authMiddleware } from '~/middlewares/authMiddleware.js'
9+
10+
const Router = express.Router()
11+
12+
/**
13+
* GET /api/v1/reports/:userId - Get monthly report data for a user
14+
* Query params: year (YYYY), month (1-12)
15+
*/
16+
Router.get('/:userId', authMiddleware.isAuthorized, reportController.getMonthlyReport)
17+
18+
export const reportRoute = Router

0 commit comments

Comments
 (0)