Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/payments/entities/refund.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export class Refund {
payment: Payment;

@Column({ name: 'payment_id' })
@Index()
paymentId: string;

@CreateDateColumn()
Expand Down
43 changes: 24 additions & 19 deletions src/payments/payouts/payouts.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,23 @@
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 = {
Expand Down Expand Up @@ -92,7 +107,8 @@

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');

Expand All @@ -105,29 +121,18 @@
},
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' },

Check failure on line 129 in src/payments/payouts/payouts.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `·courseId:·'course-1',·title:·'Course·One',·grossRevenue:·'250.00',·refunds:·'25.00',·salesCount:·'2'` with `⏎··········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' },

Check failure on line 130 in src/payments/payouts/payouts.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `·courseId:·'course-2',·title:·'Course·Two',·grossRevenue:·'200.00',·refunds:·'0.00',·salesCount:·'1'` with `⏎··········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');

Expand Down
104 changes: 44 additions & 60 deletions src/payments/payouts/payouts.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';

Check warning on line 3 in src/payments/payouts/payouts.service.ts

View workflow job for this annotation

GitHub Actions / validate

'In' is defined but never used. Allowed unused vars must match /^_/u
import { Payment, PaymentStatus } from '../entities/payment.entity';
import { Refund, RefundStatus } from '../entities/refund.entity';
import { Course } from '../../courses/entities/course.entity';
Expand Down Expand Up @@ -34,72 +34,56 @@
/**
* 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')

Check failure on line 40 in src/payments/payouts/payouts.service.ts

View workflow job for this annotation

GitHub Actions / validate

Insert `⏎······`
.leftJoin(Payment, 'payment', 'payment.courseId = course.id AND payment.status = :paymentStatus', { paymentStatus: PaymentStatus.COMPLETED })

Check failure on line 41 in src/payments/payouts/payouts.service.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `Payment,·'payment',·'payment.courseId·=·course.id·AND·payment.status·=·:paymentStatus',·{·paymentStatus:·PaymentStatus.COMPLETED·}` with `⏎········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 })

Check failure on line 42 in src/payments/payouts/payouts.service.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `Refund,·'refund',·'refund.paymentId·=·payment.id·AND·refund.status·=·:refundStatus',·{·refundStatus:·RefundStatus.PROCESSED·}` with `⏎········Refund,⏎········'refund',⏎········'refund.paymentId·=·payment.id·AND·refund.status·=·:refundStatus',⏎········{·refundStatus:·RefundStatus.PROCESSED·},⏎······`
.where('course.instructorId = :instructorId', { instructorId })
.select([

Check failure on line 44 in src/payments/payouts/payouts.service.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎········'course.id·AS·"courseId"',⏎········'course.title·AS·"title"',⏎······` with `'course.id·AS·"courseId"',·'course.title·AS·"title"'`
'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')

Check failure on line 57 in src/payments/payouts/payouts.service.ts

View workflow job for this annotation

GitHub Actions / validate

Insert `⏎······`
.leftJoin(Payment, 'payment', 'payment.courseId = course.id AND payment.status = :paymentStatus', { paymentStatus: PaymentStatus.COMPLETED })

Check failure on line 58 in src/payments/payouts/payouts.service.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `Payment,·'payment',·'payment.courseId·=·course.id·AND·payment.status·=·:paymentStatus',·{·paymentStatus:·PaymentStatus.COMPLETED·}` with `⏎········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 })

Check failure on line 59 in src/payments/payouts/payouts.service.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `Refund,·'refund',·'refund.paymentId·=·payment.id·AND·refund.status·=·:refundStatus',·{·refundStatus:·RefundStatus.PROCESSED·}` with `⏎········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"'

Check failure on line 63 in src/payments/payouts/payouts.service.ts

View workflow job for this annotation

GitHub Actions / validate

Insert `,`
]);

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)),
Expand Down
Loading