diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index b3d08814..34b8fc85 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -50,8 +50,7 @@ import { MaintenanceModule } from './maintenance/maintenance.module'; import { MaintenanceWindowMiddleware } from './maintenance/middleware/maintenance-window.middleware'; // TODO: Enable Sentry when @sentry/nestjs module is compatible -// import { SentryModule } from '@sentry/nestjs'; -import { AlertModule } from './alert/alert.module'; +// import { SentryModule } from '@nestjs/nestjs'; import { GroupsModule } from './groups/groups.module'; import { TransactionsModule } from './transactions/transactions.module'; import { PushModule } from './push/push.module'; @@ -81,6 +80,7 @@ import { FeesModule } from './fees/fees.module'; import { DeepLinkModule } from './deeplink/deeplink.module'; import { FlutterwaveModule } from './flutterwave/flutterwave.module'; import { FeatureFlagModule } from './feature-flags/feature-flag.module'; +import { PayoutsModule } from './payouts/payouts.module'; import { GeoModule } from './geo/geo.module'; import { GeoBlockMiddleware } from './geo/geo-block.middleware'; @@ -171,7 +171,6 @@ import { GeoBlockMiddleware } from './geo/geo-block.middleware'; SandboxModule, FeatureFlagsModule, MaintenanceModule, - AlertModule, GroupsModule, BankAccountsModule, VirtualAccountModule, @@ -250,6 +249,8 @@ import { GeoBlockMiddleware } from './geo/geo-block.middleware'; FeatureFlagModule, GeoModule, + PayoutsModule, + ], providers: [ diff --git a/backend/src/payouts/ScheduledPayoutService.ts b/backend/src/payouts/ScheduledPayoutService.ts deleted file mode 100644 index 6fe5d684..00000000 --- a/backend/src/payouts/ScheduledPayoutService.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { ScheduledPayout, Frequency, Status } from "../entities/ScheduledPayout"; -import { getRepository } from "typeorm"; -import { TransfersService } from "./TransfersService"; -import { bullQueue } from "../utils/bullmq"; - -export class ScheduledPayoutService { - private repo = getRepository(ScheduledPayout); - - async create(userId: string, dto: any, pin: string) { - // Verify PIN - if (!(await this.verifyPin(userId, pin))) throw new Error("Invalid PIN"); - - const payout = this.repo.create({ - userId, - toUsername: dto.toUsername, - amountUsdc: dto.amountUsdc, - note: dto.note, - frequency: dto.frequency, - dayOfWeek: dto.dayOfWeek ?? null, - dayOfMonth: dto.dayOfMonth ? Math.min(dto.dayOfMonth, 28) : null, - nextRunAt: this.computeNextRun(dto.frequency, dto.dayOfWeek, dto.dayOfMonth), - status: Status.ACTIVE, - }); - - await this.repo.save(payout); - - await bullQueue.add( - "scheduledPayout", - { payoutId: payout.id }, - { jobId: `payout:${payout.id}`, repeat: { cron: this.cronExpression(payout) } } - ); - - return payout; - } - - async execute(payoutId: string) { - const payout = await this.repo.findOneOrFail(payoutId); - - try { - await TransfersService.create(payout.userId, { - toUsername: payout.toUsername, - amountUsdc: payout.amountUsdc, - note: payout.note, - reference: `scheduled:${payout.id}`, - }); - - payout.lastRunAt = new Date(); - payout.totalRuns += 1; - payout.nextRunAt = this.computeNextRun(payout.frequency, payout.dayOfWeek, payout.dayOfMonth); - payout.failureCount = 0; - } catch (err) { - payout.failureCount += 1; - if (payout.failureCount >= 2) { - payout.status = Status.PAUSED; - await this.notifyUser(payout.userId, "Scheduled payout paused after repeated failures."); - } - } - - await this.repo.save(payout); - } - - async pause(payoutId: string, userId: string) { - const payout = await this.repo.findOneOrFail({ where: { id: payoutId, userId } }); - payout.status = Status.PAUSED; - await this.repo.save(payout); - await bullQueue.removeRepeatableByKey(`payout:${payout.id}`); - } - - async resume(payoutId: string, userId: string, pin: string) { - if (!(await this.verifyPin(userId, pin))) throw new Error("Invalid PIN"); - const payout = await this.repo.findOneOrFail({ where: { id: payoutId, userId } }); - payout.status = Status.ACTIVE; - payout.nextRunAt = this.computeNextRun(payout.frequency, payout.dayOfWeek, payout.dayOfMonth); - await this.repo.save(payout); - - await bullQueue.add( - "scheduledPayout", - { payoutId: payout.id }, - { jobId: `payout:${payout.id}`, repeat: { cron: this.cronExpression(payout) } } - ); - } - - async cancel(payoutId: string, userId: string) { - const payout = await this.repo.findOneOrFail({ where: { id: payoutId, userId } }); - payout.status = Status.CANCELLED; - await this.repo.save(payout); - await bullQueue.removeRepeatableByKey(`payout:${payout.id}`); - } - - private computeNextRun(freq: Frequency, dayOfWeek?: number, dayOfMonth?: number): Date { - const now = new Date(); - if (freq === Frequency.WEEKLY && dayOfWeek !== undefined) { - const next = new Date(now); - next.setDate(now.getDate() + ((dayOfWeek + 7 - now.getDay()) % 7)); - return next; - } - if (freq === Frequency.MONTHLY && dayOfMonth !== undefined) { - const next = new Date(now.getFullYear(), now.getMonth(), Math.min(dayOfMonth, 28)); - if (next <= now) next.setMonth(next.getMonth() + 1); - return next; - } - return now; - } - - private cronExpression(payout: ScheduledPayout): string { - if (payout.frequency === Frequency.WEEKLY && payout.dayOfWeek !== null) { - return `0 9 * * ${payout.dayOfWeek}`; // every week at 9am - } - if (payout.frequency === Frequency.MONTHLY && payout.dayOfMonth !== null) { - return `0 9 ${payout.dayOfMonth} * *`; // monthly at 9am - } - return "0 9 * * *"; - } - - private async verifyPin(userId: string, pin: string): Promise { - // Implement PIN verification logic - return true; - } - - private async notifyUser(userId: string, message: string) { - // Implement notification logic - console.log(`Notify ${userId}: ${message}`); - } -} diff --git a/backend/src/payouts/dto/create-payout.dto.ts b/backend/src/payouts/dto/create-payout.dto.ts new file mode 100644 index 00000000..67ac0cb1 --- /dev/null +++ b/backend/src/payouts/dto/create-payout.dto.ts @@ -0,0 +1,43 @@ +import { + IsEnum, + IsNumber, + IsOptional, + IsString, + Min, + Max, + IsNotEmpty, +} from 'class-validator'; +import { Frequency } from '../entities/scheduled-payout.entity'; + +export class CreatePayoutDto { + @IsString() + @IsNotEmpty() + toUsername!: string; + + @IsString() + @IsNotEmpty() + amount!: string; + + @IsOptional() + @IsString() + note?: string; + + @IsEnum(Frequency) + frequency!: Frequency; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(6) + dayOfWeek?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(28) + dayOfMonth?: number; + + @IsString() + @IsNotEmpty() + pin!: string; +} diff --git a/backend/src/payouts/entities/ScheduledPayout.ts b/backend/src/payouts/entities/scheduled-payout.entity.ts similarity index 57% rename from backend/src/payouts/entities/ScheduledPayout.ts rename to backend/src/payouts/entities/scheduled-payout.entity.ts index 16b7716d..7db403b3 100644 --- a/backend/src/payouts/entities/ScheduledPayout.ts +++ b/backend/src/payouts/entities/scheduled-payout.entity.ts @@ -1,4 +1,10 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from "typeorm"; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; export enum Frequency { WEEKLY = "weekly", @@ -11,47 +17,50 @@ export enum Status { CANCELLED = "cancelled", } -@Entity() +@Entity("scheduled_payouts") export class ScheduledPayout { @PrimaryGeneratedColumn("uuid") - id: string; + id!: string; @Column() - userId: string; + userId!: string; @Column() - toUsername: string; + toUsername!: string; @Column("varchar") - amountUsdc: string; + amount!: string; @Column("varchar", { nullable: true }) - note: string | null; + note!: string | null; @Column({ type: "enum", enum: Frequency }) - frequency: Frequency; + frequency!: Frequency; @Column({ type: "int", nullable: true }) - dayOfWeek: number | null; // 0–6 + dayOfWeek!: number | null; // 0–6 @Column({ type: "int", nullable: true }) - dayOfMonth: number | null; // 1–28 + dayOfMonth!: number | null; // 1–28 @Column({ type: "timestamp" }) - nextRunAt: Date; + nextRunAt!: Date; @Column({ type: "timestamp", nullable: true }) - lastRunAt: Date | null; + lastRunAt!: Date | null; @Column({ type: "enum", enum: Status, default: Status.ACTIVE }) - status: Status; + status!: Status; @Column({ type: "int", default: 0 }) - totalRuns: number; + totalRuns!: number; @Column({ type: "int", default: 0 }) - failureCount: number; + failureCount!: number; @CreateDateColumn() - createdAt: Date; + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; } diff --git a/backend/src/payouts/payouts.controller.ts b/backend/src/payouts/payouts.controller.ts new file mode 100644 index 00000000..8496578a --- /dev/null +++ b/backend/src/payouts/payouts.controller.ts @@ -0,0 +1,66 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + Req, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { PayoutsService } from './payouts.service'; +import { CreatePayoutDto } from './dto/create-payout.dto'; +import { ScheduledPayout } from './entities/scheduled-payout.entity'; + +@ApiTags('payouts') +@ApiBearerAuth() +@Controller({ path: 'payouts', version: '1' }) +export class PayoutsController { + constructor(private readonly payoutsService: PayoutsService) {} + + @Post() + @ApiOperation({ summary: 'Create a new scheduled payout' }) + @ApiResponse({ status: 201, description: 'Scheduled payout created successfully.' }) + async create( + @Req() req: any, + @Body() dto: CreatePayoutDto, + ): Promise { + return this.payoutsService.create(req.user.id, dto); + } + + @Get() + @ApiOperation({ summary: 'List all scheduled payouts for the current user' }) + async findAll(@Req() req: any): Promise { + return this.payoutsService.findAll(req.user.id); + } + + @Post(':id/pause') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Pause an active scheduled payout' }) + async pause( + @Req() req: any, + @Param('id') id: string, + ): Promise { + return this.payoutsService.pause(req.user.id, id); + } + + @Post(':id/resume') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Resume a paused scheduled payout' }) + async resume( + @Req() req: any, + @Param('id') id: string, + @Body('pin') pin: string, + ): Promise { + return this.payoutsService.resume(req.user.id, id, pin); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Cancel/Delete a scheduled payout' }) + async cancel(@Req() req: any, @Param('id') id: string): Promise { + return this.payoutsService.cancel(req.user.id, id); + } +} diff --git a/backend/src/payouts/payouts.module.ts b/backend/src/payouts/payouts.module.ts new file mode 100644 index 00000000..07bceb51 --- /dev/null +++ b/backend/src/payouts/payouts.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BullModule } from '@nestjs/bull'; +import { ScheduledPayout } from './entities/scheduled-payout.entity'; +import { PayoutsService } from './payouts.service'; +import { PayoutsController } from './payouts.controller'; +import { PayoutsProcessor } from './payouts.processor'; +import { TransfersModule } from '../transfers/transfers.module'; +import { UsersModule } from '../users/users.module'; +import { PinModule } from '../pin/pin.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ScheduledPayout]), + BullModule.registerQueue({ + name: 'payouts', + }), + TransfersModule, + UsersModule, + PinModule, + ], + providers: [PayoutsService, PayoutsProcessor], + controllers: [PayoutsController], + exports: [PayoutsService], +}) +export class PayoutsModule {} diff --git a/backend/src/payouts/payouts.processor.ts b/backend/src/payouts/payouts.processor.ts new file mode 100644 index 00000000..5deae1b1 --- /dev/null +++ b/backend/src/payouts/payouts.processor.ts @@ -0,0 +1,88 @@ +import { Process, Processor } from '@nestjs/bull'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bull'; +import type { Job as BullJob } from 'bull'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ScheduledPayout, Status, Frequency } from './entities/scheduled-payout.entity'; +import { TransfersService } from '../transfers/transfers.service'; +import { UsersService } from '../users/users.service'; + +@Processor('payouts') +export class PayoutsProcessor { + private readonly logger = new Logger(PayoutsProcessor.name); + + constructor( + @InjectRepository(ScheduledPayout) + private readonly repo: Repository, + private readonly transfersService: TransfersService, + private readonly usersService: UsersService, + ) {} + + @Process('scheduledPayout') + async handleScheduledPayout(job: BullJob<{ payoutId: string }>) { + const { payoutId } = job.data; + const payout = await this.repo.findOne({ where: { id: payoutId } }); + + if (!payout || payout.status !== Status.ACTIVE) { + this.logger.warn(`Job ${job.id} skipped - payout ${payoutId} not active or found`); + return; + } + + this.logger.log(`Executing scheduled payout ${payoutId} for user ${payout.userId}`); + + try { + const fromUser = await this.usersService.findById(payout.userId); + if (!fromUser) { + throw new Error(`User ${payout.userId} not found`); + } + + await this.transfersService.create(payout.userId, fromUser.username, { + toUsername: payout.toUsername, + amount: payout.amount, + note: payout.note ?? `Scheduled: ${payoutId}`, + }); + + payout.lastRunAt = new Date(); + payout.totalRuns += 1; + payout.nextRunAt = this.computeNextRun( + payout.frequency, + payout.dayOfWeek ?? undefined, + payout.dayOfMonth ?? undefined, + ); + payout.failureCount = 0; + } catch (err: any) { + this.logger.error(`Failed to execute payout ${payoutId}: ${err.message}`); + payout.failureCount += 1; + if (payout.failureCount >= 2) { + payout.status = Status.PAUSED; + } + } + + await this.repo.save(payout); + } + + private computeNextRun( + freq: Frequency, + dayOfWeek?: number, + dayOfMonth?: number, + ): Date { + const now = new Date(); + // Re-calculating correctly for next run + const next = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9, 0, 0, 0); + + if (freq === Frequency.WEEKLY && dayOfWeek !== undefined) { + next.setDate(now.getDate() + ((dayOfWeek + 7 - now.getDay()) % 7)); + if (next <= now) next.setDate(next.getDate() + 7); + return next; + } + + if (freq === Frequency.MONTHLY && dayOfMonth !== undefined) { + next.setDate(Math.min(dayOfMonth, 28)); + if (next <= now) next.setMonth(next.getMonth() + 1); + return next; + } + + return now; + } +} diff --git a/backend/src/payouts/payouts.service.ts b/backend/src/payouts/payouts.service.ts new file mode 100644 index 00000000..e9d023ca --- /dev/null +++ b/backend/src/payouts/payouts.service.ts @@ -0,0 +1,178 @@ +gimport { + Injectable, + Logger, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { InjectQueue } from '@nestjs/bull'; +import type { Queue } from 'bull'; +import { + ScheduledPayout, + Frequency, + Status, +} from './entities/scheduled-payout.entity'; +import { CreatePayoutDto } from './dto/create-payout.dto'; +import { PinService } from '../pin/pin.service'; + +@Injectable() +export class PayoutsService { + private readonly logger = new Logger(PayoutsService.name); + + constructor( + @InjectRepository(ScheduledPayout) + private readonly repo: Repository, + @InjectQueue('payouts') + private readonly queue: Queue, + private readonly pinService: PinService, + ) {} + + async create(userId: string, dto: CreatePayoutDto): Promise { + await this.pinService.verifyPin(userId, dto.pin); + + if (dto.frequency === Frequency.WEEKLY && dto.dayOfWeek === undefined) { + throw new BadRequestException('dayOfWeek is required for weekly frequency'); + } + if (dto.frequency === Frequency.MONTHLY && dto.dayOfMonth === undefined) { + throw new BadRequestException('dayOfMonth is required for monthly frequency'); + } + + const nextRunAt = this.computeNextRun( + dto.frequency, + dto.dayOfWeek, + dto.dayOfMonth, + ); + + const payout = this.repo.create({ + userId, + toUsername: dto.toUsername, + amount: dto.amount, + note: dto.note, + frequency: dto.frequency, + dayOfWeek: dto.dayOfWeek, + dayOfMonth: dto.dayOfMonth, + nextRunAt, + status: Status.ACTIVE, + }); + + await this.repo.save(payout); + + await this.queue.add( + 'scheduledPayout', + { payoutId: payout.id }, + { + jobId: `payout:${payout.id}`, + repeat: { cron: this.cronExpression(payout) }, + removeOnComplete: true, + }, + ); + + return payout; + } + + async findAll(userId: string): Promise { + return this.repo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } + + async pause(userId: string, id: string): Promise { + const payout = await this.repo.findOne({ where: { id, userId } }); + if (!payout) throw new NotFoundException('Scheduled payout not found'); + + payout.status = Status.PAUSED; + await this.repo.save(payout); + + // Bull repeatable jobs are removed by their repeat options + const jobs = await this.queue.getRepeatableJobs(); + const job = jobs.find((j) => j.id === `payout:${payout.id}`); + if (job) { + await this.queue.removeRepeatableByKey(job.key); + } + + return payout; + } + + async resume( + userId: string, + id: string, + pin: string, + ): Promise { + await this.pinService.verifyPin(userId, pin); + + const payout = await this.repo.findOne({ where: { id, userId } }); + if (!payout) throw new NotFoundException('Scheduled payout not found'); + + if (payout.status === Status.ACTIVE) return payout; + + payout.status = Status.ACTIVE; + payout.nextRunAt = this.computeNextRun( + payout.frequency, + payout.dayOfWeek ?? undefined, + payout.dayOfMonth ?? undefined, + ); + await this.repo.save(payout); + + await this.queue.add( + 'scheduledPayout', + { payoutId: payout.id }, + { + jobId: `payout:${payout.id}`, + repeat: { cron: this.cronExpression(payout) }, + removeOnComplete: true, + }, + ); + + return payout; + } + + async cancel(userId: string, id: string): Promise { + const payout = await this.repo.findOne({ where: { id, userId } }); + if (!payout) throw new NotFoundException('Scheduled payout not found'); + + payout.status = Status.CANCELLED; + await this.repo.save(payout); + + const jobs = await this.queue.getRepeatableJobs(); + const job = jobs.find((j) => j.id === `payout:${payout.id}`); + if (job) { + await this.queue.removeRepeatableByKey(job.key); + } + } + + private computeNextRun( + freq: Frequency, + dayOfWeek?: number, + dayOfMonth?: number, + ): Date { + const now = new Date(); + // Round to next hour/day for predictable UI + const next = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9, 0, 0, 0); + + if (freq === Frequency.WEEKLY && dayOfWeek !== undefined) { + next.setDate(now.getDate() + ((dayOfWeek + 7 - now.getDay()) % 7)); + if (next <= now) next.setDate(next.getDate() + 7); + return next; + } + + if (freq === Frequency.MONTHLY && dayOfMonth !== undefined) { + next.setDate(Math.min(dayOfMonth, 28)); + if (next <= now) next.setMonth(next.getMonth() + 1); + return next; + } + + return now; + } + + private cronExpression(payout: ScheduledPayout): string { + if (payout.frequency === Frequency.WEEKLY && payout.dayOfWeek !== null) { + return `0 9 * * ${payout.dayOfWeek}`; // every week at 9am + } + if (payout.frequency === Frequency.MONTHLY && payout.dayOfMonth !== null) { + return `0 9 ${payout.dayOfMonth} * *`; // monthly at 9am + } + return '0 9 * * *'; + } +} diff --git a/backend/src/payouts/scheduledPayout.controller.ts b/backend/src/payouts/scheduledPayout.controller.ts deleted file mode 100644 index e69de29b..00000000