Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -68,6 +69,7 @@ const featureFlags = loadFeatureFlags();
PaymentMethodsModule,
NotificationsModule,
ReportingModule,
PayoutsModule,
HealthModule,
...(featureFlags.ENABLE_MODERATION ? [ModerationModule] : []),

Expand Down
1 change: 0 additions & 1 deletion src/notifications/notifications.queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export class NotificationsQueueService {
* Publish notification to SNS topic
*/
async publishToTopic(notification: Notification, options?: { bypassBatch?: boolean }): Promise<void> {
async publishToTopic(notification: Notification): Promise<void> {
if (!this.snsTopicArn || !this.queueUrl) {
this.logger.warn(
`AWS SNS/SQS not configured; marking notification ${notification.id} as sent (dev mode)`,
Expand Down
25 changes: 6 additions & 19 deletions src/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,8 @@ const BATCH_CONFIG: Record<NotificationType, { intervalMs: number; batchLabel: s
[NotificationType.IN_APP]: { intervalMs: DEFAULT_BATCH_WINDOW_MS, batchLabel: 'In-App Summary' },
[NotificationType.SMS]: { intervalMs: DEFAULT_BATCH_WINDOW_MS, batchLabel: 'SMS Digest' },
};
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import {
Notification,
NotificationStatus,
NotificationType,
} from './entities/notification.entity';
import { CreateNotificationDto } from './dto/notification.dto';
import { PreferencesService } from './preferences/preferences.service';
import { NotificationsQueueService } from './notifications.queue';
import { NotificationTemplateService } from './templates/notification-template.service';
import { SendTemplatedNotificationDto } from './dto/preferences.dto';

Expand All @@ -50,7 +42,9 @@ export class NotificationsService {
private readonly configService: ConfigService,
@InjectRepository(Notification)
private readonly notificationRepository: Repository<Notification>,
private readonly notificationsQueue: NotificationsQueueService,
private readonly queueService: NotificationsQueueService,
private readonly preferencesService: PreferencesService,
private readonly templateService: NotificationTemplateService,
) {
const batchWindowSetting = this.configService.get<string | number>('NOTIFICATION_BATCH_WINDOW_MS', `${DEFAULT_BATCH_WINDOW_MS}`);
this.batchWindowMs = Number(batchWindowSetting) || DEFAULT_BATCH_WINDOW_MS;
Expand Down Expand Up @@ -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);
Expand All @@ -143,7 +137,7 @@ export class NotificationsService {
}

private async publish(notification: Notification, bypassBatch = false): Promise<void> {
await this.notificationsQueue.publishToTopic(notification, { bypassBatch });
await this.queueService.publishToTopic(notification, { bypassBatch });
await this.notificationRepository.update(notification.id, {
status: NotificationStatus.SENT,
lastAttemptAt: new Date(),
Expand All @@ -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<Notification>,
private readonly preferencesService: PreferencesService,
private readonly queueService: NotificationsQueueService,
private readonly templateService: NotificationTemplateService,
) {}
}

async findForUser(userId: string, limit = 50): Promise<Notification[]> {
return this.notificationRepository.find({
Expand Down
7 changes: 7 additions & 0 deletions src/notifications/templates/notification-template.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<p>Hello {{instructorName}},</p><p>We are pleased to inform you that your payout of <strong>{{amount}} {{currency}}</strong> has been successfully processed via {{payoutMethod}}.</p><p>Details: {{payoutDetails}}</p><p>Thank you for teaching on TeachLink!</p>',
},
];

for (const def of defaults) {
Expand Down
40 changes: 40 additions & 0 deletions src/payments/entities/payout-profile.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
64 changes: 64 additions & 0 deletions src/payments/entities/payout.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 52 additions & 0 deletions src/payments/payouts/dto/payout.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
111 changes: 111 additions & 0 deletions src/payments/payouts/payouts.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(PayoutsController);
service = module.get<PayoutsService>(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);
});
});
});
Loading
Loading