From 4b4d8f508a6be1a593369953c3b0c18b0ac7ffcf Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Mon, 1 Jun 2026 22:45:36 -0700 Subject: [PATCH] feat(payouts): implement instructor revenue breakdown and payouts system --- src/app.module.ts | 2 + src/notifications/notifications.queue.ts | 1 - src/notifications/notifications.service.ts | 25 +- .../notification-template.service.ts | 7 + .../entities/payout-profile.entity.ts | 40 +++ src/payments/entities/payout.entity.ts | 64 ++++ src/payments/payouts/dto/payout.dto.ts | 52 +++ .../payouts/payouts.controller.spec.ts | 111 ++++++ src/payments/payouts/payouts.controller.ts | 69 ++++ src/payments/payouts/payouts.module.ts | 29 ++ src/payments/payouts/payouts.service.spec.ts | 321 ++++++++++++++++++ src/payments/payouts/payouts.service.ts | 226 ++++++++++++ 12 files changed, 927 insertions(+), 20 deletions(-) create mode 100644 src/payments/entities/payout-profile.entity.ts create mode 100644 src/payments/entities/payout.entity.ts create mode 100644 src/payments/payouts/dto/payout.dto.ts create mode 100644 src/payments/payouts/payouts.controller.spec.ts create mode 100644 src/payments/payouts/payouts.controller.ts create mode 100644 src/payments/payouts/payouts.module.ts create mode 100644 src/payments/payouts/payouts.service.spec.ts create mode 100644 src/payments/payouts/payouts.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 86fe5ffe..75bc78cd 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -27,6 +27,7 @@ import { DeepLinkModule } from './deep-link/deep-link.module'; import { InvoicesModule } from './payments/invoices/invoices.module'; import { PaymentMethodsModule } from './payments/payment-methods/payment-methods.module'; import { ReportingModule } from './payments/reporting/reporting.module'; +import { PayoutsModule } from './payments/payouts/payouts.module'; import { NotificationsModule } from './notifications/notifications.module'; import { HealthModule } from './health/health.module'; import { ModerationModule } from './moderation/moderation.module'; @@ -68,6 +69,7 @@ const featureFlags = loadFeatureFlags(); PaymentMethodsModule, NotificationsModule, ReportingModule, + PayoutsModule, HealthModule, ...(featureFlags.ENABLE_MODERATION ? [ModerationModule] : []), diff --git a/src/notifications/notifications.queue.ts b/src/notifications/notifications.queue.ts index fb7e648a..bba17f29 100644 --- a/src/notifications/notifications.queue.ts +++ b/src/notifications/notifications.queue.ts @@ -35,7 +35,6 @@ export class NotificationsQueueService { * Publish notification to SNS topic */ async publishToTopic(notification: Notification, options?: { bypassBatch?: boolean }): Promise { - async publishToTopic(notification: Notification): Promise { if (!this.snsTopicArn || !this.queueUrl) { this.logger.warn( `AWS SNS/SQS not configured; marking notification ${notification.id} as sent (dev mode)`, diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index ba2b921d..998bd6ce 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -28,16 +28,8 @@ const BATCH_CONFIG: Record, - private readonly notificationsQueue: NotificationsQueueService, + private readonly queueService: NotificationsQueueService, + private readonly preferencesService: PreferencesService, + private readonly templateService: NotificationTemplateService, ) { const batchWindowSetting = this.configService.get('NOTIFICATION_BATCH_WINDOW_MS', `${DEFAULT_BATCH_WINDOW_MS}`); this.batchWindowMs = Number(batchWindowSetting) || DEFAULT_BATCH_WINDOW_MS; @@ -134,7 +128,7 @@ export class NotificationsService { deliveryAttempts: 0, }); - await this.notificationsQueue.publishToTopic(batchNotification); + await this.queueService.publishToTopic(batchNotification); const ids = notifications.map((notification) => notification.id); await this.notificationRepository.update({ id: In(ids) }, { status: NotificationStatus.SENT, lastAttemptAt: new Date() }); await this.notificationRepository.save(batchNotification); @@ -143,7 +137,7 @@ export class NotificationsService { } private async publish(notification: Notification, bypassBatch = false): Promise { - await this.notificationsQueue.publishToTopic(notification, { bypassBatch }); + await this.queueService.publishToTopic(notification, { bypassBatch }); await this.notificationRepository.update(notification.id, { status: NotificationStatus.SENT, lastAttemptAt: new Date(), @@ -169,14 +163,7 @@ export class NotificationsService { const age = Date.now() - existing.createdAt.getTime(); return age <= this.batchWindowMs ? existing : null; - - constructor( - @InjectRepository(Notification) - private readonly notificationRepository: Repository, - private readonly preferencesService: PreferencesService, - private readonly queueService: NotificationsQueueService, - private readonly templateService: NotificationTemplateService, - ) {} + } async findForUser(userId: string, limit = 50): Promise { return this.notificationRepository.find({ diff --git a/src/notifications/templates/notification-template.service.ts b/src/notifications/templates/notification-template.service.ts index b50e3acd..251b4135 100644 --- a/src/notifications/templates/notification-template.service.ts +++ b/src/notifications/templates/notification-template.service.ts @@ -95,6 +95,13 @@ export class NotificationTemplateService { channel: NotificationType.PUSH, bodyTemplate: 'You enrolled in {{courseName}}', }, + { + name: 'instructor_payout', + templateVersion: 1, + channel: NotificationType.EMAIL, + subjectTemplate: 'Your payout of {{amount}} {{currency}} has been processed!', + bodyTemplate: '

Hello {{instructorName}},

We are pleased to inform you that your payout of {{amount}} {{currency}} has been successfully processed via {{payoutMethod}}.

Details: {{payoutDetails}}

Thank you for teaching on TeachLink!

', + }, ]; for (const def of defaults) { diff --git a/src/payments/entities/payout-profile.entity.ts b/src/payments/entities/payout-profile.entity.ts new file mode 100644 index 00000000..437b615c --- /dev/null +++ b/src/payments/entities/payout-profile.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('instructor_payout_profiles') +export class InstructorPayoutProfile { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'instructor_id', unique: true }) + @Index() + instructorId: string; + + @OneToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'instructor_id' }) + instructor: User; + + @Column({ type: 'varchar', default: 'monthly' }) + payoutSchedule: string; // 'weekly', 'monthly', 'instant' + + @Column({ type: 'varchar', default: 'paypal' }) + payoutMethod: string; // 'paypal', 'bank_transfer' + + @Column({ type: 'varchar', nullable: true }) + payoutDetails: string; // paypal email or bank details + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/payments/entities/payout.entity.ts b/src/payments/entities/payout.entity.ts new file mode 100644 index 00000000..4120078c --- /dev/null +++ b/src/payments/entities/payout.entity.ts @@ -0,0 +1,64 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +export enum PayoutStatus { + PENDING = 'pending', + PROCESSING = 'processing', + COMPLETED = 'completed', + FAILED = 'failed', +} + +@Entity('instructor_payouts') +export class InstructorPayout { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'instructor_id' }) + @Index() + instructorId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'instructor_id' }) + instructor: User; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + amount: number; + + @Column({ type: 'varchar', length: 3, default: 'USD' }) + currency: string; + + @Column({ + type: 'enum', + enum: PayoutStatus, + default: PayoutStatus.PENDING, + }) + @Index() + status: PayoutStatus; + + @Column({ type: 'varchar', default: 'paypal' }) + payoutMethod: string; + + @Column({ type: 'varchar', nullable: true }) + payoutDetails: string; + + @Column({ type: 'timestamp', nullable: true }) + payoutDate: Date; + + @Column({ type: 'text', nullable: true }) + failureReason: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/payments/payouts/dto/payout.dto.ts b/src/payments/payouts/dto/payout.dto.ts new file mode 100644 index 00000000..acf33bdc --- /dev/null +++ b/src/payments/payouts/dto/payout.dto.ts @@ -0,0 +1,52 @@ +import { IsString, IsEnum, IsNotEmpty, IsNumber, IsPositive } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export enum PayoutSchedulePreference { + WEEKLY = 'weekly', + MONTHLY = 'monthly', + INSTANT = 'instant', +} + +export class UpdatePayoutSettingsDto { + @ApiProperty({ + description: 'Payout schedule preference', + enum: PayoutSchedulePreference, + example: PayoutSchedulePreference.MONTHLY, + }) + @IsEnum(PayoutSchedulePreference) + @IsNotEmpty() + payoutSchedule: string; + + @ApiProperty({ + description: 'Payout method (e.g. paypal, bank_transfer)', + example: 'paypal', + }) + @IsString() + @IsNotEmpty() + payoutMethod: string; + + @ApiProperty({ + description: 'PayPal email address or bank details details', + example: 'instructor@example.com', + }) + @IsString() + @IsNotEmpty() + payoutDetails: string; +} + +export class ProcessPayoutDto { + @ApiProperty({ + description: 'The instructor ID to trigger payout for', + }) + @IsString() + @IsNotEmpty() + instructorId: string; + + @ApiProperty({ + description: 'Payout amount', + example: 100.00, + }) + @IsNumber() + @IsPositive() + amount: number; +} diff --git a/src/payments/payouts/payouts.controller.spec.ts b/src/payments/payouts/payouts.controller.spec.ts new file mode 100644 index 00000000..6aa7285f --- /dev/null +++ b/src/payments/payouts/payouts.controller.spec.ts @@ -0,0 +1,111 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PayoutsController } from './payouts.controller'; +import { PayoutsService } from './payouts.service'; +import { PayoutStatus } from '../entities/payout.entity'; + +describe('PayoutsController', () => { + let controller: PayoutsController; + let service: PayoutsService; + + const mockPayoutsService = { + getRevenueBreakdown: jest.fn(), + getPayoutProfile: jest.fn(), + updatePayoutProfile: jest.fn(), + getHistoricalPayouts: jest.fn(), + processPayout: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [PayoutsController], + providers: [ + { + provide: PayoutsService, + useValue: mockPayoutsService, + }, + ], + }).compile(); + + controller = module.get(PayoutsController); + service = module.get(PayoutsService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getRevenueBreakdown', () => { + it('should delegate to service using instructor ID from request', async () => { + const mockResult = { summary: { totalGrossRevenue: 100.0 }, courses: [] }; + mockPayoutsService.getRevenueBreakdown.mockResolvedValue(mockResult); + + const mockRequest = { user: { id: 'instructor-123' } }; + const result = await controller.getRevenueBreakdown(mockRequest); + + expect(result).toBe(mockResult); + expect(mockPayoutsService.getRevenueBreakdown).toHaveBeenCalledWith('instructor-123'); + }); + }); + + describe('getPayoutProfile', () => { + it('should delegate to service to fetch settings', async () => { + const mockProfile = { id: 'prof-1', instructorId: 'inst-123', payoutSchedule: 'monthly' }; + mockPayoutsService.getPayoutProfile.mockResolvedValue(mockProfile); + + const mockRequest = { user: { id: 'inst-123' } }; + const result = await controller.getPayoutProfile(mockRequest); + + expect(result).toBe(mockProfile); + expect(mockPayoutsService.getPayoutProfile).toHaveBeenCalledWith('inst-123'); + }); + }); + + describe('updatePayoutProfile', () => { + it('should delegate to service to save new settings', async () => { + const mockUpdated = { id: 'prof-1', payoutSchedule: 'weekly' }; + mockPayoutsService.updatePayoutProfile.mockResolvedValue(mockUpdated); + + const dto = { + payoutSchedule: 'weekly', + payoutMethod: 'paypal', + payoutDetails: 'inst@example.com', + }; + const mockRequest = { user: { id: 'inst-123' } }; + const result = await controller.updatePayoutProfile(mockRequest, dto); + + expect(result).toBe(mockUpdated); + expect(mockPayoutsService.updatePayoutProfile).toHaveBeenCalledWith('inst-123', dto); + }); + }); + + describe('getHistoricalPayouts', () => { + it('should delegate to service to fetch payout history', async () => { + const mockPayouts = [{ id: 'p-1', amount: 100.0, status: PayoutStatus.COMPLETED }]; + mockPayoutsService.getHistoricalPayouts.mockResolvedValue(mockPayouts); + + const mockRequest = { user: { id: 'inst-123' } }; + const result = await controller.getHistoricalPayouts(mockRequest); + + expect(result).toBe(mockPayouts); + expect(mockPayoutsService.getHistoricalPayouts).toHaveBeenCalledWith('inst-123'); + }); + }); + + describe('processPayout', () => { + it('should delegate to service to trigger payout processing', async () => { + const mockProcessed = { id: 'payout-1', amount: 150.0 }; + mockPayoutsService.processPayout.mockResolvedValue(mockProcessed); + + const dto = { + instructorId: 'inst-555', + amount: 150.0, + }; + const result = await controller.processPayout(dto); + + expect(result).toBe(mockProcessed); + expect(mockPayoutsService.processPayout).toHaveBeenCalledWith('inst-555', 150.0); + }); + }); +}); diff --git a/src/payments/payouts/payouts.controller.ts b/src/payments/payouts/payouts.controller.ts new file mode 100644 index 00000000..39a8d938 --- /dev/null +++ b/src/payments/payouts/payouts.controller.ts @@ -0,0 +1,69 @@ +import { + Controller, + Get, + Post, + Put, + Body, + UseGuards, + Request, + HttpCode, + HttpStatus, + ForbiddenException, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { PayoutsService } from './payouts.service'; +import { UpdatePayoutSettingsDto, ProcessPayoutDto } from './dto/payout.dto'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { UserRole } from '../../users/entities/user.entity'; + +@ApiTags('Payouts') +@Controller('payments/payouts') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth() +@ApiResponse({ status: 401, description: 'Authentication required' }) +export class PayoutsController { + constructor(private readonly payoutsService: PayoutsService) {} + + @Get('revenue') + @Roles(UserRole.INSTRUCTOR, UserRole.TEACHER, UserRole.ADMIN) + @ApiOperation({ summary: 'Get revenue breakdown by course for current instructor' }) + @ApiResponse({ status: 200, description: 'Returns revenue breakdown' }) + async getRevenueBreakdown(@Request() req) { + return this.payoutsService.getRevenueBreakdown(req.user.id); + } + + @Get('settings') + @Roles(UserRole.INSTRUCTOR, UserRole.TEACHER, UserRole.ADMIN) + @ApiOperation({ summary: 'Get payout profile settings for current instructor' }) + @ApiResponse({ status: 200, description: 'Returns payout settings profile' }) + async getPayoutProfile(@Request() req) { + return this.payoutsService.getPayoutProfile(req.user.id); + } + + @Put('settings') + @Roles(UserRole.INSTRUCTOR, UserRole.TEACHER, UserRole.ADMIN) + @ApiOperation({ summary: 'Update payout profile settings for current instructor' }) + @ApiResponse({ status: 200, description: 'Returns updated settings' }) + async updatePayoutProfile(@Request() req, @Body() dto: UpdatePayoutSettingsDto) { + return this.payoutsService.updatePayoutProfile(req.user.id, dto); + } + + @Get('historical') + @Roles(UserRole.INSTRUCTOR, UserRole.TEACHER, UserRole.ADMIN) + @ApiOperation({ summary: 'Get historical payouts list for current instructor' }) + @ApiResponse({ status: 200, description: 'Returns list of historical payout transactions' }) + async getHistoricalPayouts(@Request() req) { + return this.payoutsService.getHistoricalPayouts(req.user.id); + } + + @Post('admin/process') + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Process a payout for an instructor (Admin only)' }) + @ApiResponse({ status: 200, description: 'Payout processed successfully' }) + async processPayout(@Body() dto: ProcessPayoutDto) { + return this.payoutsService.processPayout(dto.instructorId, dto.amount); + } +} diff --git a/src/payments/payouts/payouts.module.ts b/src/payments/payouts/payouts.module.ts new file mode 100644 index 00000000..ffc5053f --- /dev/null +++ b/src/payments/payouts/payouts.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PayoutsService } from './payouts.service'; +import { PayoutsController } from './payouts.controller'; +import { InstructorPayoutProfile } from '../entities/payout-profile.entity'; +import { InstructorPayout } from '../entities/payout.entity'; +import { Course } from '../../courses/entities/course.entity'; +import { Payment } from '../entities/payment.entity'; +import { Refund } from '../entities/refund.entity'; +import { User } from '../../users/entities/user.entity'; +import { NotificationsModule } from '../../notifications/notifications.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + InstructorPayoutProfile, + InstructorPayout, + Course, + Payment, + Refund, + User, + ]), + NotificationsModule, + ], + controllers: [PayoutsController], + providers: [PayoutsService], + exports: [PayoutsService], +}) +export class PayoutsModule {} diff --git a/src/payments/payouts/payouts.service.spec.ts b/src/payments/payouts/payouts.service.spec.ts new file mode 100644 index 00000000..5f135145 --- /dev/null +++ b/src/payments/payouts/payouts.service.spec.ts @@ -0,0 +1,321 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PayoutsService } from './payouts.service'; +import { Course } from '../../courses/entities/course.entity'; +import { Payment, PaymentStatus } from '../entities/payment.entity'; +import { Refund, RefundStatus } from '../entities/refund.entity'; +import { User } from '../../users/entities/user.entity'; +import { InstructorPayoutProfile } from '../entities/payout-profile.entity'; +import { InstructorPayout, PayoutStatus } from '../entities/payout.entity'; +import { NotificationsService } from '../../notifications/notifications.service'; + +describe('PayoutsService', () => { + let service: PayoutsService; + + const mockCourseRepository = { + find: jest.fn(), + }; + + const mockPaymentRepository = { + find: jest.fn(), + }; + + const mockRefundRepository = { + find: jest.fn(), + }; + + const mockUserRepository = { + findOne: jest.fn(), + }; + + const mockPayoutProfileRepository = { + findOne: jest.fn(), + create: jest.fn((dto) => dto), + save: jest.fn(async (profile) => ({ id: 'profile-1', ...profile })), + }; + + const mockPayoutRepository = { + find: jest.fn(), + create: jest.fn((dto) => dto), + save: jest.fn(async (payout) => ({ id: 'payout-1', ...payout })), + }; + + const mockNotificationsService = { + sendTemplated: jest.fn(), + send: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PayoutsService, + { + provide: getRepositoryToken(Course), + useValue: mockCourseRepository, + }, + { + provide: getRepositoryToken(Payment), + useValue: mockPaymentRepository, + }, + { + provide: getRepositoryToken(Refund), + useValue: mockRefundRepository, + }, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + { + provide: getRepositoryToken(InstructorPayoutProfile), + useValue: mockPayoutProfileRepository, + }, + { + provide: getRepositoryToken(InstructorPayout), + useValue: mockPayoutRepository, + }, + { + provide: NotificationsService, + useValue: mockNotificationsService, + }, + ], + }).compile(); + + service = module.get(PayoutsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getRevenueBreakdown', () => { + it('should return empty summary if instructor has no courses', async () => { + mockCourseRepository.find.mockResolvedValue([]); + + const result = await service.getRevenueBreakdown('inst-1'); + + expect(result).toEqual({ + summary: { + totalGrossRevenue: 0.0, + totalRefunds: 0.0, + totalNetRevenue: 0.0, + currency: 'USD', + }, + courses: [], + }); + expect(mockCourseRepository.find).toHaveBeenCalledWith({ + where: { instructorId: 'inst-1' }, + }); + }); + + 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' }, + ]; + mockCourseRepository.find.mockResolvedValue(mockCourses); + + 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); + + const result = await service.getRevenueBreakdown('inst-1'); + + expect(result).toEqual({ + summary: { + totalGrossRevenue: 450.0, + totalRefunds: 25.0, + totalNetRevenue: 425.0, + currency: 'USD', + }, + courses: [ + { + courseId: 'course-1', + title: 'Course One', + grossRevenue: 250.0, + refunds: 25.0, + netRevenue: 225.0, + salesCount: 2, + }, + { + courseId: 'course-2', + title: 'Course Two', + grossRevenue: 200.0, + refunds: 0.0, + netRevenue: 200.0, + salesCount: 1, + }, + ], + }); + }); + }); + + describe('getPayoutProfile', () => { + it('should return existing profile if found', async () => { + const existingProfile = { + id: 'prof-1', + instructorId: 'inst-1', + payoutSchedule: 'weekly', + payoutMethod: 'bank_transfer', + payoutDetails: 'XYZ Bank', + }; + mockPayoutProfileRepository.findOne.mockResolvedValue(existingProfile); + + const result = await service.getPayoutProfile('inst-1'); + + expect(result).toBe(existingProfile); + expect(mockPayoutProfileRepository.create).not.toHaveBeenCalled(); + }); + + it('should lazily create and return default profile if not found', async () => { + mockPayoutProfileRepository.findOne.mockResolvedValue(null); + + const result = await service.getPayoutProfile('inst-1'); + + expect(result).toEqual({ + id: 'profile-1', + instructorId: 'inst-1', + payoutSchedule: 'monthly', + payoutMethod: 'paypal', + payoutDetails: '', + }); + expect(mockPayoutProfileRepository.create).toHaveBeenCalledWith({ + instructorId: 'inst-1', + payoutSchedule: 'monthly', + payoutMethod: 'paypal', + payoutDetails: '', + }); + expect(mockPayoutProfileRepository.save).toHaveBeenCalled(); + }); + }); + + describe('updatePayoutProfile', () => { + it('should update and save payout profile details', async () => { + const existingProfile = { + id: 'prof-1', + instructorId: 'inst-1', + payoutSchedule: 'weekly', + payoutMethod: 'paypal', + payoutDetails: 'inst@example.com', + }; + mockPayoutProfileRepository.findOne.mockResolvedValue(existingProfile); + mockPayoutProfileRepository.save.mockImplementation(async (profile) => profile); + + const updateDto = { + payoutSchedule: 'monthly', + payoutMethod: 'bank_transfer', + payoutDetails: 'bank-routing-details', + }; + + const result = await service.updatePayoutProfile('inst-1', updateDto); + + expect(result.payoutSchedule).toBe('monthly'); + expect(result.payoutMethod).toBe('bank_transfer'); + expect(result.payoutDetails).toBe('bank-routing-details'); + expect(mockPayoutProfileRepository.save).toHaveBeenCalledWith(existingProfile); + }); + }); + + describe('getHistoricalPayouts', () => { + it('should retrieve payouts sorted by creation date descending', async () => { + const mockPayouts = [ + { id: 'p-1', instructorId: 'inst-1', amount: 150.0 }, + { id: 'p-2', instructorId: 'inst-1', amount: 200.0 }, + ]; + mockPayoutRepository.find.mockResolvedValue(mockPayouts); + + const result = await service.getHistoricalPayouts('inst-1'); + + expect(result).toBe(mockPayouts); + expect(mockPayoutRepository.find).toHaveBeenCalledWith({ + where: { instructorId: 'inst-1' }, + order: { createdAt: 'DESC' }, + }); + }); + }); + + describe('processPayout', () => { + it('should create completed payout and attempt sending templated email', async () => { + const existingProfile = { + id: 'prof-1', + instructorId: 'inst-1', + payoutSchedule: 'monthly', + payoutMethod: 'paypal', + payoutDetails: 'instructor@example.com', + }; + mockPayoutProfileRepository.findOne.mockResolvedValue(existingProfile); + + const mockInstructor = { + id: 'inst-1', + firstName: 'John', + lastName: 'Doe', + email: 'instructor@example.com', + }; + mockUserRepository.findOne.mockResolvedValue(mockInstructor); + + const result = await service.processPayout('inst-1', 300.0); + + expect(result.id).toBe('payout-1'); + expect(result.amount).toBe(300.0); + expect(result.status).toBe(PayoutStatus.COMPLETED); + expect(result.payoutMethod).toBe('paypal'); + expect(result.payoutDetails).toBe('instructor@example.com'); + + expect(mockNotificationsService.sendTemplated).toHaveBeenCalledWith({ + userId: 'inst-1', + templateName: 'instructor_payout', + eventType: 'payout', + context: { + instructorName: 'John Doe', + amount: '300', + currency: 'USD', + payoutMethod: 'paypal', + payoutDetails: 'instructor@example.com', + }, + }); + expect(mockNotificationsService.send).not.toHaveBeenCalled(); + }); + + it('should send direct fallback notification if templated email fails', async () => { + const existingProfile = { + id: 'prof-1', + instructorId: 'inst-1', + payoutSchedule: 'monthly', + payoutMethod: 'paypal', + payoutDetails: 'instructor@example.com', + }; + mockPayoutProfileRepository.findOne.mockResolvedValue(existingProfile); + + const mockInstructor = { + id: 'inst-1', + firstName: 'John', + lastName: 'Doe', + email: 'instructor@example.com', + }; + mockUserRepository.findOne.mockResolvedValue(mockInstructor); + + mockNotificationsService.sendTemplated.mockRejectedValue(new Error('Template render error')); + + const result = await service.processPayout('inst-1', 300.0); + + expect(result.id).toBe('payout-1'); + expect(mockNotificationsService.sendTemplated).toHaveBeenCalled(); + expect(mockNotificationsService.send).toHaveBeenCalledWith({ + userId: 'inst-1', + title: 'Your payout has been processed!', + content: expect.stringContaining('Hello John Doe'), + type: 'email', + }); + }); + }); +}); diff --git a/src/payments/payouts/payouts.service.ts b/src/payments/payouts/payouts.service.ts new file mode 100644 index 00000000..a2363bd8 --- /dev/null +++ b/src/payments/payouts/payouts.service.ts @@ -0,0 +1,226 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { Payment, PaymentStatus } from '../entities/payment.entity'; +import { Refund, RefundStatus } from '../entities/refund.entity'; +import { Course } from '../../courses/entities/course.entity'; +import { User } from '../../users/entities/user.entity'; +import { InstructorPayoutProfile } from '../entities/payout-profile.entity'; +import { InstructorPayout, PayoutStatus } from '../entities/payout.entity'; +import { UpdatePayoutSettingsDto } from './dto/payout.dto'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationType } from '../../notifications/entities/notification.entity'; + +@Injectable() +export class PayoutsService { + private readonly logger = new Logger(PayoutsService.name); + + constructor( + @InjectRepository(Course) + private readonly courseRepository: Repository, + @InjectRepository(Payment) + private readonly paymentRepository: Repository, + @InjectRepository(Refund) + private readonly refundRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(InstructorPayoutProfile) + private readonly payoutProfileRepository: Repository, + @InjectRepository(InstructorPayout) + private readonly payoutRepository: Repository, + private readonly notificationsService: NotificationsService, + ) {} + + /** + * 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; + + return { + courseId: course.id, + title: course.title, + grossRevenue: Number(gross.toFixed(2)), + refunds: Number(refunded.toFixed(2)), + netRevenue: Number(net.toFixed(2)), + salesCount: coursePayments.length, + }; + }); + + return { + summary: { + totalGrossRevenue: Number(totalGrossRevenue.toFixed(2)), + totalRefunds: Number(totalRefunds.toFixed(2)), + totalNetRevenue: Number((totalGrossRevenue - totalRefunds).toFixed(2)), + currency: 'USD', + }, + courses: coursesBreakdown, + }; + } + + /** + * Fetches or lazily creates a payout profile settings for an instructor. + */ + async getPayoutProfile(instructorId: string): Promise { + let profile = await this.payoutProfileRepository.findOne({ + where: { instructorId }, + }); + + if (!profile) { + profile = this.payoutProfileRepository.create({ + instructorId, + payoutSchedule: 'monthly', + payoutMethod: 'paypal', + payoutDetails: '', + }); + profile = await this.payoutProfileRepository.save(profile); + } + + return profile; + } + + /** + * Updates an instructor's payout profile. + */ + async updatePayoutProfile( + instructorId: string, + dto: UpdatePayoutSettingsDto, + ): Promise { + const profile = await this.getPayoutProfile(instructorId); + profile.payoutSchedule = dto.payoutSchedule; + profile.payoutMethod = dto.payoutMethod; + profile.payoutDetails = dto.payoutDetails; + return this.payoutProfileRepository.save(profile); + } + + /** + * Returns the payout history of an instructor. + */ + async getHistoricalPayouts(instructorId: string): Promise { + return this.payoutRepository.find({ + where: { instructorId }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Processes a payout transaction for an instructor and sends a notification. + */ + async processPayout( + instructorId: string, + amount: number, + method?: string, + details?: string, + ): Promise { + const profile = await this.getPayoutProfile(instructorId); + const payoutMethod = method ?? profile.payoutMethod; + const payoutDetails = details ?? profile.payoutDetails; + + const payout = this.payoutRepository.create({ + instructorId, + amount, + currency: 'USD', + status: PayoutStatus.COMPLETED, + payoutMethod, + payoutDetails, + payoutDate: new Date(), + }); + + const savedPayout = await this.payoutRepository.save(payout); + + // Retrieve instructor profile for name and email + const instructor = await this.userRepository.findOne({ + where: { id: instructorId }, + }); + + if (instructor) { + try { + await this.notificationsService.sendTemplated({ + userId: instructorId, + templateName: 'instructor_payout', + eventType: 'payout', + context: { + instructorName: `${instructor.firstName} ${instructor.lastName}`, + amount: savedPayout.amount.toString(), + currency: savedPayout.currency, + payoutMethod: savedPayout.payoutMethod, + payoutDetails: savedPayout.payoutDetails || 'N/A', + }, + }); + this.logger.log(`Templated payout email sent successfully to instructor ${instructorId}`); + } catch (err) { + this.logger.warn( + `Failed to send templated payout email to instructor ${instructorId}, falling back to direct notification: ${err.message}`, + ); + try { + await this.notificationsService.send({ + userId: instructorId, + title: 'Your payout has been processed!', + content: `Hello ${instructor.firstName} ${instructor.lastName},\n\nWe are pleased to inform you that your payout of ${savedPayout.amount} ${savedPayout.currency} has been successfully processed via ${savedPayout.payoutMethod}.\n\nDetails: ${savedPayout.payoutDetails || 'N/A'}\n\nThank you for teaching on TeachLink!`, + type: NotificationType.EMAIL, + }); + this.logger.log(`Direct fallback payout email sent successfully to instructor ${instructorId}`); + } catch (fallbackErr) { + this.logger.error( + `Failed to send direct fallback payout notification: ${fallbackErr.message}`, + ); + } + } + } + + return savedPayout; + } +}