From 321c471933506205d1fcae767f4f95083d879d7f Mon Sep 17 00:00:00 2001 From: Uche Solomon <100881766+Devsol-01@users.noreply.github.com> Date: Mon, 30 Mar 2026 07:15:53 +0100 Subject: [PATCH] feat: add savings product capacity management (closes #482) --- ...0000000-AddMaxCapacityToSavingsProducts.ts | 20 ++ .../modules/admin/admin-savings.controller.ts | 14 ++ .../entities/notification.entity.ts | 1 + .../notifications/notifications.service.ts | 43 ++++ .../modules/savings/dto/create-product.dto.ts | 9 + .../savings/dto/product-details.dto.ts | 17 ++ .../savings/dto/savings-product.dto.ts | 14 ++ .../entities/savings-product.entity.ts | 3 + .../src/modules/savings/savings.controller.ts | 7 +- .../src/modules/savings/savings.service.ts | 215 ++++++++++++++---- .../src/modules/savings/waitlist.service.ts | 6 +- 11 files changed, 304 insertions(+), 45 deletions(-) create mode 100644 backend/src/migrations/1791000000000-AddMaxCapacityToSavingsProducts.ts diff --git a/backend/src/migrations/1791000000000-AddMaxCapacityToSavingsProducts.ts b/backend/src/migrations/1791000000000-AddMaxCapacityToSavingsProducts.ts new file mode 100644 index 000000000..c853b91a3 --- /dev/null +++ b/backend/src/migrations/1791000000000-AddMaxCapacityToSavingsProducts.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddMaxCapacityToSavingsProducts1791000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'savings_products', + new TableColumn({ + name: 'maxCapacity', + type: 'decimal', + precision: 14, + scale: 2, + isNullable: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('savings_products', 'maxCapacity'); + } +} diff --git a/backend/src/modules/admin/admin-savings.controller.ts b/backend/src/modules/admin/admin-savings.controller.ts index d0bab6bc4..55c6b9b22 100644 --- a/backend/src/modules/admin/admin-savings.controller.ts +++ b/backend/src/modules/admin/admin-savings.controller.ts @@ -1,5 +1,6 @@ import { Controller, + Get, Post, Patch, Body, @@ -23,6 +24,7 @@ import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../common/guards/roles.guard'; import { Roles } from '../../common/decorators/roles.decorator'; import { Role } from '../../common/enums/role.enum'; +import { ProductCapacitySnapshot } from '../savings/savings.service'; @ApiTags('admin/savings') @Controller('admin/savings') @@ -63,4 +65,16 @@ export class AdminSavingsController { ): Promise { return await this.savingsService.updateProduct(id, dto); } + + @Get('products/:id/capacity-metrics') + @ApiOperation({ summary: 'Get live capacity utilization metrics (admin)' }) + @ApiResponse({ + status: 200, + description: 'Live capacity metrics', + }) + async getCapacityMetrics( + @Param('id') id: string, + ): Promise { + return await this.savingsService.getProductCapacitySnapshot(id); + } } diff --git a/backend/src/modules/notifications/entities/notification.entity.ts b/backend/src/modules/notifications/entities/notification.entity.ts index dc28b0f18..48baccced 100644 --- a/backend/src/modules/notifications/entities/notification.entity.ts +++ b/backend/src/modules/notifications/entities/notification.entity.ts @@ -21,6 +21,7 @@ export enum NotificationType { CHALLENGE_BADGE_EARNED = 'CHALLENGE_BADGE_EARNED', PRODUCT_ALERT_TRIGGERED = 'PRODUCT_ALERT_TRIGGERED', REBALANCING_RECOMMENDED = 'REBALANCING_RECOMMENDED', + ADMIN_CAPACITY_ALERT = 'ADMIN_CAPACITY_ALERT', } @Entity('notifications') diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts index 2b328cef5..d68b22a5f 100644 --- a/backend/src/modules/notifications/notifications.service.ts +++ b/backend/src/modules/notifications/notifications.service.ts @@ -8,6 +8,7 @@ import { MailService } from '../mail/mail.service'; import { User } from '../user/entities/user.entity'; import { WaitlistEntry } from '../savings/entities/waitlist-entry.entity'; import { WaitlistEvent } from '../savings/entities/waitlist-event.entity'; +import { Role } from '../../common/enums/role.enum'; export interface SweepCompletedEvent { userId: string; @@ -426,6 +427,48 @@ export class NotificationsService { } } + @OnEvent('savings.capacity.threshold') + async handleCapacityAlert(event: { + productId: string; + utilizationPercentage: number; + isFull: boolean; + }) { + try { + const admins = await this.userRepository.find({ + where: { role: Role.ADMIN }, + select: ['id'], + }); + + if (!admins.length) { + return; + } + + const title = event.isFull + ? 'Savings product auto-deactivated' + : 'Savings product nearing capacity'; + const message = event.isFull + ? `Product ${event.productId} reached maximum capacity and was auto-deactivated.` + : `Product ${event.productId} is ${event.utilizationPercentage}% utilized.`; + + await Promise.all( + admins.map((admin) => + this.createNotification({ + userId: admin.id, + type: NotificationType.ADMIN_CAPACITY_ALERT, + title, + message, + metadata: event, + }), + ), + ); + } catch (error) { + this.logger.error( + `Error processing savings.capacity.threshold for product ${event.productId}`, + error, + ); + } + } + /** * Create a notification in the database */ diff --git a/backend/src/modules/savings/dto/create-product.dto.ts b/backend/src/modules/savings/dto/create-product.dto.ts index 52a8c6486..24312248e 100644 --- a/backend/src/modules/savings/dto/create-product.dto.ts +++ b/backend/src/modules/savings/dto/create-product.dto.ts @@ -77,6 +77,15 @@ export class CreateProductDto { @Min(0) tvlAmount?: number; + @ApiPropertyOptional({ + example: 250000, + description: 'Maximum liquidity-backed capacity for the product', + }) + @IsOptional() + @IsNumber() + @Min(0) + maxCapacity?: number; + @ApiPropertyOptional({ enum: RiskLevel, default: RiskLevel.LOW, diff --git a/backend/src/modules/savings/dto/product-details.dto.ts b/backend/src/modules/savings/dto/product-details.dto.ts index 56f0ec66d..160cce1d6 100644 --- a/backend/src/modules/savings/dto/product-details.dto.ts +++ b/backend/src/modules/savings/dto/product-details.dto.ts @@ -46,6 +46,23 @@ export class ProductDetailsDto { @ApiProperty({ description: 'Live total assets formatted as XLM' }) totalAssetsXlm: number; + @ApiPropertyOptional({ + description: 'Maximum liquidity-backed capacity for the product', + }) + maxCapacity: number | null; + + @ApiProperty({ description: 'Current utilized capacity amount' }) + utilizedCapacity: number; + + @ApiProperty({ description: 'Remaining capacity amount' }) + availableCapacity: number; + + @ApiProperty({ description: 'Capacity utilization percentage' }) + utilizationPercentage: number; + + @ApiProperty({ description: 'Whether the product is fully utilized' }) + isFull: boolean; + @ApiProperty({ description: 'Product creation timestamp' }) createdAt: Date; diff --git a/backend/src/modules/savings/dto/savings-product.dto.ts b/backend/src/modules/savings/dto/savings-product.dto.ts index 88aa58087..de5d68db8 100644 --- a/backend/src/modules/savings/dto/savings-product.dto.ts +++ b/backend/src/modules/savings/dto/savings-product.dto.ts @@ -44,6 +44,20 @@ export class SavingsProductDto { @ApiProperty({ description: 'Total Value Locked (aggregated local balance)' }) tvlAmount: number; + @ApiPropertyOptional({ + description: 'Maximum liquidity-backed capacity for the product', + }) + maxCapacity: number | null; + + @ApiProperty({ description: 'Current utilized capacity amount' }) + utilizedCapacity: number; + + @ApiProperty({ description: 'Remaining capacity amount' }) + availableCapacity: number; + + @ApiProperty({ description: 'Capacity utilization percentage' }) + utilizationPercentage: number; + @ApiProperty({ description: 'Product creation timestamp' }) createdAt: Date; diff --git a/backend/src/modules/savings/entities/savings-product.entity.ts b/backend/src/modules/savings/entities/savings-product.entity.ts index 69dd664d2..b10e4dfa0 100644 --- a/backend/src/modules/savings/entities/savings-product.entity.ts +++ b/backend/src/modules/savings/entities/savings-product.entity.ts @@ -54,6 +54,9 @@ export class SavingsProduct { @Column('int', { nullable: true }) capacity: number | null; + @Column('decimal', { precision: 14, scale: 2, nullable: true }) + maxCapacity: number | null; + @Column({ default: true }) isActive: boolean; diff --git a/backend/src/modules/savings/savings.controller.ts b/backend/src/modules/savings/savings.controller.ts index a9ca2aa5f..6c56142c7 100644 --- a/backend/src/modules/savings/savings.controller.ts +++ b/backend/src/modules/savings/savings.controller.ts @@ -93,7 +93,7 @@ export class SavingsController { description: 'Soroban RPC request timeout', }) async getProductDetails(@Param('id') id: string): Promise { - const { product, totalAssets } = + const { product, totalAssets, capacity } = await this.savingsService.findProductWithLiveData(id); const totalAssetsXlm = totalAssets / 10_000_000; @@ -111,6 +111,11 @@ export class SavingsController { contractId: product.contractId, totalAssets, totalAssetsXlm, + maxCapacity: capacity.maxCapacity, + utilizedCapacity: capacity.utilizedCapacity, + availableCapacity: capacity.availableCapacity, + utilizationPercentage: capacity.utilizationPercentage, + isFull: capacity.isFull, riskLevel: product.riskLevel || RiskLevel.LOW, createdAt: product.createdAt, updatedAt: product.updatedAt, diff --git a/backend/src/modules/savings/savings.service.ts b/backend/src/modules/savings/savings.service.ts index d5c787253..4c2a04936 100644 --- a/backend/src/modules/savings/savings.service.ts +++ b/backend/src/modules/savings/savings.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException, + ConflictException, Logger, Inject, } from '@nestjs/common'; @@ -38,6 +39,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { WaitlistService } from './waitlist.service'; export type SavingsGoalProgress = GoalProgressDto; @@ -50,6 +52,16 @@ export interface UserSubscriptionWithLiveBalance extends UserSubscription { estimatedYieldPerSecond: number; } +export interface ProductCapacitySnapshot { + productId: string; + maxCapacity: number | null; + utilizedCapacity: number; + availableCapacity: number; + utilizationPercentage: number; + isFull: boolean; + source: 'soroban' | 'database'; +} + const STROOPS_PER_XLM = 10_000_000; const POOLS_CACHE_KEY = 'pools_all'; @@ -72,6 +84,7 @@ export class SavingsService { private readonly transactionRepository: Repository, private readonly blockchainSavingsService: BlockchainSavingsService, private readonly predictiveEvaluatorService: PredictiveEvaluatorService, + private readonly waitlistService: WaitlistService, private readonly configService: ConfigService, @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, @Optional() private readonly eventEmitter?: EventEmitter2, @@ -109,39 +122,29 @@ export class SavingsService { 'minAmount must be less than or equal to maxAmount', ); } + const previousIsActive = product.isActive; Object.assign(product, dto); const updatedProduct = await this.productRepository.save(product); + await this.syncCapacityState(updatedProduct); 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; + const capacity = await this.getProductCapacitySnapshot(updatedProduct.id); // 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); + if (capacity.maxCapacity != null && capacity.availableCapacity > 0) { this.eventEmitter?.emit('waitlist.product.available', { productId: updatedProduct.id, - spots, + spots: 1, }); } // If product was previously inactive and now active, notify waitlist (launch) - if (updatedProduct.isActive && !product.isActive) { + if (updatedProduct.isActive && !previousIsActive) { this.eventEmitter?.emit('waitlist.product.available', { productId: updatedProduct.id, - spots: Math.max(1, (updatedProduct.capacity ?? 1) - activeCount), + spots: 1, }); } } catch (e) { @@ -159,31 +162,38 @@ export class SavingsService { relations: ['subscriptions'], }); - const dtos: SavingsProductDto[] = products.map((product) => { - // Calculate TVL by summing active subscriptions - const tvlAmount = product.subscriptions - ? product.subscriptions - .filter((s) => s.status === SubscriptionStatus.ACTIVE) - .reduce((sum, s) => sum + Number(s.amount), 0) - : 0; - - return { - id: product.id, - name: product.name, - type: product.type, - description: product.description, - interestRate: Number(product.interestRate), - minAmount: Number(product.minAmount), - maxAmount: Number(product.maxAmount), - tenureMonths: product.tenureMonths, - contractId: product.contractId, - isActive: product.isActive, - riskLevel: product.riskLevel || RiskLevel.LOW, - tvlAmount, - createdAt: product.createdAt, - updatedAt: product.updatedAt, - }; - }); + const dtos: SavingsProductDto[] = await Promise.all( + products.map(async (product) => { + // Calculate TVL by summing active subscriptions + const tvlAmount = product.subscriptions + ? product.subscriptions + .filter((s) => s.status === SubscriptionStatus.ACTIVE) + .reduce((sum, s) => sum + Number(s.amount), 0) + : 0; + const capacity = await this.getProductCapacitySnapshot(product.id); + + return { + id: product.id, + name: product.name, + type: product.type, + description: product.description, + interestRate: Number(product.interestRate), + minAmount: Number(product.minAmount), + maxAmount: Number(product.maxAmount), + tenureMonths: product.tenureMonths, + contractId: product.contractId, + isActive: product.isActive, + riskLevel: product.riskLevel || RiskLevel.LOW, + tvlAmount, + maxCapacity: capacity.maxCapacity, + utilizedCapacity: capacity.utilizedCapacity, + availableCapacity: capacity.availableCapacity, + utilizationPercentage: capacity.utilizationPercentage, + createdAt: product.createdAt, + updatedAt: product.updatedAt, + }; + }), + ); // Handle local sorting if (sort === 'apy') { @@ -209,6 +219,7 @@ export class SavingsService { async findProductWithLiveData(id: string): Promise<{ product: SavingsProduct; totalAssets: number; + capacity: ProductCapacitySnapshot; }> { const product = await this.findOneProduct(id); @@ -228,7 +239,9 @@ export class SavingsService { } } - return { product, totalAssets }; + const capacity = await this.getProductCapacitySnapshot(product.id); + + return { product, totalAssets, capacity }; } async subscribe( @@ -237,6 +250,7 @@ export class SavingsService { amount: number, ): Promise { const product = await this.findOneProduct(productId); + await this.syncCapacityState(product); if (!product.isActive) { throw new BadRequestException( 'This savings product is not available for subscription', @@ -251,6 +265,20 @@ export class SavingsService { ); } + const capacity = await this.getProductCapacitySnapshot(productId); + if ( + capacity.maxCapacity != null && + (capacity.isFull || amount > capacity.availableCapacity) + ) { + const { position } = await this.waitlistService.joinWaitlist( + userId, + productId, + ); + throw new ConflictException( + `This savings product is at capacity. You have been added to the waitlist at position ${position}.`, + ); + } + const subscription = this.subscriptionRepository.create({ userId, productId: product.id, @@ -346,6 +374,70 @@ export class SavingsService { ); } + async getProductCapacitySnapshot( + productId: string, + ): Promise { + const product = await this.findOneProduct(productId); + const maxCapacity = + product.maxCapacity != null + ? Number(product.maxCapacity) + : product.capacity != null + ? Number(product.capacity) + : null; + + let utilizedCapacity = 0; + let source: ProductCapacitySnapshot['source'] = 'database'; + + if (product.contractId) { + try { + const totalAssets = + await this.blockchainSavingsService.getVaultTotalAssets( + product.contractId, + ); + utilizedCapacity = this.stroopsToDecimal(totalAssets); + source = 'soroban'; + } catch (error) { + this.logger.warn( + `Falling back to database capacity for product ${product.id}: ${(error as Error).message}`, + ); + } + } + + if (source === 'database') { + const total = await this.subscriptionRepository + .createQueryBuilder('subscription') + .select('COALESCE(SUM(subscription.amount), 0)', 'total') + .where('subscription.productId = :productId', { productId }) + .andWhere('subscription.status = :status', { + status: SubscriptionStatus.ACTIVE, + }) + .getRawOne<{ total: string }>(); + utilizedCapacity = Number(total?.total ?? 0); + } + + const availableCapacity = + maxCapacity == null ? null : maxCapacity - utilizedCapacity; + const safeAvailableCapacity = + availableCapacity == null ? 0 : Math.max(0, availableCapacity); + const utilizationPercentage = + maxCapacity && maxCapacity > 0 + ? Math.min( + 100, + Number(((utilizedCapacity / maxCapacity) * 100).toFixed(2)), + ) + : 0; + + return { + productId: product.id, + maxCapacity, + utilizedCapacity: Number(utilizedCapacity.toFixed(2)), + availableCapacity: Number(safeAvailableCapacity.toFixed(2)), + utilizationPercentage, + isFull: maxCapacity != null && safeAvailableCapacity <= 0, + source, + }; + } + async findMyGoals(userId: string): Promise { const [goals, user, subscriptions] = await Promise.all([ this.goalRepository.find({ @@ -765,4 +857,41 @@ export class SavingsService { return totalYield / activeSubscriptions.length; } + + private async syncCapacityState( + product: SavingsProduct, + ): Promise { + const maxCapacity = + product.maxCapacity != null + ? Number(product.maxCapacity) + : product.capacity != null + ? Number(product.capacity) + : null; + + if (maxCapacity == null) { + return product; + } + + const snapshot = await this.getProductCapacitySnapshot(product.id); + if (snapshot.isFull && product.isActive) { + product.isActive = false; + await this.productRepository.save(product); + this.eventEmitter?.emit('savings.capacity.threshold', { + productId: product.id, + utilizationPercentage: snapshot.utilizationPercentage, + isFull: true, + }); + return product; + } + + if (snapshot.utilizationPercentage >= 80) { + this.eventEmitter?.emit('savings.capacity.threshold', { + productId: product.id, + utilizationPercentage: snapshot.utilizationPercentage, + isFull: false, + }); + } + + return product; + } } diff --git a/backend/src/modules/savings/waitlist.service.ts b/backend/src/modules/savings/waitlist.service.ts index e1f5aa3fe..5c7146543 100644 --- a/backend/src/modules/savings/waitlist.service.ts +++ b/backend/src/modules/savings/waitlist.service.ts @@ -38,7 +38,11 @@ export class WaitlistService { if (!product) throw new NotFoundException('Product not found'); // If product is available for subscription, reject — no waitlist needed - if (product.isActive && product.capacity == null) { + if ( + product.isActive && + product.capacity == null && + product.maxCapacity == null + ) { throw new BadRequestException('Product is currently available'); }