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 e79066e8c..7e414f991 100644 --- a/backend/src/modules/admin/admin-savings.controller.ts +++ b/backend/src/modules/admin/admin-savings.controller.ts @@ -88,6 +88,25 @@ export class AdminSavingsController { return this.adminSavingsService.getSubscribers(id, opts); } + @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, + ); @Post('experiments') @ApiOperation({ summary: 'Create a savings product experiment (admin)' }) @ApiResponse({ status: 201, description: 'Experiment created' }) diff --git a/backend/src/modules/savings/dto/create-product.dto.ts b/backend/src/modules/savings/dto/create-product.dto.ts index 9a396f8c4..80c3e733d 100644 --- a/backend/src/modules/savings/dto/create-product.dto.ts +++ b/backend/src/modules/savings/dto/create-product.dto.ts @@ -96,6 +96,8 @@ export class CreateProductDto { riskLevel?: RiskLevel; @ApiPropertyOptional({ + example: 3, + description: 'Maximum active subscriptions allowed per user', example: 1, description: 'Initial product version', default: 1, @@ -103,6 +105,7 @@ export class CreateProductDto { @IsOptional() @IsNumber() @Min(1) + maxSubscriptionsPerUser?: number; version?: number; @ApiPropertyOptional({ default: true }) diff --git a/backend/src/modules/savings/dto/product-details.dto.ts b/backend/src/modules/savings/dto/product-details.dto.ts index a0b604f06..97464745c 100644 --- a/backend/src/modules/savings/dto/product-details.dto.ts +++ b/backend/src/modules/savings/dto/product-details.dto.ts @@ -35,6 +35,10 @@ export class ProductDetailsDto { @ApiProperty({ description: 'Whether product is active' }) isActive: boolean; + @ApiPropertyOptional({ + description: 'Maximum active subscriptions allowed per user', + }) + maxSubscriptionsPerUser: number | null; @ApiProperty({ description: 'Current product version' }) version: number; diff --git a/backend/src/modules/savings/dto/savings-product.dto.ts b/backend/src/modules/savings/dto/savings-product.dto.ts index e3a73e9af..6a64c3d89 100644 --- a/backend/src/modules/savings/dto/savings-product.dto.ts +++ b/backend/src/modules/savings/dto/savings-product.dto.ts @@ -35,6 +35,10 @@ export class SavingsProductDto { @ApiProperty({ description: 'Whether product is active' }) isActive: boolean; + @ApiPropertyOptional({ + description: 'Maximum active subscriptions allowed per user', + }) + maxSubscriptionsPerUser: number | null; @ApiProperty({ description: 'Current product version' }) version: number; diff --git a/backend/src/modules/savings/entities/savings-product.entity.ts b/backend/src/modules/savings/entities/savings-product.entity.ts index da8620cdc..34cb80332 100644 --- a/backend/src/modules/savings/entities/savings-product.entity.ts +++ b/backend/src/modules/savings/entities/savings-product.entity.ts @@ -54,6 +54,8 @@ export class SavingsProduct { @Column('int', { nullable: true }) capacity: number | null; + @Column('int', { nullable: true }) + maxSubscriptionsPerUser: number | null; @Column({ type: 'int', default: 1 }) version: number; diff --git a/backend/src/modules/savings/savings.controller.ts b/backend/src/modules/savings/savings.controller.ts index 2ed9cdc9f..2c43f0be2 100644 --- a/backend/src/modules/savings/savings.controller.ts +++ b/backend/src/modules/savings/savings.controller.ts @@ -112,6 +112,7 @@ export class SavingsController { minAmount: product.minAmount, maxAmount: product.maxAmount, tenureMonths: product.tenureMonths, + maxSubscriptionsPerUser: product.maxSubscriptionsPerUser, version: product.version ?? 1, isActive: product.isActive, contractId: product.contractId, diff --git a/backend/src/modules/savings/savings.service.ts b/backend/src/modules/savings/savings.service.ts index f61d754f1..d8f18f67b 100644 --- a/backend/src/modules/savings/savings.service.ts +++ b/backend/src/modules/savings/savings.service.ts @@ -239,6 +239,7 @@ export class SavingsService { tenureMonths: product.tenureMonths, contractId: product.contractId, isActive: product.isActive, + maxSubscriptionsPerUser: product.maxSubscriptionsPerUser, version: product.version ?? 1, riskLevel: product.riskLevel || RiskLevel.LOW, tvlAmount, @@ -382,6 +383,7 @@ export class SavingsService { userId: string, productId: string, amount: number, + overrideLimits = false, ): Promise { const product = await this.findOneProduct(productId); await this.syncCapacityState(product); @@ -399,6 +401,44 @@ 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 capacity = await this.getProductCapacitySnapshot(productId); if ( capacity.maxCapacity != null &&