From e4617230e85db0aadccd6320376dc4ee9fad088e Mon Sep 17 00:00:00 2001 From: Uche Solomon <100881766+Devsol-01@users.noreply.github.com> Date: Mon, 30 Mar 2026 07:46:29 +0100 Subject: [PATCH] feat: add subscription limits to savings products (closes #480) --- ...791300000000-AddMaxSubscriptionsPerUser.ts | 18 ++++++++ .../modules/admin/admin-savings.controller.ts | 22 +++++++++ .../modules/savings/dto/create-product.dto.ts | 9 ++++ .../savings/dto/product-details.dto.ts | 5 +++ .../savings/dto/savings-product.dto.ts | 5 +++ .../entities/savings-product.entity.ts | 3 ++ .../src/modules/savings/savings.controller.ts | 1 + .../src/modules/savings/savings.service.ts | 45 +++++++++++++++++++ 8 files changed, 108 insertions(+) create mode 100644 backend/src/migrations/1791300000000-AddMaxSubscriptionsPerUser.ts diff --git a/backend/src/migrations/1791300000000-AddMaxSubscriptionsPerUser.ts b/backend/src/migrations/1791300000000-AddMaxSubscriptionsPerUser.ts new file mode 100644 index 000000000..fbe030535 --- /dev/null +++ b/backend/src/migrations/1791300000000-AddMaxSubscriptionsPerUser.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddMaxSubscriptionsPerUser1791300000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'savings_products', + new TableColumn({ + name: 'maxSubscriptionsPerUser', + type: 'int', + isNullable: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('savings_products', 'maxSubscriptionsPerUser'); + } +} diff --git a/backend/src/modules/admin/admin-savings.controller.ts b/backend/src/modules/admin/admin-savings.controller.ts index d0bab6bc4..9139a2c6a 100644 --- a/backend/src/modules/admin/admin-savings.controller.ts +++ b/backend/src/modules/admin/admin-savings.controller.ts @@ -23,6 +23,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 { CurrentUser } from '../../common/decorators/current-user.decorator'; @ApiTags('admin/savings') @Controller('admin/savings') @@ -63,4 +64,25 @@ export class AdminSavingsController { ): Promise { return await this.savingsService.updateProduct(id, dto); } + + @Post('products/:id/subscriptions/override') + @ApiOperation({ + summary: 'Create a subscription with admin override for limit checks', + }) + @ApiResponse({ + status: 201, + description: 'Subscription created with admin override', + }) + async createSubscriptionOverride( + @Param('id') id: string, + @Body() body: { userId: string; amount: number }, + @CurrentUser() _admin: { id: string; email: string }, + ) { + return await this.savingsService.subscribe( + body.userId, + id, + body.amount, + true, + ); + } } diff --git a/backend/src/modules/savings/dto/create-product.dto.ts b/backend/src/modules/savings/dto/create-product.dto.ts index 52a8c6486..b45b47b65 100644 --- a/backend/src/modules/savings/dto/create-product.dto.ts +++ b/backend/src/modules/savings/dto/create-product.dto.ts @@ -86,6 +86,15 @@ export class CreateProductDto { @IsEnum(RiskLevel) riskLevel?: RiskLevel; + @ApiPropertyOptional({ + example: 3, + description: 'Maximum active subscriptions allowed per user', + }) + @IsOptional() + @IsNumber() + @Min(1) + maxSubscriptionsPerUser?: number; + @ApiPropertyOptional({ default: true }) @IsOptional() isActive?: boolean; diff --git a/backend/src/modules/savings/dto/product-details.dto.ts b/backend/src/modules/savings/dto/product-details.dto.ts index 56f0ec66d..299f1572f 100644 --- a/backend/src/modules/savings/dto/product-details.dto.ts +++ b/backend/src/modules/savings/dto/product-details.dto.ts @@ -35,6 +35,11 @@ export class ProductDetailsDto { @ApiProperty({ description: 'Whether product is active' }) isActive: boolean; + @ApiPropertyOptional({ + description: 'Maximum active subscriptions allowed per user', + }) + maxSubscriptionsPerUser: number | null; + @ApiPropertyOptional({ description: 'Soroban vault contract ID' }) contractId: string | null; diff --git a/backend/src/modules/savings/dto/savings-product.dto.ts b/backend/src/modules/savings/dto/savings-product.dto.ts index 88aa58087..d0eee9b6c 100644 --- a/backend/src/modules/savings/dto/savings-product.dto.ts +++ b/backend/src/modules/savings/dto/savings-product.dto.ts @@ -35,6 +35,11 @@ export class SavingsProductDto { @ApiProperty({ description: 'Whether product is active' }) isActive: boolean; + @ApiPropertyOptional({ + description: 'Maximum active subscriptions allowed per user', + }) + maxSubscriptionsPerUser: number | null; + @ApiProperty({ description: 'Risk level classification (e.g. Low, Medium, High)', enum: RiskLevel, diff --git a/backend/src/modules/savings/entities/savings-product.entity.ts b/backend/src/modules/savings/entities/savings-product.entity.ts index 69dd664d2..8ea381624 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('int', { nullable: true }) + maxSubscriptionsPerUser: 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..63d0747e2 100644 --- a/backend/src/modules/savings/savings.controller.ts +++ b/backend/src/modules/savings/savings.controller.ts @@ -107,6 +107,7 @@ export class SavingsController { minAmount: product.minAmount, maxAmount: product.maxAmount, tenureMonths: product.tenureMonths, + maxSubscriptionsPerUser: product.maxSubscriptionsPerUser, isActive: product.isActive, contractId: product.contractId, totalAssets, diff --git a/backend/src/modules/savings/savings.service.ts b/backend/src/modules/savings/savings.service.ts index d5c787253..e967d59a1 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; @@ -72,6 +74,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, @@ -178,6 +181,7 @@ export class SavingsService { tenureMonths: product.tenureMonths, contractId: product.contractId, isActive: product.isActive, + maxSubscriptionsPerUser: product.maxSubscriptionsPerUser, riskLevel: product.riskLevel || RiskLevel.LOW, tvlAmount, createdAt: product.createdAt, @@ -235,6 +239,7 @@ export class SavingsService { userId: string, productId: string, amount: number, + overrideLimits = false, ): Promise { const product = await this.findOneProduct(productId); if (!product.isActive) { @@ -251,6 +256,46 @@ export class SavingsService { ); } + if (!overrideLimits) { + const activeSubscriptionsForUser = + await this.subscriptionRepository.count({ + where: { + userId, + productId: product.id, + status: SubscriptionStatus.ACTIVE, + }, + }); + + if ( + product.maxSubscriptionsPerUser != null && + activeSubscriptionsForUser >= product.maxSubscriptionsPerUser + ) { + throw new ConflictException( + `Subscription limit reached. You can only hold ${product.maxSubscriptionsPerUser} active subscriptions for this product.`, + ); + } + + if (product.capacity != null) { + const activeSubscriptionsForProduct = + await this.subscriptionRepository.count({ + where: { + productId: product.id, + status: SubscriptionStatus.ACTIVE, + }, + }); + + if (activeSubscriptionsForProduct >= product.capacity) { + const { position } = await this.waitlistService.joinWaitlist( + userId, + product.id, + ); + throw new ConflictException( + `This savings product is full. You have been added to the waitlist at position ${position}.`, + ); + } + } + } + const subscription = this.subscriptionRepository.create({ userId, productId: product.id,