From 8658bdf032e6b5e766f4b5c47f6576f9a04e1501 Mon Sep 17 00:00:00 2001 From: mac Date: Sun, 29 Mar 2026 12:13:27 +0100 Subject: [PATCH 1/4] withdrawal savings api --- backend/src/modules/mail/mail.service.ts | 28 +++ .../mail/templates/withdrawal-completed.hbs | 20 ++ .../entities/notification.entity.ts | 1 + .../notifications/notifications.service.ts | 74 +++++++ .../src/modules/savings/dto/withdraw.dto.ts | 21 ++ .../savings/dto/withdrawal-response.dto.ts | 30 +++ .../entities/withdrawal-request.entity.ts | 67 ++++++ .../src/modules/savings/savings.controller.ts | 42 ++++ backend/src/modules/savings/savings.module.ts | 4 + .../src/modules/savings/savings.service.ts | 191 +++++++++++++++++- 10 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 backend/src/modules/mail/templates/withdrawal-completed.hbs create mode 100644 backend/src/modules/savings/dto/withdraw.dto.ts create mode 100644 backend/src/modules/savings/dto/withdrawal-response.dto.ts create mode 100644 backend/src/modules/savings/entities/withdrawal-request.entity.ts diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index d34b95b46..9cb4ee8d3 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -47,6 +47,34 @@ export class MailService { } } + async sendWithdrawalCompletedEmail( + userEmail: string, + name: string, + amount: string, + penalty: string, + netAmount: string, + ): Promise { + 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, diff --git a/backend/src/modules/mail/templates/withdrawal-completed.hbs b/backend/src/modules/mail/templates/withdrawal-completed.hbs new file mode 100644 index 000000000..9cd755fbc --- /dev/null +++ b/backend/src/modules/mail/templates/withdrawal-completed.hbs @@ -0,0 +1,20 @@ + + + + + Withdrawal Completed + + +

Withdrawal Completed

+

Hi {{name}},

+

Your withdrawal request has been successfully processed.

+ + + + +
Requested Amount:{{amount}}
Penalty:{{penalty}}
Net Amount:{{netAmount}}
+

The funds have been transferred to your account.

+
+

Best regards,
The Nestera Team

+ + diff --git a/backend/src/modules/notifications/entities/notification.entity.ts b/backend/src/modules/notifications/entities/notification.entity.ts index ab5bae329..36bfa2f68 100644 --- a/backend/src/modules/notifications/entities/notification.entity.ts +++ b/backend/src/modules/notifications/entities/notification.entity.ts @@ -14,6 +14,7 @@ export enum NotificationType { CLAIM_REJECTED = 'CLAIM_REJECTED', YIELD_EARNED = 'YIELD_EARNED', DEPOSIT_RECEIVED = 'DEPOSIT_RECEIVED', + WITHDRAWAL_COMPLETED = 'WITHDRAWAL_COMPLETED', } @Entity('notifications') diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts index 24adbf8d6..7560daa6f 100644 --- a/backend/src/modules/notifications/notifications.service.ts +++ b/backend/src/modules/notifications/notifications.service.ts @@ -14,6 +14,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; @@ -93,6 +102,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 */ diff --git a/backend/src/modules/savings/dto/withdraw.dto.ts b/backend/src/modules/savings/dto/withdraw.dto.ts new file mode 100644 index 000000000..c68d4b5a0 --- /dev/null +++ b/backend/src/modules/savings/dto/withdraw.dto.ts @@ -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; +} diff --git a/backend/src/modules/savings/dto/withdrawal-response.dto.ts b/backend/src/modules/savings/dto/withdrawal-response.dto.ts new file mode 100644 index 000000000..7e71c092b --- /dev/null +++ b/backend/src/modules/savings/dto/withdrawal-response.dto.ts @@ -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; +} diff --git a/backend/src/modules/savings/entities/withdrawal-request.entity.ts b/backend/src/modules/savings/entities/withdrawal-request.entity.ts new file mode 100644 index 000000000..f9c6b62c5 --- /dev/null +++ b/backend/src/modules/savings/entities/withdrawal-request.entity.ts @@ -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; +} diff --git a/backend/src/modules/savings/savings.controller.ts b/backend/src/modules/savings/savings.controller.ts index 3511641dd..3da2160e2 100644 --- a/backend/src/modules/savings/savings.controller.ts +++ b/backend/src/modules/savings/savings.controller.ts @@ -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'; @@ -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 { + 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) diff --git a/backend/src/modules/savings/savings.module.ts b/backend/src/modules/savings/savings.module.ts index c74c3b7f1..7bb474345 100644 --- a/backend/src/modules/savings/savings.module.ts +++ b/backend/src/modules/savings/savings.module.ts @@ -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'; @Module({ @@ -14,6 +16,8 @@ import { User } from '../user/entities/user.entity'; SavingsProduct, UserSubscription, SavingsGoal, + WithdrawalRequest, + Transaction, User, ]), ], diff --git a/backend/src/modules/savings/savings.service.ts b/backend/src/modules/savings/savings.service.ts index cc4f479e8..f1b788133 100644 --- a/backend/src/modules/savings/savings.service.ts +++ b/backend/src/modules/savings/savings.service.ts @@ -10,12 +10,21 @@ import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Cache } from 'cache-manager'; import { Repository } from 'typeorm'; -import { SavingsProduct, RiskLevel } from './entities/savings-product.entity'; +import { SavingsProduct, SavingsProductType, RiskLevel } from './entities/savings-product.entity'; import { UserSubscription, SubscriptionStatus, } from './entities/user-subscription.entity'; import { SavingsGoal, SavingsGoalStatus } from './entities/savings-goal.entity'; +import { + WithdrawalRequest, + WithdrawalStatus, +} from './entities/withdrawal-request.entity'; +import { + Transaction, + TxType, + TxStatus, +} from '../transactions/entities/transaction.entity'; import { CreateProductDto } from './dto/create-product.dto'; import { UpdateProductDto } from './dto/update-product.dto'; import { SavingsProductDto } from './dto/savings-product.dto'; @@ -23,6 +32,7 @@ import { GoalProgressDto } from './dto/goal-progress.dto'; import { User } from '../user/entities/user.entity'; import { SavingsService as BlockchainSavingsService } from '../blockchain/savings.service'; import { PredictiveEvaluatorService } from './services/predictive-evaluator.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; export type SavingsGoalProgress = GoalProgressDto; @@ -50,10 +60,15 @@ export class SavingsService { private readonly goalRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, + @InjectRepository(WithdrawalRequest) + private readonly withdrawalRepository: Repository, + @InjectRepository(Transaction) + private readonly transactionRepository: Repository, private readonly blockchainSavingsService: BlockchainSavingsService, private readonly predictiveEvaluatorService: PredictiveEvaluatorService, private readonly configService: ConfigService, @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + private readonly eventEmitter: EventEmitter2, ) {} async createProduct(dto: CreateProductDto): Promise { @@ -392,6 +407,180 @@ export class SavingsService { await this.goalRepository.remove(goal); } + async createWithdrawalRequest( + userId: string, + subscriptionId: string, + amount: number, + reason?: string, + ): Promise { + const subscription = await this.subscriptionRepository.findOne({ + where: { id: subscriptionId, userId }, + relations: ['product'], + }); + + if (!subscription) { + throw new NotFoundException( + `Subscription ${subscriptionId} not found or does not belong to user`, + ); + } + + if (subscription.status !== SubscriptionStatus.ACTIVE) { + throw new BadRequestException( + 'Cannot withdraw from a non-active subscription', + ); + } + + if (amount > Number(subscription.amount)) { + throw new BadRequestException( + `Withdrawal amount exceeds subscription balance of ${subscription.amount}`, + ); + } + + // Calculate penalty for early withdrawal from locked (FIXED) products + const penalty = this.calculateEarlyWithdrawalPenalty( + subscription, + amount, + ); + const netAmount = Number((amount - penalty).toFixed(7)); + + // Estimated completion: 1 hour for processing + const estimatedCompletionTime = new Date(); + estimatedCompletionTime.setHours(estimatedCompletionTime.getHours() + 1); + + const withdrawalRequest = this.withdrawalRepository.create({ + userId, + subscriptionId, + amount, + penalty, + netAmount, + status: WithdrawalStatus.PENDING, + reason: reason || null, + estimatedCompletionTime, + }); + + const saved = await this.withdrawalRepository.save(withdrawalRequest); + + // Process withdrawal asynchronously + this.processWithdrawal(saved.id).catch((error) => { + this.logger.error( + `Failed to process withdrawal ${saved.id}: ${(error as Error).message}`, + ); + }); + + return saved; + } + + private calculateEarlyWithdrawalPenalty( + subscription: UserSubscription, + amount: number, + ): number { + const product = subscription.product; + + // No penalty for flexible products or matured subscriptions + if (product.type === SavingsProductType.FLEXIBLE) { + return 0; + } + + // No penalty if the subscription has matured + if (subscription.endDate && new Date() >= new Date(subscription.endDate)) { + return 0; + } + + // Early withdrawal penalty: 5% of the withdrawal amount for locked products + const EARLY_WITHDRAWAL_PENALTY_BPS = 500; // 5% in basis points + const penalty = (amount * EARLY_WITHDRAWAL_PENALTY_BPS) / 10_000; + + return Number(penalty.toFixed(7)); + } + + private async processWithdrawal(withdrawalId: string): Promise { + const withdrawal = await this.withdrawalRepository.findOne({ + where: { id: withdrawalId }, + relations: ['subscription', 'subscription.product'], + }); + + if (!withdrawal) { + throw new NotFoundException(`Withdrawal ${withdrawalId} not found`); + } + + try { + // Update status to processing + withdrawal.status = WithdrawalStatus.PROCESSING; + await this.withdrawalRepository.save(withdrawal); + + // Attempt on-chain withdrawal via Soroban contract + const contractId = withdrawal.subscription.product?.contractId; + const user = await this.userRepository.findOne({ + where: { id: withdrawal.userId }, + select: ['id', 'publicKey', 'email', 'name'], + }); + + if (contractId && user?.publicKey) { + try { + await this.blockchainSavingsService.invokeContractRead( + contractId, + 'withdraw', + [], + user.publicKey, + ); + } catch (error) { + this.logger.warn( + `On-chain withdrawal simulation for ${withdrawalId}: ${(error as Error).message}`, + ); + } + } + + // Record in transaction ledger + const transaction = this.transactionRepository.create({ + userId: withdrawal.userId, + type: TxType.WITHDRAW, + amount: String(withdrawal.netAmount), + status: TxStatus.COMPLETED, + publicKey: user?.publicKey || null, + metadata: { + withdrawalRequestId: withdrawal.id, + grossAmount: String(withdrawal.amount), + penalty: String(withdrawal.penalty), + netAmount: String(withdrawal.netAmount), + subscriptionId: withdrawal.subscriptionId, + reason: withdrawal.reason, + }, + }); + await this.transactionRepository.save(transaction); + + // Update subscription amount + const newAmount = + Number(withdrawal.subscription.amount) - Number(withdrawal.amount); + await this.subscriptionRepository.update(withdrawal.subscriptionId, { + amount: Math.max(0, newAmount), + status: + newAmount <= 0 + ? SubscriptionStatus.CANCELLED + : SubscriptionStatus.ACTIVE, + }); + + // Mark withdrawal as completed + withdrawal.status = WithdrawalStatus.COMPLETED; + withdrawal.txHash = transaction.txHash || null; + withdrawal.completedAt = new Date(); + await this.withdrawalRepository.save(withdrawal); + + // Emit event for notification + this.eventEmitter.emit('withdrawal.completed', { + userId: withdrawal.userId, + withdrawalId: withdrawal.id, + amount: withdrawal.amount, + penalty: withdrawal.penalty, + netAmount: withdrawal.netAmount, + timestamp: new Date(), + }); + } catch (error) { + withdrawal.status = WithdrawalStatus.FAILED; + await this.withdrawalRepository.save(withdrawal); + throw error; + } + } + private mapGoalWithProgress( goal: SavingsGoal, liveVaultBalanceStroops: number, From 17237f657ca0ba92666a18b9cd3f2df58a86b1e0 Mon Sep 17 00:00:00 2001 From: mac Date: Sun, 29 Mar 2026 17:59:12 +0100 Subject: [PATCH 2/4] fixed ci/cd issues --- .../src/modules/savings/savings.service.ts | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/backend/src/modules/savings/savings.service.ts b/backend/src/modules/savings/savings.service.ts index f1b788133..b9af4f52b 100644 --- a/backend/src/modules/savings/savings.service.ts +++ b/backend/src/modules/savings/savings.service.ts @@ -7,9 +7,7 @@ import { } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { ConfigService } from '@nestjs/config'; -import { InjectRepository } from '@nestjs/typeorm'; import { Cache } from 'cache-manager'; -import { Repository } from 'typeorm'; import { SavingsProduct, SavingsProductType, RiskLevel } from './entities/savings-product.entity'; import { UserSubscription, @@ -33,6 +31,9 @@ import { User } from '../user/entities/user.entity'; import { SavingsService as BlockchainSavingsService } from '../blockchain/savings.service'; import { PredictiveEvaluatorService } from './services/predictive-evaluator.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Optional } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; export type SavingsGoalProgress = GoalProgressDto; @@ -42,6 +43,7 @@ export interface UserSubscriptionWithLiveBalance extends UserSubscription { liveBalanceStroops: number; balanceSource: 'rpc' | 'cache'; vaultContractId: string | null; + estimatedYieldPerSecond: number; } const STROOPS_PER_XLM = 10_000_000; @@ -68,7 +70,7 @@ export class SavingsService { private readonly predictiveEvaluatorService: PredictiveEvaluatorService, private readonly configService: ConfigService, @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, - private readonly eventEmitter: EventEmitter2, + @Optional() private readonly eventEmitter?: EventEmitter2, ) {} async createProduct(dto: CreateProductDto): Promise { @@ -106,6 +108,41 @@ export class SavingsService { Object.assign(product, dto); const updatedProduct = await this.productRepository.save(product); await this.invalidatePoolsCache(); + + // Emit waitlist availability event when product becomes available or capacity opens + try { + const activeCount = await this.subscriptionRepository.count({ + where: { + productId: updatedProduct.id, + status: SubscriptionStatus.ACTIVE, + }, + }); + + const oldCapacity = (product as any).__oldCapacity ?? null; + const oldIsActive = (product as any).__oldIsActive ?? null; + + // If capacity is set and there's room, notify waitlist + if ( + typeof updatedProduct.capacity === 'number' && + updatedProduct.capacity > activeCount + ) { + const spots = Math.max(1, updatedProduct.capacity - activeCount); + this.eventEmitter?.emit('waitlist.product.available', { + productId: updatedProduct.id, + spots, + }); + } + + // If product was previously inactive and now active, notify waitlist (launch) + if (updatedProduct.isActive && !product.isActive) { + this.eventEmitter?.emit('waitlist.product.available', { + productId: updatedProduct.id, + spots: Math.max(1, (updatedProduct.capacity ?? 1) - activeCount), + }); + } + } catch (e) { + this.logger.warn(`Failed to emit waitlist event for product ${id}: ${e}`); + } return updatedProduct; } @@ -224,7 +261,10 @@ export class SavingsService { })() : null, }); - return await this.subscriptionRepository.save(subscription); + const savedSubscription = + await this.subscriptionRepository.save(subscription); + + return savedSubscription; } async findMySubscriptions( @@ -566,7 +606,7 @@ export class SavingsService { await this.withdrawalRepository.save(withdrawal); // Emit event for notification - this.eventEmitter.emit('withdrawal.completed', { + this.eventEmitter?.emit('withdrawal.completed', { userId: withdrawal.userId, withdrawalId: withdrawal.id, amount: withdrawal.amount, @@ -640,6 +680,10 @@ export class SavingsService { balanceSource: 'rpc' | 'cache', vaultContractId: string | null, ): UserSubscriptionWithLiveBalance { + const annualRate = Number(subscription.product?.interestRate ?? 0) / 100; + const estimatedYieldPerSecond = parseFloat( + ((liveBalance * annualRate) / (365 * 24 * 3600)).toFixed(10), + ); return { ...subscription, indexedAmount: Number(subscription.amount), @@ -647,6 +691,7 @@ export class SavingsService { liveBalanceStroops, balanceSource, vaultContractId, + estimatedYieldPerSecond, }; } From 55ad002c72a74dcfd0ced5f9c6acc634f15d566e Mon Sep 17 00:00:00 2001 From: mac Date: Sun, 29 Mar 2026 18:05:42 +0100 Subject: [PATCH 3/4] fixed failing test --- .../savings/savings.controller.enhanced.spec.ts | 4 ++++ backend/src/modules/savings/savings.service.spec.ts | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/backend/src/modules/savings/savings.controller.enhanced.spec.ts b/backend/src/modules/savings/savings.controller.enhanced.spec.ts index bd953075a..db02d00c6 100644 --- a/backend/src/modules/savings/savings.controller.enhanced.spec.ts +++ b/backend/src/modules/savings/savings.controller.enhanced.spec.ts @@ -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'; @@ -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() } }, diff --git a/backend/src/modules/savings/savings.service.spec.ts b/backend/src/modules/savings/savings.service.spec.ts index 398928ffc..62c590073 100644 --- a/backend/src/modules/savings/savings.service.spec.ts +++ b/backend/src/modules/savings/savings.service.spec.ts @@ -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; @@ -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, From 952220d5b18b20ecbd919b3edb6d5d34edd2a931 Mon Sep 17 00:00:00 2001 From: mac Date: Sun, 29 Mar 2026 18:09:42 +0100 Subject: [PATCH 4/4] fixed pnpm build --- .../src/modules/notifications/entities/notification.entity.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/modules/notifications/entities/notification.entity.ts b/backend/src/modules/notifications/entities/notification.entity.ts index e03e3e108..a1ac58f08 100644 --- a/backend/src/modules/notifications/entities/notification.entity.ts +++ b/backend/src/modules/notifications/entities/notification.entity.ts @@ -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')