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 1497a232a..36a1e8cd7 100644 --- a/backend/src/modules/admin/admin-savings.controller.ts +++ b/backend/src/modules/admin/admin-savings.controller.ts @@ -26,6 +26,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'; import { ProductCapacitySnapshot } from '../savings/savings.service'; import { PageOptionsDto } from '../../common/dto/page-options.dto'; import { CreateProductDto } from '../savings/dto/create-product.dto'; @@ -82,6 +83,30 @@ export class AdminSavingsController { return this.adminSavingsService.getSubscribers(id, opts); } + @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, + }; @Get('products/:id/capacity-metrics') @ApiOperation({ summary: 'Get live capacity utilization metrics (admin)' }) @ApiResponse({ diff --git a/backend/src/modules/savings/dto/create-product.dto.ts b/backend/src/modules/savings/dto/create-product.dto.ts index 24312248e..9a396f8c4 100644 --- a/backend/src/modules/savings/dto/create-product.dto.ts +++ b/backend/src/modules/savings/dto/create-product.dto.ts @@ -95,6 +95,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 160cce1d6..a0b604f06 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 de5d68db8..e3a73e9af 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 b10e4dfa0..da8620cdc 100644 --- a/backend/src/modules/savings/entities/savings-product.entity.ts +++ b/backend/src/modules/savings/entities/savings-product.entity.ts @@ -54,6 +54,14 @@ 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('decimal', { precision: 14, scale: 2, nullable: true }) maxCapacity: number | null; diff --git a/backend/src/modules/savings/savings.controller.ts b/backend/src/modules/savings/savings.controller.ts index 7d1f413e2..2ed9cdc9f 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, + 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 4c2a04936..f61d754f1 100644 --- a/backend/src/modules/savings/savings.service.ts +++ b/backend/src/modules/savings/savings.service.ts @@ -38,6 +38,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 { In, Repository } from 'typeorm'; +import { SavingsProductVersionAudit } from './entities/savings-product-version-audit.entity'; import { Repository } from 'typeorm'; import { WaitlistService } from './waitlist.service'; @@ -78,6 +80,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) @@ -98,9 +102,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; } @@ -122,6 +139,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), + }, + }); const previousIsActive = product.isActive; Object.assign(product, dto); const updatedProduct = await this.productRepository.save(product); @@ -162,6 +220,32 @@ 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, + version: product.version ?? 1, + 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 @@ -244,6 +328,56 @@ export class SavingsService { return { product, totalAssets, capacity }; } + 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, @@ -858,6 +992,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, + }), + ); private async syncCapacityState( product: SavingsProduct, ): Promise {