From 82c681b20ff3ded58959bbd201e0270f024bc966 Mon Sep 17 00:00:00 2001 From: Asher <141028690+No-bodyq@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:28:15 +0100 Subject: [PATCH] feat: enhance savings product with tvlAmount and riskLevel fields, update related DTOs and service logic --- ...nhanceSavingsProductWithTvlAndRiskLevel.ts | 73 +++++++++++++++++++ .../modules/savings/dto/create-product.dto.ts | 29 +++++++- .../savings/dto/product-details.dto.ts | 6 +- .../savings/dto/savings-product.dto.ts | 5 +- .../entities/savings-product.entity.ts | 13 +++- .../savings.controller.enhanced.spec.ts | 7 +- .../src/modules/savings/savings.controller.ts | 4 +- .../src/modules/savings/savings.service.ts | 4 +- 8 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 backend/src/migrations/1775200000000-EnhanceSavingsProductWithTvlAndRiskLevel.ts diff --git a/backend/src/migrations/1775200000000-EnhanceSavingsProductWithTvlAndRiskLevel.ts b/backend/src/migrations/1775200000000-EnhanceSavingsProductWithTvlAndRiskLevel.ts new file mode 100644 index 000000000..721e7658c --- /dev/null +++ b/backend/src/migrations/1775200000000-EnhanceSavingsProductWithTvlAndRiskLevel.ts @@ -0,0 +1,73 @@ +import { + MigrationInterface, + QueryRunner, + TableColumn, + TableColumnOptions, +} from 'typeorm'; + +export class EnhanceSavingsProductWithTvlAndRiskLevel1775200000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + // Add tvlAmount column if it doesn't exist + const table = await queryRunner.getTable('savings_products'); + + if (table && !table.findColumnByName('tvlAmount')) { + await queryRunner.addColumn( + 'savings_products', + new TableColumn({ + name: 'tvlAmount', + type: 'decimal', + precision: 14, + scale: 2, + default: 0, + isNullable: false, + }), + ); + } + + // Update riskLevel column from varchar to enum + if (table && table.findColumnByName('riskLevel')) { + const riskLevelColumn = table.findColumnByName('riskLevel'); + + if (riskLevelColumn && riskLevelColumn.type !== 'enum') { + // Drop the old constraint and column with old data + await queryRunner.changeColumn( + 'savings_products', + 'riskLevel', + new TableColumn({ + name: 'riskLevel', + type: 'enum', + enum: ['LOW', 'MEDIUM', 'HIGH'], + default: "'LOW'", + isNullable: false, + }), + ); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('savings_products'); + + // Remove tvlAmount column + if (table && table.findColumnByName('tvlAmount')) { + await queryRunner.dropColumn('savings_products', 'tvlAmount'); + } + + // Revert riskLevel column back to varchar + if (table && table.findColumnByName('riskLevel')) { + await queryRunner.changeColumn( + 'savings_products', + 'riskLevel', + new TableColumn({ + name: 'riskLevel', + type: 'varchar', + length: '20', + default: "'Low'", + isNullable: false, + }), + ); + } + } +} diff --git a/backend/src/modules/savings/dto/create-product.dto.ts b/backend/src/modules/savings/dto/create-product.dto.ts index 5a65ed608..1c084030d 100644 --- a/backend/src/modules/savings/dto/create-product.dto.ts +++ b/backend/src/modules/savings/dto/create-product.dto.ts @@ -10,7 +10,7 @@ import { MaxLength, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { SavingsProductType } from '../entities/savings-product.entity'; +import { SavingsProductType, RiskLevel } from '../entities/savings-product.entity'; export class CreateProductDto { @ApiProperty({ example: 'Fixed 12-Month Plan', description: 'Product name' }) @@ -56,6 +56,33 @@ export class CreateProductDto { @Max(360) tenureMonths?: number; + @ApiPropertyOptional({ + example: 'contract1234567890abcdefghijklmnopqrstuvwxyz', + description: 'Soroban contract ID for testnet/mainnet', + }) + @IsOptional() + @IsString() + @MaxLength(56) + contractId?: string; + + @ApiPropertyOptional({ + description: 'Total Value Locked amount', + default: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + tvlAmount?: number; + + @ApiPropertyOptional({ + enum: RiskLevel, + default: RiskLevel.LOW, + description: 'Risk level classification', + }) + @IsOptional() + @IsEnum(RiskLevel) + riskLevel?: RiskLevel; + @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 5ab80d708..dd798a4eb 100644 --- a/backend/src/modules/savings/dto/product-details.dto.ts +++ b/backend/src/modules/savings/dto/product-details.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { SavingsProductType } from '../entities/savings-product.entity'; +import { SavingsProductType, RiskLevel } from '../entities/savings-product.entity'; /** * Detailed product response combining static DB attributes with live Soroban contract data @@ -46,8 +46,8 @@ export class ProductDetailsDto { @ApiProperty({ description: 'Product creation timestamp' }) createdAt: Date; - @ApiProperty({ description: 'Risk level classification' }) - riskLevel: string; + @ApiProperty({ description: 'Risk level classification', enum: RiskLevel }) + riskLevel: RiskLevel; @ApiProperty({ description: 'Product last update timestamp' }) updatedAt: Date; diff --git a/backend/src/modules/savings/dto/savings-product.dto.ts b/backend/src/modules/savings/dto/savings-product.dto.ts index bdb068a0b..791969f18 100644 --- a/backend/src/modules/savings/dto/savings-product.dto.ts +++ b/backend/src/modules/savings/dto/savings-product.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { SavingsProductType } from '../entities/savings-product.entity'; +import { SavingsProductType, RiskLevel } from '../entities/savings-product.entity'; export class SavingsProductDto { @ApiProperty({ description: 'Product UUID' }) @@ -34,8 +34,9 @@ export class SavingsProductDto { @ApiProperty({ description: 'Risk level classification (e.g. Low, Medium, High)', + enum: RiskLevel, }) - riskLevel: string; + riskLevel: RiskLevel; @ApiProperty({ description: 'Total Value Locked (aggregated local balance)' }) tvlAmount: number; diff --git a/backend/src/modules/savings/entities/savings-product.entity.ts b/backend/src/modules/savings/entities/savings-product.entity.ts index 17e36a84f..0aaef71b9 100644 --- a/backend/src/modules/savings/entities/savings-product.entity.ts +++ b/backend/src/modules/savings/entities/savings-product.entity.ts @@ -13,6 +13,12 @@ export enum SavingsProductType { FLEXIBLE = 'FLEXIBLE', } +export enum RiskLevel { + LOW = 'LOW', + MEDIUM = 'MEDIUM', + HIGH = 'HIGH', +} + @Entity('savings_products') export class SavingsProduct { @PrimaryGeneratedColumn('uuid') @@ -42,11 +48,14 @@ export class SavingsProduct { @Column({ type: 'varchar', length: 56, nullable: true }) contractId: string | null; + @Column('decimal', { precision: 14, scale: 2, default: 0 }) + tvlAmount: number; + @Column({ default: true }) isActive: boolean; - @Column({ type: 'varchar', length: 20, default: 'Low' }) - riskLevel: string; + @Column({ type: 'enum', enum: RiskLevel, default: RiskLevel.LOW }) + riskLevel: RiskLevel; @CreateDateColumn() createdAt: Date; diff --git a/backend/src/modules/savings/savings.controller.enhanced.spec.ts b/backend/src/modules/savings/savings.controller.enhanced.spec.ts index 1ec1f2f7f..bd953075a 100644 --- a/backend/src/modules/savings/savings.controller.enhanced.spec.ts +++ b/backend/src/modules/savings/savings.controller.enhanced.spec.ts @@ -7,6 +7,7 @@ import { SavingsService } from './savings.service'; import { SavingsProduct, SavingsProductType, + RiskLevel, } from './entities/savings-product.entity'; import { UserSubscription, @@ -33,7 +34,7 @@ describe('SavingsController (Enhanced)', () => { { amount: 100, status: SubscriptionStatus.ACTIVE }, { amount: 50, status: SubscriptionStatus.ACTIVE }, ], - riskLevel: 'Low', + riskLevel: RiskLevel.LOW, }, { id: 'p2', @@ -41,7 +42,7 @@ describe('SavingsController (Enhanced)', () => { interestRate: 15, createdAt: new Date('2026-01-02'), subscriptions: [{ amount: 30, status: SubscriptionStatus.ACTIVE }], - riskLevel: 'Medium', + riskLevel: RiskLevel.MEDIUM, }, ]; @@ -93,6 +94,6 @@ describe('SavingsController (Enhanced)', () => { it('should include riskLevel in the response', async () => { const result = await controller.getProducts(); expect(result[0].riskLevel).toBeDefined(); - expect(['Low', 'Medium', 'High']).toContain(result[0].riskLevel); + expect(Object.values(RiskLevel)).toContain(result[0].riskLevel); }); }); diff --git a/backend/src/modules/savings/savings.controller.ts b/backend/src/modules/savings/savings.controller.ts index 20fad539a..3511641dd 100644 --- a/backend/src/modules/savings/savings.controller.ts +++ b/backend/src/modules/savings/savings.controller.ts @@ -24,7 +24,7 @@ import { } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { SavingsService } from './savings.service'; -import { SavingsProduct } from './entities/savings-product.entity'; +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'; @@ -109,7 +109,7 @@ export class SavingsController { contractId: product.contractId, totalAssets, totalAssetsXlm, - riskLevel: (product as any).riskLevel || 'Low', + 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 3a04d7c5c..cc4f479e8 100644 --- a/backend/src/modules/savings/savings.service.ts +++ b/backend/src/modules/savings/savings.service.ts @@ -10,7 +10,7 @@ import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Cache } from 'cache-manager'; import { Repository } from 'typeorm'; -import { SavingsProduct } from './entities/savings-product.entity'; +import { SavingsProduct, RiskLevel } from './entities/savings-product.entity'; import { UserSubscription, SubscriptionStatus, @@ -122,7 +122,7 @@ export class SavingsService { tenureMonths: product.tenureMonths, contractId: product.contractId, isActive: product.isActive, - riskLevel: (product as any).riskLevel || 'Low', + riskLevel: product.riskLevel || RiskLevel.LOW, tvlAmount, createdAt: product.createdAt, updatedAt: product.updatedAt,