From 01413a14e1ed288c3bb1da2826e9acba395cf4bd Mon Sep 17 00:00:00 2001 From: Uche Solomon <100881766+Devsol-01@users.noreply.github.com> Date: Mon, 30 Mar 2026 07:30:39 +0100 Subject: [PATCH] feat: implement savings product versioning (closes #481) --- ...91100000000-AddSavingsProductVersioning.ts | 59 ++++++ .../modules/admin/admin-savings.controller.ts | 27 +++ .../modules/savings/dto/create-product.dto.ts | 10 + .../savings/dto/product-details.dto.ts | 3 + .../savings/dto/savings-product.dto.ts | 3 + .../savings-product-version-audit.entity.ts | 45 +++++ .../entities/savings-product.entity.ts | 9 + .../src/modules/savings/savings.controller.ts | 1 + .../src/modules/savings/savings.service.ts | 175 +++++++++++++++++- 9 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 backend/src/migrations/1791100000000-AddSavingsProductVersioning.ts create mode 100644 backend/src/modules/savings/entities/savings-product-version-audit.entity.ts diff --git a/backend/src/migrations/1791100000000-AddSavingsProductVersioning.ts b/backend/src/migrations/1791100000000-AddSavingsProductVersioning.ts new file mode 100644 index 000000000..4a5be34f9 --- /dev/null +++ b/backend/src/migrations/1791100000000-AddSavingsProductVersioning.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm'; + +export class AddSavingsProductVersioning1791100000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns('savings_products', [ + new TableColumn({ + name: 'version', + type: 'int', + default: 1, + }), + new TableColumn({ + name: 'versionGroupId', + type: 'uuid', + isNullable: true, + }), + new TableColumn({ + name: 'previousVersionId', + type: 'uuid', + isNullable: true, + }), + ]); + + await queryRunner.createTable( + new Table({ + name: 'savings_product_version_audits', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + isGenerated: true, + generationStrategy: 'uuid', + }, + { name: 'productId', type: 'uuid' }, + { name: 'versionGroupId', type: 'uuid' }, + { name: 'sourceProductId', type: 'uuid', isNullable: true }, + { name: 'targetProductId', type: 'uuid', isNullable: true }, + { name: 'actorId', type: 'uuid', isNullable: true }, + { name: 'action', type: 'varchar' }, + { name: 'metadata', type: 'jsonb', isNullable: true }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + ], + }), + ); + + await queryRunner.query(` + UPDATE savings_products + SET "versionGroupId" = id + WHERE "versionGroupId" IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('savings_product_version_audits'); + await queryRunner.dropColumn('savings_products', 'previousVersionId'); + await queryRunner.dropColumn('savings_products', 'versionGroupId'); + await queryRunner.dropColumn('savings_products', 'version'); + } +} diff --git a/backend/src/modules/admin/admin-savings.controller.ts b/backend/src/modules/admin/admin-savings.controller.ts index d0bab6bc4..950d3b320 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,30 @@ export class AdminSavingsController { ): Promise { return await this.savingsService.updateProduct(id, dto); } + + @Post('products/:id/migrations') + @ApiOperation({ + summary: 'Migrate active subscriptions to another product version (admin)', + }) + @ApiResponse({ + status: 200, + description: 'Subscriptions migrated to the target product version', + }) + async migrateProductSubscriptions( + @Param('id') id: string, + @Body() body: { targetProductId: string; subscriptionIds?: string[] }, + @CurrentUser() user: { id: string; email: string }, + ): Promise<{ migratedCount: number; targetProductId: string }> { + const result = await this.savingsService.migrateSubscriptionsToVersion( + id, + body.targetProductId, + user.id, + body.subscriptionIds, + ); + + return { + migratedCount: result.migratedCount, + targetProductId: result.targetProduct.id, + }; + } } diff --git a/backend/src/modules/savings/dto/create-product.dto.ts b/backend/src/modules/savings/dto/create-product.dto.ts index 52a8c6486..ef0a87cb4 100644 --- a/backend/src/modules/savings/dto/create-product.dto.ts +++ b/backend/src/modules/savings/dto/create-product.dto.ts @@ -86,6 +86,16 @@ export class CreateProductDto { @IsEnum(RiskLevel) riskLevel?: RiskLevel; + @ApiPropertyOptional({ + example: 1, + description: 'Initial product version', + default: 1, + }) + @IsOptional() + @IsNumber() + @Min(1) + version?: 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..dbfd32a81 100644 --- a/backend/src/modules/savings/dto/product-details.dto.ts +++ b/backend/src/modules/savings/dto/product-details.dto.ts @@ -35,6 +35,9 @@ export class ProductDetailsDto { @ApiProperty({ description: 'Whether product is active' }) isActive: boolean; + @ApiProperty({ description: 'Current product version' }) + version: number; + @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..720871565 100644 --- a/backend/src/modules/savings/dto/savings-product.dto.ts +++ b/backend/src/modules/savings/dto/savings-product.dto.ts @@ -35,6 +35,9 @@ export class SavingsProductDto { @ApiProperty({ description: 'Whether product is active' }) isActive: boolean; + @ApiProperty({ description: 'Current product version' }) + version: number; + @ApiProperty({ description: 'Risk level classification (e.g. Low, Medium, High)', enum: RiskLevel, diff --git a/backend/src/modules/savings/entities/savings-product-version-audit.entity.ts b/backend/src/modules/savings/entities/savings-product-version-audit.entity.ts new file mode 100644 index 000000000..f630ab6c1 --- /dev/null +++ b/backend/src/modules/savings/entities/savings-product-version-audit.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type SavingsProductVersionAuditAction = + | 'CREATED' + | 'UPDATED' + | 'VERSION_CREATED' + | 'SUBSCRIPTIONS_MIGRATED'; + +@Entity('savings_product_version_audits') +@Index(['productId', 'createdAt']) +@Index(['versionGroupId', 'createdAt']) +export class SavingsProductVersionAudit { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + productId: string; + + @Column('uuid') + versionGroupId: string; + + @Column({ type: 'uuid', nullable: true }) + sourceProductId: string | null; + + @Column({ type: 'uuid', nullable: true }) + targetProductId: string | null; + + @Column({ type: 'uuid', nullable: true }) + actorId: string | null; + + @Column({ type: 'varchar' }) + action: SavingsProductVersionAuditAction; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + @CreateDateColumn() + 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..cef549ab8 100644 --- a/backend/src/modules/savings/entities/savings-product.entity.ts +++ b/backend/src/modules/savings/entities/savings-product.entity.ts @@ -54,6 +54,15 @@ export class SavingsProduct { @Column('int', { nullable: true }) capacity: number | null; + @Column({ type: 'int', default: 1 }) + version: number; + + @Column({ type: 'uuid', nullable: true }) + versionGroupId: string | null; + + @Column({ type: 'uuid', nullable: true }) + previousVersionId: string | 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..87fb31e7f 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, + version: product.version ?? 1, 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..68b86f8a7 100644 --- a/backend/src/modules/savings/savings.service.ts +++ b/backend/src/modules/savings/savings.service.ts @@ -37,7 +37,8 @@ import { PredictiveEvaluatorService } from './services/predictive-evaluator.serv import { EventEmitter2 } from '@nestjs/event-emitter'; import { Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; +import { SavingsProductVersionAudit } from './entities/savings-product-version-audit.entity'; export type SavingsGoalProgress = GoalProgressDto; @@ -66,6 +67,8 @@ export class SavingsService { private readonly goalRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, + @InjectRepository(SavingsProductVersionAudit) + private readonly productVersionAuditRepository: Repository, @InjectRepository(WithdrawalRequest) private readonly withdrawalRepository: Repository, @InjectRepository(Transaction) @@ -85,9 +88,22 @@ export class SavingsService { } const product = this.productRepository.create({ ...dto, + version: dto.version ?? 1, isActive: dto.isActive ?? true, }); - const savedProduct = await this.productRepository.save(product); + let savedProduct = await this.productRepository.save(product); + + if (!savedProduct.versionGroupId) { + savedProduct.versionGroupId = savedProduct.id; + savedProduct = await this.productRepository.save(savedProduct); + } + + await this.recordVersionAudit(savedProduct, { + action: 'CREATED', + sourceProductId: null, + targetProductId: savedProduct.id, + metadata: { version: savedProduct.version }, + }); await this.invalidatePoolsCache(); return savedProduct; } @@ -109,8 +125,47 @@ export class SavingsService { 'minAmount must be less than or equal to maxAmount', ); } + if (this.requiresNewVersion(product, dto)) { + const versionGroupId = product.versionGroupId ?? product.id; + product.isActive = false; + await this.productRepository.save(product); + + const versionedProduct = this.productRepository.create({ + ...product, + ...dto, + id: undefined, + createdAt: undefined, + updatedAt: undefined, + subscriptions: undefined, + version: (product.version ?? 1) + 1, + versionGroupId, + previousVersionId: product.id, + isActive: dto.isActive ?? true, + }); + const savedVersion = await this.productRepository.save(versionedProduct); + await this.recordVersionAudit(savedVersion, { + action: 'VERSION_CREATED', + sourceProductId: product.id, + targetProductId: savedVersion.id, + metadata: { + version: savedVersion.version, + changedFields: this.getChangedFields(product, dto), + }, + }); + await this.invalidatePoolsCache(); + return savedVersion; + } + Object.assign(product, dto); const updatedProduct = await this.productRepository.save(product); + await this.recordVersionAudit(updatedProduct, { + action: 'UPDATED', + sourceProductId: product.id, + targetProductId: updatedProduct.id, + metadata: { + changedFields: this.getChangedFields(product, dto), + }, + }); await this.invalidatePoolsCache(); // Emit waitlist availability event when product becomes available or capacity opens @@ -178,6 +233,7 @@ export class SavingsService { tenureMonths: product.tenureMonths, contractId: product.contractId, isActive: product.isActive, + version: product.version ?? 1, riskLevel: product.riskLevel || RiskLevel.LOW, tvlAmount, createdAt: product.createdAt, @@ -231,6 +287,56 @@ export class SavingsService { return { product, totalAssets }; } + async migrateSubscriptionsToVersion( + sourceProductId: string, + targetProductId: string, + actorId?: string, + subscriptionIds?: string[], + ): Promise<{ migratedCount: number; targetProduct: SavingsProduct }> { + const [sourceProduct, targetProduct] = await Promise.all([ + this.findOneProduct(sourceProductId), + this.findOneProduct(targetProductId), + ]); + + const sourceGroupId = sourceProduct.versionGroupId ?? sourceProduct.id; + const targetGroupId = targetProduct.versionGroupId ?? targetProduct.id; + if (sourceGroupId !== targetGroupId) { + throw new BadRequestException( + 'Subscriptions can only be migrated within the same product version group', + ); + } + + const where = { + productId: sourceProductId, + status: SubscriptionStatus.ACTIVE, + ...(subscriptionIds?.length ? { id: In(subscriptionIds) } : {}), + }; + const subscriptions = await this.subscriptionRepository.find({ where }); + + if (!subscriptions.length) { + return { migratedCount: 0, targetProduct }; + } + + for (const subscription of subscriptions) { + subscription.productId = targetProduct.id; + } + await this.subscriptionRepository.save(subscriptions); + + await this.recordVersionAudit(targetProduct, { + action: 'SUBSCRIPTIONS_MIGRATED', + actorId: actorId ?? null, + sourceProductId, + targetProductId, + metadata: { + migratedCount: subscriptions.length, + subscriptionIds: subscriptions.map((subscription) => subscription.id), + }, + }); + + await this.invalidatePoolsCache(); + return { migratedCount: subscriptions.length, targetProduct }; + } + async subscribe( userId: string, productId: string, @@ -765,4 +871,69 @@ export class SavingsService { return totalYield / activeSubscriptions.length; } + + private requiresNewVersion( + product: SavingsProduct, + dto: UpdateProductDto, + ): boolean { + const versionedFields: Array = [ + 'interestRate', + 'minAmount', + 'maxAmount', + 'tenureMonths', + 'description', + 'type', + ]; + + return versionedFields.some((field) => { + const nextValue = dto[field]; + return ( + nextValue !== undefined && + nextValue !== product[field as keyof SavingsProduct] + ); + }); + } + + private getChangedFields( + product: SavingsProduct, + dto: UpdateProductDto, + ): Record { + const productRecord = product as unknown as Record; + + return Object.entries(dto).reduce( + (changes, [key, value]) => { + if (value !== undefined && value !== productRecord[key]) { + changes[key] = { + from: productRecord[key], + to: value, + }; + } + return changes; + }, + {} as Record, + ); + } + + private async recordVersionAudit( + product: SavingsProduct, + options: { + action: SavingsProductVersionAudit['action']; + actorId?: string | null; + sourceProductId: string | null; + targetProductId: string | null; + metadata?: Record; + }, + ): Promise { + await this.productVersionAuditRepository.save( + this.productVersionAuditRepository.create({ + productId: product.id, + versionGroupId: product.versionGroupId ?? product.id, + sourceProductId: options.sourceProductId, + targetProductId: options.targetProductId, + actorId: options.actorId ?? null, + action: options.action, + metadata: options.metadata ?? null, + }), + ); + } }