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
28 changes: 28 additions & 0 deletions backend/src/modules/mail/mail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,34 @@ export class MailService {
}
}

async sendWithdrawalCompletedEmail(
userEmail: string,
name: string,
amount: string,
penalty: string,
netAmount: string,
): Promise<void> {
try {
await this.mailerService.sendMail({
to: userEmail,
subject: 'Withdrawal Request Completed',
template: './withdrawal-completed',
context: {
name: name || 'User',
amount,
penalty,
netAmount,
},
});
this.logger.log(`Withdrawal completed email sent to ${userEmail}`);
} catch (error) {
this.logger.error(
`Failed to send withdrawal completed email to ${userEmail}`,
error,
);
}
}

async sendClaimStatusEmail(
userEmail: string,
name: string,
Expand Down
20 changes: 20 additions & 0 deletions backend/src/modules/mail/templates/withdrawal-completed.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Withdrawal Completed</title>
</head>
<body>
<h1>Withdrawal Completed</h1>
<p>Hi {{name}},</p>
<p>Your withdrawal request has been successfully processed.</p>
<table>
<tr><td><strong>Requested Amount:</strong></td><td>{{amount}}</td></tr>
<tr><td><strong>Penalty:</strong></td><td>{{penalty}}</td></tr>
<tr><td><strong>Net Amount:</strong></td><td>{{netAmount}}</td></tr>
</table>
<p>The funds have been transferred to your account.</p>
<br>
<p>Best regards,<br>The Nestera Team</p>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export enum NotificationType {
WAITLIST_AVAILABLE = 'WAITLIST_AVAILABLE',
GOAL_MILESTONE = 'GOAL_MILESTONE',
GOAL_COMPLETED = 'GOAL_COMPLETED',
WITHDRAWAL_COMPLETED = 'WITHDRAWAL_COMPLETED',
}

@Entity('notifications')
Expand Down
74 changes: 74 additions & 0 deletions backend/src/modules/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ export interface SweepCompletedEvent {
timestamp: Date;
}

export interface WithdrawalCompletedEvent {
userId: string;
withdrawalId: string;
amount: number;
penalty: number;
netAmount: number;
timestamp: Date;
}

export interface ClaimUpdatedEvent {
userId: string;
claimId: string;
Expand Down Expand Up @@ -99,6 +108,71 @@ export class NotificationsService {
}
}

/**
* Listen to withdrawal.completed event and create notifications
*/
@OnEvent('withdrawal.completed')
async handleWithdrawalCompleted(event: WithdrawalCompletedEvent) {
this.logger.log(
`Processing withdrawal.completed event for user ${event.userId}`,
);

try {
const user = await this.userRepository.findOne({
where: { id: event.userId },
});

if (!user) {
this.logger.warn(
`User ${event.userId} not found for withdrawal notification`,
);
return;
}

const preferences = await this.getOrCreatePreferences(event.userId);

const penaltyNote =
event.penalty > 0
? ` An early withdrawal penalty of ${event.penalty} was applied.`
: '';

if (preferences.inAppNotifications) {
await this.createNotification({
userId: event.userId,
type: NotificationType.WITHDRAWAL_COMPLETED,
title: 'Withdrawal Completed',
message: `Your withdrawal of ${event.netAmount} has been completed.${penaltyNote}`,
metadata: {
withdrawalId: event.withdrawalId,
amount: event.amount,
penalty: event.penalty,
netAmount: event.netAmount,
timestamp: event.timestamp,
},
});
}

if (preferences.emailNotifications) {
await this.mailService.sendWithdrawalCompletedEmail(
user.email,
user.name || 'User',
String(event.amount),
String(event.penalty),
String(event.netAmount),
);
}

this.logger.log(
`Withdrawal notification processed for user ${event.userId}`,
);
} catch (error) {
this.logger.error(
`Error processing withdrawal.completed event for user ${event.userId}`,
error,
);
}
}

/**
* Listen to claim.updated event and create notifications
*/
Expand Down
21 changes: 21 additions & 0 deletions backend/src/modules/savings/dto/withdraw.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { IsUUID, IsNumber, Min, IsOptional, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class WithdrawDto {
@ApiProperty({ description: 'Subscription ID to withdraw from' })
@IsUUID()
subscriptionId: string;

@ApiProperty({ example: 1000.5, description: 'Amount to withdraw' })
@IsNumber()
@Min(0.01)
amount: number;

@ApiPropertyOptional({
example: 'emergency',
description: 'Optional reason for withdrawal',
})
@IsOptional()
@IsString()
reason?: string;
}
30 changes: 30 additions & 0 deletions backend/src/modules/savings/dto/withdrawal-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';

export class WithdrawalResponseDto {
@ApiProperty({ description: 'Withdrawal request ID' })
withdrawalId: string;

@ApiProperty({ example: 1000.5, description: 'Requested withdrawal amount' })
amount: number;

@ApiProperty({
example: 50.25,
description: 'Early withdrawal penalty amount',
})
penalty: number;

@ApiProperty({
example: 950.25,
description: 'Net amount after penalty deduction',
})
netAmount: number;

@ApiProperty({ example: 'pending', description: 'Withdrawal request status' })
status: string;

@ApiProperty({
example: '2026-03-29T10:00:00Z',
description: 'Estimated completion time',
})
estimatedCompletionTime: string;
}
67 changes: 67 additions & 0 deletions backend/src/modules/savings/entities/withdrawal-request.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { UserSubscription } from './user-subscription.entity';

export enum WithdrawalStatus {
PENDING = 'PENDING',
PROCESSING = 'PROCESSING',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
}

@Entity('withdrawal_requests')
export class WithdrawalRequest {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column('uuid')
userId: string;

@Column('uuid')
subscriptionId: string;

@Column('decimal', { precision: 18, scale: 7 })
amount: number;

@Column('decimal', { precision: 18, scale: 7, default: 0 })
penalty: number;

@Column('decimal', { precision: 18, scale: 7 })
netAmount: number;

@Column({
type: 'enum',
enum: WithdrawalStatus,
default: WithdrawalStatus.PENDING,
})
status: WithdrawalStatus;

@Column({ type: 'varchar', nullable: true })
reason: string | null;

@Column({ type: 'varchar', nullable: true })
txHash: string | null;

@Column({ type: 'timestamp', nullable: true })
estimatedCompletionTime: Date | null;

@Column({ type: 'timestamp', nullable: true })
completedAt: Date | null;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;

@ManyToOne(() => UserSubscription, { eager: true })
@JoinColumn({ name: 'subscriptionId' })
subscription: UserSubscription;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { SavingsGoal } from './entities/savings-goal.entity';
import { User } from '../user/entities/user.entity';
import { SavingsService as BlockchainSavingsService } from '../blockchain/savings.service';
import { PredictiveEvaluatorService } from './services/predictive-evaluator.service';
import { WithdrawalRequest } from './entities/withdrawal-request.entity';
import { Transaction } from '../transactions/entities/transaction.entity';
import { RpcThrottleGuard } from '../../common/guards/rpc-throttle.guard';
import { Reflector } from '@nestjs/core';

Expand Down Expand Up @@ -61,6 +63,8 @@ describe('SavingsController (Enhanced)', () => {
{ provide: getRepositoryToken(UserSubscription), useValue: {} },
{ provide: getRepositoryToken(SavingsGoal), useValue: {} },
{ provide: getRepositoryToken(User), useValue: {} },
{ provide: getRepositoryToken(WithdrawalRequest), useValue: {} },
{ provide: getRepositoryToken(Transaction), useValue: {} },
{ provide: BlockchainSavingsService, useValue: {} },
{ provide: PredictiveEvaluatorService, useValue: {} },
{ provide: ConfigService, useValue: { get: jest.fn() } },
Expand Down
42 changes: 42 additions & 0 deletions backend/src/modules/savings/savings.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { SavingsProduct, RiskLevel } from './entities/savings-product.entity';
import { UserSubscription } from './entities/user-subscription.entity';
import { SavingsGoal } from './entities/savings-goal.entity';
import { SubscribeDto } from './dto/subscribe.dto';
import { WithdrawDto } from './dto/withdraw.dto';
import { WithdrawalResponseDto } from './dto/withdrawal-response.dto';
import { CreateGoalDto } from './dto/create-goal.dto';
import { UpdateGoalDto } from './dto/update-goal.dto';
import { SavingsProductDto } from './dto/savings-product.dto';
Expand Down Expand Up @@ -139,6 +141,46 @@ export class SavingsController {
);
}

@Post('withdraw')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.CREATED)
@ApiBearerAuth()
@ApiOperation({
summary: 'Request withdrawal from a savings subscription',
description:
'Creates a withdrawal request with penalty calculation for early withdrawal from locked products',
})
@ApiBody({ type: WithdrawDto })
@ApiResponse({
status: 201,
description: 'Withdrawal request created',
type: WithdrawalResponseDto,
})
@ApiResponse({ status: 400, description: 'Invalid request or insufficient balance' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Subscription not found' })
async withdraw(
@Body() dto: WithdrawDto,
@CurrentUser() user: { id: string; email: string },
): Promise<WithdrawalResponseDto> {
const withdrawal = await this.savingsService.createWithdrawalRequest(
user.id,
dto.subscriptionId,
dto.amount,
dto.reason,
);

return {
withdrawalId: withdrawal.id,
amount: Number(withdrawal.amount),
penalty: Number(withdrawal.penalty),
netAmount: Number(withdrawal.netAmount),
status: withdrawal.status.toLowerCase(),
estimatedCompletionTime:
withdrawal.estimatedCompletionTime?.toISOString() || '',
};
}

@Get('my-subscriptions')
@Throttle({ rpc: { limit: 10, ttl: 60000 } })
@UseGuards(JwtAuthGuard, RpcThrottleGuard)
Expand Down
4 changes: 4 additions & 0 deletions backend/src/modules/savings/savings.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { PredictiveEvaluatorService } from './services/predictive-evaluator.serv
import { SavingsProduct } from './entities/savings-product.entity';
import { UserSubscription } from './entities/user-subscription.entity';
import { SavingsGoal } from './entities/savings-goal.entity';
import { WithdrawalRequest } from './entities/withdrawal-request.entity';
import { Transaction } from '../transactions/entities/transaction.entity';
import { User } from '../user/entities/user.entity';
import { WaitlistEntry } from './entities/waitlist-entry.entity';
import { WaitlistEvent } from './entities/waitlist-event.entity';
Expand All @@ -18,6 +20,8 @@ import { WaitlistController } from './waitlist.controller';
SavingsProduct,
UserSubscription,
SavingsGoal,
WithdrawalRequest,
Transaction,
User,
WaitlistEntry,
WaitlistEvent,
Expand Down
10 changes: 10 additions & 0 deletions backend/src/modules/savings/savings.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { UserSubscription } from './entities/user-subscription.entity';
import { SavingsGoal, SavingsGoalStatus } from './entities/savings-goal.entity';
import { User } from '../user/entities/user.entity';
import { SavingsService as BlockchainSavingsService } from '../blockchain/savings.service';
import { WithdrawalRequest } from './entities/withdrawal-request.entity';
import { Transaction } from '../transactions/entities/transaction.entity';

describe('SavingsService', () => {
let service: SavingsService;
Expand Down Expand Up @@ -75,6 +77,14 @@ describe('SavingsService', () => {
provide: getRepositoryToken(User),
useValue: userRepository,
},
{
provide: getRepositoryToken(WithdrawalRequest),
useValue: { create: jest.fn(), save: jest.fn(), findOne: jest.fn() },
},
{
provide: getRepositoryToken(Transaction),
useValue: { create: jest.fn((v) => v), save: jest.fn() },
},
{
provide: BlockchainSavingsService,
useValue: blockchainSavingsService,
Expand Down
Loading
Loading