diff --git a/src/payments/entities/refund.entity.ts b/src/payments/entities/refund.entity.ts index 02db529b..5de71d31 100644 --- a/src/payments/entities/refund.entity.ts +++ b/src/payments/entities/refund.entity.ts @@ -56,6 +56,7 @@ export class Refund { payment: Payment; @Column({ name: 'payment_id' }) + @Index() paymentId: string; @CreateDateColumn() diff --git a/src/payments/payouts/payouts.service.spec.ts b/src/payments/payouts/payouts.service.spec.ts index 5f135145..a04a773f 100644 --- a/src/payments/payouts/payouts.service.spec.ts +++ b/src/payments/payouts/payouts.service.spec.ts @@ -13,8 +13,23 @@ import { NotificationsService } from '../../notifications/notifications.service' describe('PayoutsService', () => { let service: PayoutsService; + const mockQueryBuilder = { + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + addGroupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getRawMany: jest.fn(), + getRawOne: jest.fn(), + }; + const mockCourseRepository = { find: jest.fn(), + createQueryBuilder: jest.fn(() => mockQueryBuilder), }; const mockPaymentRepository = { @@ -92,7 +107,8 @@ describe('PayoutsService', () => { describe('getRevenueBreakdown', () => { it('should return empty summary if instructor has no courses', async () => { - mockCourseRepository.find.mockResolvedValue([]); + mockQueryBuilder.getRawMany.mockResolvedValueOnce([]); + mockQueryBuilder.getRawOne.mockResolvedValueOnce(null); const result = await service.getRevenueBreakdown('inst-1'); @@ -105,29 +121,18 @@ describe('PayoutsService', () => { }, courses: [], }); - expect(mockCourseRepository.find).toHaveBeenCalledWith({ - where: { instructorId: 'inst-1' }, - }); + expect(mockCourseRepository.createQueryBuilder).toHaveBeenCalledWith('course'); }); it('should compute gross, refunds, and net revenue correctly per course', async () => { - const mockCourses = [ - { id: 'course-1', title: 'Course One', instructorId: 'inst-1' }, - { id: 'course-2', title: 'Course Two', instructorId: 'inst-1' }, + const mockRawCourses = [ + { courseId: 'course-1', title: 'Course One', grossRevenue: '250.00', refunds: '25.00', salesCount: '2' }, + { courseId: 'course-2', title: 'Course Two', grossRevenue: '200.00', refunds: '0.00', salesCount: '1' }, ]; - mockCourseRepository.find.mockResolvedValue(mockCourses); + const mockSummary = { totalGrossRevenue: '450.00', totalRefunds: '25.00' }; - const mockPayments = [ - { id: 'pay-1', courseId: 'course-1', amount: 100.0, status: PaymentStatus.COMPLETED }, - { id: 'pay-2', courseId: 'course-1', amount: 150.0, status: PaymentStatus.COMPLETED }, - { id: 'pay-3', courseId: 'course-2', amount: 200.0, status: PaymentStatus.COMPLETED }, - ]; - mockPaymentRepository.find.mockResolvedValue(mockPayments); - - const mockRefunds = [ - { id: 'ref-1', paymentId: 'pay-1', amount: 25.0, status: RefundStatus.PROCESSED }, - ]; - mockRefundRepository.find.mockResolvedValue(mockRefunds); + mockQueryBuilder.getRawMany.mockResolvedValueOnce(mockRawCourses); + mockQueryBuilder.getRawOne.mockResolvedValueOnce(mockSummary); const result = await service.getRevenueBreakdown('inst-1'); diff --git a/src/payments/payouts/payouts.service.ts b/src/payments/payouts/payouts.service.ts index ee48b220..f88725f8 100644 --- a/src/payments/payouts/payouts.service.ts +++ b/src/payments/payouts/payouts.service.ts @@ -34,72 +34,56 @@ export class PayoutsService { /** * Generates the revenue breakdown for an instructor, course-by-course. */ - async getRevenueBreakdown(instructorId: string) { - const courses = await this.courseRepository.find({ - where: { instructorId }, - }); - - if (courses.length === 0) { - return { - summary: { - totalGrossRevenue: 0.0, - totalRefunds: 0.0, - totalNetRevenue: 0.0, - currency: 'USD', - }, - courses: [], - }; - } - - const courseIds = courses.map((c) => c.id); - - // Fetch all completed payments for instructor's courses - const payments = await this.paymentRepository.find({ - where: { - courseId: In(courseIds), - status: PaymentStatus.COMPLETED, - }, - }); - - const paymentIds = payments.map((p) => p.id); - - // Fetch all processed refunds for those payments - const refunds = - paymentIds.length > 0 - ? await this.refundRepository.find({ - where: { - paymentId: In(paymentIds), - status: RefundStatus.PROCESSED, - }, - }) - : []; - - // Map payments and refunds to courses - let totalGrossRevenue = 0; - let totalRefunds = 0; - - const coursesBreakdown = courses.map((course) => { - const coursePayments = payments.filter((p) => p.courseId === course.id); - const coursePaymentIds = coursePayments.map((p) => p.id); - const courseRefunds = refunds.filter((r) => coursePaymentIds.includes(r.paymentId)); - - const gross = coursePayments.reduce((sum, p) => sum + Number(p.amount), 0); - const refunded = courseRefunds.reduce((sum, r) => sum + Number(r.amount), 0); - const net = gross - refunded; - - totalGrossRevenue += gross; - totalRefunds += refunded; - + async getRevenueBreakdown(instructorId: string, page: number = 1, limit: number = 10) { + const skip = (page - 1) * limit; + + const qb = this.courseRepository.createQueryBuilder('course') + .leftJoin(Payment, 'payment', 'payment.courseId = course.id AND payment.status = :paymentStatus', { paymentStatus: PaymentStatus.COMPLETED }) + .leftJoin(Refund, 'refund', 'refund.paymentId = payment.id AND refund.status = :refundStatus', { refundStatus: RefundStatus.PROCESSED }) + .where('course.instructorId = :instructorId', { instructorId }) + .select([ + 'course.id AS "courseId"', + 'course.title AS "title"', + ]) + .addSelect('COUNT(DISTINCT payment.id)', 'salesCount') + .addSelect('COALESCE(SUM(payment.amount), 0)', 'grossRevenue') + .addSelect('COALESCE(SUM(refund.amount), 0)', 'refunds') + .groupBy('course.id') + .addGroupBy('course.title') + .orderBy('course.id', 'ASC') + .offset(skip) + .limit(limit); + + const summaryQb = this.courseRepository.createQueryBuilder('course') + .leftJoin(Payment, 'payment', 'payment.courseId = course.id AND payment.status = :paymentStatus', { paymentStatus: PaymentStatus.COMPLETED }) + .leftJoin(Refund, 'refund', 'refund.paymentId = payment.id AND refund.status = :refundStatus', { refundStatus: RefundStatus.PROCESSED }) + .where('course.instructorId = :instructorId', { instructorId }) + .select([ + 'COALESCE(SUM(payment.amount), 0) AS "totalGrossRevenue"', + 'COALESCE(SUM(refund.amount), 0) AS "totalRefunds"' + ]); + + const [rawCourses, summaryRaw] = await Promise.all([ + qb.getRawMany(), + summaryQb.getRawOne() + ]); + + const coursesBreakdown = rawCourses.map((raw) => { + const gross = Number(raw.grossRevenue); + const refunded = Number(raw.refunds); return { - courseId: course.id, - title: course.title, + courseId: raw.courseId, + title: raw.title, grossRevenue: Number(gross.toFixed(2)), refunds: Number(refunded.toFixed(2)), - netRevenue: Number(net.toFixed(2)), - salesCount: coursePayments.length, + netRevenue: Number((gross - refunded).toFixed(2)), + salesCount: Number(raw.salesCount), }; }); + const totalGrossRevenue = summaryRaw ? Number(summaryRaw.totalGrossRevenue) : 0; + const totalRefunds = summaryRaw ? Number(summaryRaw.totalRefunds) : 0; + return { summary: { totalGrossRevenue: Number(totalGrossRevenue.toFixed(2)),