diff --git a/backend/src/migrations/1800410000000-CreateGoalTransferTables.ts b/backend/src/migrations/1800410000000-CreateGoalTransferTables.ts new file mode 100644 index 000000000..8156cd8a1 --- /dev/null +++ b/backend/src/migrations/1800410000000-CreateGoalTransferTables.ts @@ -0,0 +1,131 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; + +export class CreateGoalTransferTables1800410000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'goal_transfer_schedules', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'userId', type: 'uuid', isNullable: false }, + { name: 'goalId', type: 'uuid', isNullable: false }, + { name: 'productId', type: 'uuid', isNullable: true }, + { + name: 'amount', + type: 'decimal', + precision: 14, + scale: 7, + isNullable: false, + }, + { + name: 'frequency', + type: 'enum', + enum: ['DAILY', 'WEEKLY', 'BI_WEEKLY', 'MONTHLY'], + isNullable: false, + }, + { + name: 'status', + type: 'enum', + enum: ['ACTIVE', 'PAUSED', 'CANCELLED'], + default: "'ACTIVE'", + isNullable: false, + }, + { name: 'nextRunAt', type: 'timestamptz', isNullable: false }, + { name: 'retryCount', type: 'int', default: 0 }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'goal_transfer_schedules', + new TableForeignKey({ + columnNames: ['userId'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'goal_transfer_schedules', + new TableForeignKey({ + columnNames: ['goalId'], + referencedTableName: 'savings_goals', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createIndex( + 'goal_transfer_schedules', + new TableIndex({ + name: 'IDX_GOAL_TRANSFER_USER_ID', + columnNames: ['userId'], + }), + ); + + await queryRunner.createTable( + new Table({ + name: 'goal_transfer_executions', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'scheduleId', type: 'uuid', isNullable: false }, + { name: 'userId', type: 'uuid', isNullable: false }, + { name: 'goalId', type: 'uuid', isNullable: false }, + { + name: 'amount', + type: 'decimal', + precision: 14, + scale: 7, + isNullable: false, + }, + { + name: 'status', + type: 'enum', + enum: ['SUCCESS', 'FAILED'], + isNullable: false, + }, + { name: 'errorMessage', type: 'text', isNullable: true }, + { name: 'executedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'goal_transfer_executions', + new TableForeignKey({ + columnNames: ['scheduleId'], + referencedTableName: 'goal_transfer_schedules', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('goal_transfer_executions'); + await queryRunner.dropTable('goal_transfer_schedules'); + } +} diff --git a/backend/src/modules/savings/dto/goal-transfer.dto.ts b/backend/src/modules/savings/dto/goal-transfer.dto.ts new file mode 100644 index 000000000..c1b2a7dbf --- /dev/null +++ b/backend/src/modules/savings/dto/goal-transfer.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { + GoalTransferFrequency, + GoalTransferStatus, +} from '../entities/goal-transfer-schedule.entity'; +import { IsPositiveAmount } from '../../../common/validators/is-positive-amount.validator'; + +export class CreateGoalTransferScheduleDto { + @ApiProperty({ example: 'uuid-goal-id' }) + @IsUUID() + goalId: string; + + @ApiPropertyOptional({ example: 'uuid-product-id' }) + @IsOptional() + @IsUUID() + productId?: string; + + @ApiProperty({ example: 50, minimum: 0.01 }) + @IsNumber() + @IsPositiveAmount() + @Min(0.01) + amount: number; + + @ApiProperty({ enum: GoalTransferFrequency }) + @IsEnum(GoalTransferFrequency) + frequency: GoalTransferFrequency; +} + +export class GoalTransferScheduleResponseDto { + @ApiProperty() id: string; + @ApiProperty() userId: string; + @ApiProperty() goalId: string; + @ApiPropertyOptional() productId: string | null; + @ApiProperty() amount: number; + @ApiProperty({ enum: GoalTransferFrequency }) + frequency: GoalTransferFrequency; + @ApiProperty({ enum: GoalTransferStatus }) status: GoalTransferStatus; + @ApiProperty() nextRunAt: Date; + @ApiProperty() createdAt: Date; + @ApiProperty() updatedAt: Date; +} diff --git a/backend/src/modules/savings/entities/goal-transfer-schedule.entity.ts b/backend/src/modules/savings/entities/goal-transfer-schedule.entity.ts new file mode 100644 index 000000000..7feae070d --- /dev/null +++ b/backend/src/modules/savings/entities/goal-transfer-schedule.entity.ts @@ -0,0 +1,113 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity'; +import { SavingsGoal } from './savings-goal.entity'; +import { SavingsProduct } from './savings-product.entity'; + +export enum GoalTransferFrequency { + DAILY = 'DAILY', + WEEKLY = 'WEEKLY', + BI_WEEKLY = 'BI_WEEKLY', + MONTHLY = 'MONTHLY', +} + +export enum GoalTransferStatus { + ACTIVE = 'ACTIVE', + PAUSED = 'PAUSED', + CANCELLED = 'CANCELLED', +} + +@Entity('goal_transfer_schedules') +export class GoalTransferSchedule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + userId: string; + + @Column('uuid') + goalId: string; + + @Column('uuid', { nullable: true }) + productId: string | null; + + @Column('decimal', { precision: 14, scale: 7 }) + amount: number; + + @Column({ type: 'enum', enum: GoalTransferFrequency }) + frequency: GoalTransferFrequency; + + @Column({ + type: 'enum', + enum: GoalTransferStatus, + default: GoalTransferStatus.ACTIVE, + }) + status: GoalTransferStatus; + + @Column({ type: 'timestamptz' }) + nextRunAt: Date; + + @Column({ type: 'int', default: 0 }) + retryCount: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @ManyToOne(() => SavingsGoal, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'goalId' }) + goal: SavingsGoal; + + @ManyToOne(() => SavingsProduct, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'productId' }) + product: SavingsProduct | null; +} + +export enum GoalTransferExecutionStatus { + SUCCESS = 'SUCCESS', + FAILED = 'FAILED', +} + +@Entity('goal_transfer_executions') +export class GoalTransferExecution { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + scheduleId: string; + + @Column('uuid') + userId: string; + + @Column('uuid') + goalId: string; + + @Column('decimal', { precision: 14, scale: 7 }) + amount: number; + + @Column({ type: 'enum', enum: GoalTransferExecutionStatus }) + status: GoalTransferExecutionStatus; + + @Column({ type: 'text', nullable: true }) + errorMessage: string | null; + + @CreateDateColumn() + executedAt: Date; + + @ManyToOne(() => GoalTransferSchedule, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'scheduleId' }) + schedule: GoalTransferSchedule; +} diff --git a/backend/src/modules/savings/savings.controller.ts b/backend/src/modules/savings/savings.controller.ts index cbc7b80ca..c1d43845f 100644 --- a/backend/src/modules/savings/savings.controller.ts +++ b/backend/src/modules/savings/savings.controller.ts @@ -60,6 +60,15 @@ import { AutoDepositResponseDto, } from './dto/auto-deposit.dto'; import { AutoDepositSchedule } from './entities/auto-deposit-schedule.entity'; +import { GoalTransferService } from './services/goal-transfer.service'; +import { + CreateGoalTransferScheduleDto, + GoalTransferScheduleResponseDto, +} from './dto/goal-transfer.dto'; +import { + GoalTransferSchedule, + GoalTransferExecution, +} from './entities/goal-transfer-schedule.entity'; import { SavingsGoalProgress, UserSubscriptionWithLiveBalance, @@ -73,6 +82,7 @@ export class SavingsController { private readonly milestoneService: MilestoneService, private readonly recommendationService: RecommendationService, private readonly autoDepositService: AutoDepositService, + private readonly goalTransferService: GoalTransferService, ) {} @Get('products') @@ -530,4 +540,82 @@ export class SavingsController { ): Promise { return this.autoDepositService.cancel(id, user.id); } + + // ── Goal Auto-Transfer (#930) ────────────────────────────────────────────── + + @Post('goal-transfer/create') + @UseGuards(JwtAuthGuard) + @UseInterceptors(IdempotencyInterceptor) + @HttpCode(HttpStatus.CREATED) + @ApiBearerAuth() + @ApiOperation({ summary: 'Create a recurring goal auto-transfer schedule' }) + @ApiBody({ type: CreateGoalTransferScheduleDto }) + @ApiResponse({ status: 201, type: GoalTransferScheduleResponseDto }) + async createGoalTransfer( + @Body() dto: CreateGoalTransferScheduleDto, + @CurrentUser() user: { id: string }, + ): Promise { + return this.goalTransferService.create(user.id, dto); + } + + @Get('goal-transfer') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'List goal auto-transfer schedules for current user', + }) + @ApiResponse({ status: 200, type: [GoalTransferScheduleResponseDto] }) + async getGoalTransfers( + @CurrentUser() user: { id: string }, + ): Promise { + return this.goalTransferService.findAllForUser(user.id); + } + + @Patch('goal-transfer/:id/pause') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Pause a goal auto-transfer schedule' }) + async pauseGoalTransfer( + @Param('id') id: string, + @CurrentUser() user: { id: string }, + ): Promise { + return this.goalTransferService.pause(id, user.id); + } + + @Patch('goal-transfer/:id/resume') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Resume a paused goal auto-transfer schedule' }) + async resumeGoalTransfer( + @Param('id') id: string, + @CurrentUser() user: { id: string }, + ): Promise { + return this.goalTransferService.resume(id, user.id); + } + + @Delete('goal-transfer/:id') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiBearerAuth() + @ApiOperation({ summary: 'Cancel a goal auto-transfer schedule' }) + async cancelGoalTransfer( + @Param('id') id: string, + @CurrentUser() user: { id: string }, + ): Promise { + return this.goalTransferService.cancel(id, user.id); + } + + @Get('goal-transfer/:id/executions') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get execution history for a goal transfer schedule', + }) + @ApiResponse({ status: 200, type: [GoalTransferExecution] }) + async getGoalTransferExecutions( + @Param('id') id: string, + @CurrentUser() user: { id: string }, + ): Promise { + return this.goalTransferService.getExecutions(id, user.id); + } } diff --git a/backend/src/modules/savings/savings.module.ts b/backend/src/modules/savings/savings.module.ts index 2313b7d3d..4213bf12d 100644 --- a/backend/src/modules/savings/savings.module.ts +++ b/backend/src/modules/savings/savings.module.ts @@ -28,14 +28,21 @@ import { GroupSavingsService } from './group-savings.service'; import { GroupSavingsController } from './group-savings.controller'; import { AutoDepositSchedule } from './entities/auto-deposit-schedule.entity'; import { AutoDepositService } from './services/auto-deposit.service'; +import { + GoalTransferSchedule, + GoalTransferExecution, +} from './entities/goal-transfer-schedule.entity'; +import { GoalTransferService } from './services/goal-transfer.service'; import { SavingsGoalShare } from './entities/savings-goal-share.entity'; import { SavingsGoalShareEvent } from './entities/savings-goal-share-event.entity'; import { SavingsGoalSharingService } from './savings-goal-sharing.service'; import { SavingsGoalSharingController } from './savings-goal-sharing.controller'; +import { MailModule } from '../mail/mail.module'; @Module({ imports: [ ScheduleModule.forRoot(), + MailModule, TypeOrmModule.forFeature([ SavingsProduct, UserSubscription, @@ -53,6 +60,8 @@ import { SavingsGoalSharingController } from './savings-goal-sharing.controller' SavingsGroupMember, SavingsGroupActivity, AutoDepositSchedule, + GoalTransferSchedule, + GoalTransferExecution, SavingsGoalShare, SavingsGoalShareEvent, ]), @@ -72,6 +81,7 @@ import { SavingsGoalSharingController } from './savings-goal-sharing.controller' ExperimentsService, GroupSavingsService, AutoDepositService, + GoalTransferService, SavingsGoalSharingService, ], exports: [ diff --git a/backend/src/modules/savings/savings.service.ts b/backend/src/modules/savings/savings.service.ts index 13b719b66..ffa488010 100644 --- a/backend/src/modules/savings/savings.service.ts +++ b/backend/src/modules/savings/savings.service.ts @@ -1012,6 +1012,49 @@ export class SavingsService { await this.goalRepository.remove(goal); } + async transferToGoal( + userId: string, + goalId: string, + amount: number, + productId?: string, + ): Promise { + const goal = await this.goalRepository.findOne({ + where: { id: goalId, userId }, + }); + if (!goal) { + throw new NotFoundException( + `Savings goal ${goalId} not found or does not belong to user`, + ); + } + if (goal.status !== SavingsGoalStatus.IN_PROGRESS) { + throw new BadRequestException('Cannot transfer to a completed goal'); + } + + let resolvedProductId = productId; + if (!resolvedProductId) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + resolvedProductId = user?.defaultSavingsProductId ?? undefined; + } + if (!resolvedProductId) { + throw new BadRequestException( + 'No savings product specified and user has no default product', + ); + } + + await this.subscribe(userId, resolvedProductId, amount, true); + + const tx = this.transactionRepository.create({ + userId, + type: TxType.DEPOSIT, + amount: String(amount), + status: TxStatus.COMPLETED, + poolId: resolvedProductId, + metadata: { goalId, goalName: goal.goalName, transferType: 'GOAL_AUTO' }, + txHash: `goal-transfer-${goalId}-${Date.now()}`, + }); + return this.transactionRepository.save(tx); + } + async createWithdrawalRequest( userId: string, subscriptionId: string, diff --git a/backend/src/modules/savings/services/goal-transfer.service.spec.ts b/backend/src/modules/savings/services/goal-transfer.service.spec.ts new file mode 100644 index 000000000..ae7b13d95 --- /dev/null +++ b/backend/src/modules/savings/services/goal-transfer.service.spec.ts @@ -0,0 +1,131 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { GoalTransferService } from './goal-transfer.service'; +import { + GoalTransferSchedule, + GoalTransferExecution, + GoalTransferFrequency, + GoalTransferStatus, +} from '../entities/goal-transfer-schedule.entity'; +import { + SavingsGoal, + SavingsGoalStatus, +} from '../entities/savings-goal.entity'; +import { User } from '../../user/entities/user.entity'; +import { SavingsService } from '../savings.service'; +import { MailService } from '../../mail/mail.service'; + +const mockRepo = () => ({ + create: jest.fn((v) => v), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(), +}); + +describe('GoalTransferService', () => { + let service: GoalTransferService; + let scheduleRepo: ReturnType; + let goalRepo: ReturnType; + let userRepo: ReturnType; + let savingsService: { transferToGoal: jest.Mock; findOneProduct: jest.Mock }; + + beforeEach(async () => { + scheduleRepo = mockRepo(); + goalRepo = mockRepo(); + userRepo = mockRepo(); + savingsService = { + transferToGoal: jest.fn(), + findOneProduct: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GoalTransferService, + { + provide: getRepositoryToken(GoalTransferSchedule), + useValue: scheduleRepo, + }, + { + provide: getRepositoryToken(GoalTransferExecution), + useValue: mockRepo(), + }, + { provide: getRepositoryToken(SavingsGoal), useValue: goalRepo }, + { provide: getRepositoryToken(User), useValue: userRepo }, + { provide: SavingsService, useValue: savingsService }, + { + provide: MailService, + useValue: { sendSavingsAlertEmail: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(GoalTransferService); + }); + + describe('create', () => { + it('creates schedule when validation passes', async () => { + const dto = { + goalId: 'goal-1', + amount: 25, + frequency: GoalTransferFrequency.WEEKLY, + }; + goalRepo.findOne.mockResolvedValue({ + id: 'goal-1', + status: SavingsGoalStatus.IN_PROGRESS, + }); + scheduleRepo.findOne.mockResolvedValue(null); + userRepo.findOne.mockResolvedValue({ defaultSavingsProductId: 'prod-1' }); + scheduleRepo.save.mockResolvedValue({ id: 'sched-1', ...dto }); + + const result = await service.create('user-1', dto); + expect(result.id).toBe('sched-1'); + }); + + it('rejects duplicate active schedule', async () => { + goalRepo.findOne.mockResolvedValue({ + id: 'goal-1', + status: SavingsGoalStatus.IN_PROGRESS, + }); + scheduleRepo.findOne.mockResolvedValue({ id: 'existing' }); + + await expect( + service.create('user-1', { + goalId: 'goal-1', + amount: 10, + frequency: GoalTransferFrequency.DAILY, + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('pause', () => { + it('pauses active schedule', async () => { + scheduleRepo.findOne.mockResolvedValue({ + id: 's1', + userId: 'u1', + status: GoalTransferStatus.ACTIVE, + }); + scheduleRepo.save.mockImplementation((s) => Promise.resolve(s)); + + const result = await service.pause('s1', 'u1'); + expect(result.status).toBe(GoalTransferStatus.PAUSED); + }); + + it('throws when not found', async () => { + scheduleRepo.findOne.mockResolvedValue(null); + await expect(service.pause('bad', 'u1')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('computeNextRun', () => { + it('adds 7 days for WEEKLY', () => { + const from = new Date('2026-01-01T00:00:00Z'); + const next = service.computeNextRun(GoalTransferFrequency.WEEKLY, from); + expect(next.getDate()).toBe(8); + }); + }); +}); diff --git a/backend/src/modules/savings/services/goal-transfer.service.ts b/backend/src/modules/savings/services/goal-transfer.service.ts new file mode 100644 index 000000000..2ed5ca40d --- /dev/null +++ b/backend/src/modules/savings/services/goal-transfer.service.ts @@ -0,0 +1,273 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { + GoalTransferExecution, + GoalTransferExecutionStatus, + GoalTransferSchedule, + GoalTransferFrequency, + GoalTransferStatus, +} from '../entities/goal-transfer-schedule.entity'; +import { CreateGoalTransferScheduleDto } from '../dto/goal-transfer.dto'; +import { SavingsService } from '../savings.service'; +import { + SavingsGoal, + SavingsGoalStatus, +} from '../entities/savings-goal.entity'; +import { User } from '../../user/entities/user.entity'; +import { MailService } from '../../mail/mail.service'; +import { ShutdownTrackedTask } from '../../../common/decorators/shutdown-task.decorator'; + +const MAX_RETRIES = 5; +const MIN_TRANSFER_AMOUNT = 0.01; + +@Injectable() +export class GoalTransferService { + private readonly logger = new Logger(GoalTransferService.name); + + constructor( + @InjectRepository(GoalTransferSchedule) + private readonly scheduleRepo: Repository, + @InjectRepository(GoalTransferExecution) + private readonly executionRepo: Repository, + @InjectRepository(SavingsGoal) + private readonly goalRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + private readonly savingsService: SavingsService, + private readonly mailService: MailService, + ) {} + + async create( + userId: string, + dto: CreateGoalTransferScheduleDto, + ): Promise { + await this.validateSchedule(userId, dto); + + const nextRunAt = this.computeNextRun(dto.frequency); + const schedule = this.scheduleRepo.create({ + userId, + goalId: dto.goalId, + productId: dto.productId ?? null, + amount: dto.amount, + frequency: dto.frequency, + status: GoalTransferStatus.ACTIVE, + nextRunAt, + }); + return this.scheduleRepo.save(schedule); + } + + async findAllForUser(userId: string): Promise { + return this.scheduleRepo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } + + async pause(id: string, userId: string): Promise { + const schedule = await this.findOwned(id, userId); + if (schedule.status === GoalTransferStatus.CANCELLED) { + throw new BadRequestException('Cannot pause a cancelled schedule'); + } + schedule.status = GoalTransferStatus.PAUSED; + return this.scheduleRepo.save(schedule); + } + + async resume(id: string, userId: string): Promise { + const schedule = await this.findOwned(id, userId); + if (schedule.status === GoalTransferStatus.CANCELLED) { + throw new BadRequestException('Cannot resume a cancelled schedule'); + } + schedule.status = GoalTransferStatus.ACTIVE; + if (schedule.nextRunAt <= new Date()) { + schedule.nextRunAt = this.computeNextRun(schedule.frequency); + } + return this.scheduleRepo.save(schedule); + } + + async cancel(id: string, userId: string): Promise { + const schedule = await this.findOwned(id, userId); + schedule.status = GoalTransferStatus.CANCELLED; + await this.scheduleRepo.save(schedule); + } + + async getExecutions( + scheduleId: string, + userId: string, + ): Promise { + await this.findOwned(scheduleId, userId); + return this.executionRepo.find({ + where: { scheduleId }, + order: { executedAt: 'DESC' }, + }); + } + + @ShutdownTrackedTask() + @Cron(CronExpression.EVERY_MINUTE) + async processDueSchedules(): Promise { + const now = new Date(); + const due = await this.scheduleRepo + .createQueryBuilder('s') + .where('s.status = :status', { status: GoalTransferStatus.ACTIVE }) + .andWhere('s.nextRunAt <= :now', { now }) + .getMany(); + + for (const schedule of due) { + await this.executeSchedule(schedule); + } + } + + private async executeSchedule(schedule: GoalTransferSchedule): Promise { + try { + await this.savingsService.transferToGoal( + schedule.userId, + schedule.goalId, + Number(schedule.amount), + schedule.productId ?? undefined, + ); + + await this.recordExecution(schedule, GoalTransferExecutionStatus.SUCCESS); + + schedule.retryCount = 0; + schedule.nextRunAt = this.computeNextRun(schedule.frequency); + await this.scheduleRepo.save(schedule); + + const user = await this.userRepo.findOne({ + where: { id: schedule.userId }, + }); + const goal = await this.goalRepo.findOne({ + where: { id: schedule.goalId }, + }); + if (user?.email) { + await this.mailService.sendSavingsAlertEmail( + user.email, + user.name ?? 'User', + `Auto-transfer of ${schedule.amount} XLM to goal "${goal?.goalName ?? schedule.goalId}" completed successfully.`, + ); + } + + this.logger.log(`Goal transfer executed for schedule ${schedule.id}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await this.recordExecution( + schedule, + GoalTransferExecutionStatus.FAILED, + message, + ); + + schedule.retryCount += 1; + if (schedule.retryCount >= MAX_RETRIES) { + schedule.status = GoalTransferStatus.CANCELLED; + this.logger.error( + `Goal transfer schedule ${schedule.id} cancelled after ${MAX_RETRIES} failures`, + ); + } else { + const backoffMs = Math.pow(2, schedule.retryCount) * 60_000; + schedule.nextRunAt = new Date(Date.now() + backoffMs); + this.logger.warn( + `Goal transfer schedule ${schedule.id} retry ${schedule.retryCount}`, + ); + } + await this.scheduleRepo.save(schedule); + } + } + + private async recordExecution( + schedule: GoalTransferSchedule, + status: GoalTransferExecutionStatus, + errorMessage?: string, + ): Promise { + const execution = this.executionRepo.create({ + scheduleId: schedule.id, + userId: schedule.userId, + goalId: schedule.goalId, + amount: schedule.amount, + status, + errorMessage: errorMessage ?? null, + }); + await this.executionRepo.save(execution); + } + + private async validateSchedule( + userId: string, + dto: CreateGoalTransferScheduleDto, + ): Promise { + if (dto.amount < MIN_TRANSFER_AMOUNT) { + throw new BadRequestException( + `Minimum transfer amount is ${MIN_TRANSFER_AMOUNT}`, + ); + } + + const goal = await this.goalRepo.findOne({ + where: { id: dto.goalId, userId }, + }); + if (!goal) { + throw new NotFoundException('Savings goal not found'); + } + if (goal.status !== SavingsGoalStatus.IN_PROGRESS) { + throw new BadRequestException( + 'Cannot schedule transfers to a completed goal', + ); + } + + const existing = await this.scheduleRepo.findOne({ + where: { + userId, + goalId: dto.goalId, + status: GoalTransferStatus.ACTIVE, + }, + }); + if (existing) { + throw new BadRequestException( + 'An active transfer schedule already exists for this goal', + ); + } + + if (dto.productId) { + await this.savingsService.findOneProduct(dto.productId); + } else { + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user?.defaultSavingsProductId) { + throw new BadRequestException( + 'productId is required when no default savings product is configured', + ); + } + } + } + + private async findOwned( + id: string, + userId: string, + ): Promise { + const schedule = await this.scheduleRepo.findOne({ where: { id, userId } }); + if (!schedule) { + throw new NotFoundException(`Goal transfer schedule ${id} not found`); + } + return schedule; + } + + computeNextRun(frequency: GoalTransferFrequency, from = new Date()): Date { + const next = new Date(from); + switch (frequency) { + case GoalTransferFrequency.DAILY: + next.setDate(next.getDate() + 1); + break; + case GoalTransferFrequency.WEEKLY: + next.setDate(next.getDate() + 7); + break; + case GoalTransferFrequency.BI_WEEKLY: + next.setDate(next.getDate() + 14); + break; + case GoalTransferFrequency.MONTHLY: + next.setMonth(next.getMonth() + 1); + break; + } + return next; + } +} diff --git a/backend/uploads/kyc-encrypted/5dc63999-b651-4260-ba8e-f70f717cd749.enc b/backend/uploads/kyc-encrypted/5dc63999-b651-4260-ba8e-f70f717cd749.enc new file mode 100644 index 000000000..35da5554f --- /dev/null +++ b/backend/uploads/kyc-encrypted/5dc63999-b651-4260-ba8e-f70f717cd749.enc @@ -0,0 +1 @@ +encrypted-data \ No newline at end of file