From d6fae0e908f47163f58b404d22a153fc7a6c912c Mon Sep 17 00:00:00 2001 From: KingFRANKHOOD Date: Sun, 29 Mar 2026 22:58:32 +0100 Subject: [PATCH] feat(backend): add KYC automation, challenges, rebalancing, and product alerts --- backend/src/app.module.ts | 11 + backend/src/auth/auth.controller.ts | 5 +- backend/src/auth/two-factor.service.ts | 13 +- backend/src/common/common.module.ts | 5 +- .../common/guards/tiered-throttler.guard.ts | 33 +-- .../common/services/pii-encryption.service.ts | 81 ++++++ backend/src/config/configuration.ts | 5 + backend/src/modules/admin/admin.controller.ts | 7 +- .../src/modules/alerts/alerts.controller.ts | 70 +++++ backend/src/modules/alerts/alerts.module.ts | 27 ++ backend/src/modules/alerts/alerts.service.ts | 247 +++++++++++++++++ .../modules/alerts/dto/create-alert.dto.ts | 17 ++ .../modules/alerts/dto/snooze-alert.dto.ts | 9 + .../alerts/entities/alert-history.entity.ts | 32 +++ .../alerts/entities/product-alert.entity.ts | 41 +++ .../modules/analytics/analytics.controller.ts | 37 +++ .../src/modules/analytics/analytics.module.ts | 12 +- .../modules/analytics/analytics.service.ts | 236 +++++++++++++++- .../analytics/dto/execute-rebalancing.dto.ts | 12 + .../analytics/dto/rebalancing-query.dto.ts | 12 + .../entities/rebalancing-execution.entity.ts | 32 +++ .../challenges/challenges.controller.ts | 67 +++++ .../modules/challenges/challenges.module.ts | 27 ++ .../modules/challenges/challenges.service.ts | 257 ++++++++++++++++++ .../challenges/dto/create-challenge.dto.ts | 36 +++ .../entities/challenge-achievement.entity.ts | 29 ++ .../entities/challenge-participant.entity.ts | 37 +++ .../entities/savings-challenge.entity.ts | 42 +++ .../src/modules/kyc/dto/initiate-kyc.dto.ts | 23 ++ .../src/modules/kyc/dto/kyc-webhook.dto.ts | 18 ++ .../entities/kyc-compliance-report.entity.ts | 33 +++ .../kyc/entities/kyc-verification.entity.ts | 66 +++++ backend/src/modules/kyc/kyc.controller.ts | 70 +++++ backend/src/modules/kyc/kyc.module.ts | 17 ++ backend/src/modules/kyc/kyc.service.ts | 246 +++++++++++++++++ backend/src/modules/mail/mail.service.ts | 24 ++ .../entities/notification.entity.ts | 3 + .../src/modules/savings/savings.controller.ts | 5 +- .../src/modules/savings/savings.service.ts | 11 +- .../src/modules/user/entities/user.entity.ts | 3 + backend/tsconfig.json | 1 - 41 files changed, 1917 insertions(+), 42 deletions(-) create mode 100644 backend/src/common/services/pii-encryption.service.ts create mode 100644 backend/src/modules/alerts/alerts.controller.ts create mode 100644 backend/src/modules/alerts/alerts.module.ts create mode 100644 backend/src/modules/alerts/alerts.service.ts create mode 100644 backend/src/modules/alerts/dto/create-alert.dto.ts create mode 100644 backend/src/modules/alerts/dto/snooze-alert.dto.ts create mode 100644 backend/src/modules/alerts/entities/alert-history.entity.ts create mode 100644 backend/src/modules/alerts/entities/product-alert.entity.ts create mode 100644 backend/src/modules/analytics/dto/execute-rebalancing.dto.ts create mode 100644 backend/src/modules/analytics/dto/rebalancing-query.dto.ts create mode 100644 backend/src/modules/analytics/entities/rebalancing-execution.entity.ts create mode 100644 backend/src/modules/challenges/challenges.controller.ts create mode 100644 backend/src/modules/challenges/challenges.module.ts create mode 100644 backend/src/modules/challenges/challenges.service.ts create mode 100644 backend/src/modules/challenges/dto/create-challenge.dto.ts create mode 100644 backend/src/modules/challenges/entities/challenge-achievement.entity.ts create mode 100644 backend/src/modules/challenges/entities/challenge-participant.entity.ts create mode 100644 backend/src/modules/challenges/entities/savings-challenge.entity.ts create mode 100644 backend/src/modules/kyc/dto/initiate-kyc.dto.ts create mode 100644 backend/src/modules/kyc/dto/kyc-webhook.dto.ts create mode 100644 backend/src/modules/kyc/entities/kyc-compliance-report.entity.ts create mode 100644 backend/src/modules/kyc/entities/kyc-verification.entity.ts create mode 100644 backend/src/modules/kyc/kyc.controller.ts create mode 100644 backend/src/modules/kyc/kyc.module.ts create mode 100644 backend/src/modules/kyc/kyc.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a1e206a52..96e7d3862 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; import { ThrottlerModule } from '@nestjs/throttler'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { CorrelationIdInterceptor } from './common/interceptors/correlation-id.interceptor'; @@ -17,6 +18,9 @@ import { AuthModule } from './auth/auth.module'; import { HealthModule } from './modules/health/health.module'; import { BlockchainModule } from './modules/blockchain/blockchain.module'; import { UserModule } from './modules/user/user.module'; +import { KycModule } from './modules/kyc/kyc.module'; +import { ChallengesModule } from './modules/challenges/challenges.module'; +import { AlertsModule } from './modules/alerts/alerts.module'; import { AdminModule } from './modules/admin/admin.module'; import { MailModule } from './modules/mail/mail.module'; import { RedisCacheModule } from './modules/cache/cache.module'; @@ -66,6 +70,9 @@ const envValidationSchema = Joi.object({ MAIL_USER: Joi.string().optional(), MAIL_PASS: Joi.string().optional(), MAIL_FROM: Joi.string().optional(), + KYC_PROVIDER_BASE_URL: Joi.string().uri().optional(), + KYC_PROVIDER_API_KEY: Joi.string().optional(), + KYC_PII_ENCRYPTION_KEY: Joi.string().min(16).optional(), }); @Module({ @@ -114,6 +121,7 @@ const envValidationSchema = Joi.object({ }, }), EventEmitterModule.forRoot(), + ScheduleModule.forRoot(), TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (configService: ConfigService) => { @@ -154,6 +162,9 @@ const envValidationSchema = Joi.object({ HealthModule, BlockchainModule, UserModule, + KycModule, + ChallengesModule, + AlertsModule, AdminModule, MailModule, WebhooksModule, diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 7dd1036b9..f729b194c 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -121,7 +121,10 @@ export class AuthController { 'Returns a TOTP secret, otpauth:// URL for QR code generation, and backup codes. ' + 'Call POST /auth/2fa/verify with a valid token to activate.', }) - @ApiResponse({ status: 201, description: 'Secret and backup codes generated' }) + @ApiResponse({ + status: 201, + description: 'Secret and backup codes generated', + }) @ApiResponse({ status: 400, description: '2FA already enabled' }) enable2fa(@Request() req: { user: { id: string } }) { return this.twoFactorService.enable(req.user.id); diff --git a/backend/src/auth/two-factor.service.ts b/backend/src/auth/two-factor.service.ts index 8fe329d00..d0718f0de 100644 --- a/backend/src/auth/two-factor.service.ts +++ b/backend/src/auth/two-factor.service.ts @@ -82,10 +82,7 @@ export class TwoFactorService { return { enabled: true, message: '2FA has been enabled successfully' }; } - async validateLogin( - userId: string, - token: string, - ): Promise { + async validateLogin(userId: string, token: string): Promise { const user = await this.findUser(userId); if (!user.twoFactorEnabled || !user.twoFactorSecret) { @@ -129,9 +126,7 @@ export class TwoFactorService { return { message: '2FA has been disabled' }; } - async adminDisable( - targetUserId: string, - ): Promise<{ message: string }> { + async adminDisable(targetUserId: string): Promise<{ message: string }> { const user = await this.findUser(targetUserId); if (!user.twoFactorEnabled) { @@ -154,9 +149,7 @@ export class TwoFactorService { return { enabled: user.twoFactorEnabled }; } - async completeLogin( - userId: string, - ): Promise<{ accessToken: string }> { + async completeLogin(userId: string): Promise<{ accessToken: string }> { const user = await this.findUser(userId); return { accessToken: this.jwtService.sign({ diff --git a/backend/src/common/common.module.ts b/backend/src/common/common.module.ts index 679958000..1f59877a4 100644 --- a/backend/src/common/common.module.ts +++ b/backend/src/common/common.module.ts @@ -1,9 +1,10 @@ import { Global, Module } from '@nestjs/common'; +import { PiiEncryptionService } from './services/pii-encryption.service'; import { RateLimitMonitorService } from './services/rate-limit-monitor.service'; @Global() @Module({ - providers: [RateLimitMonitorService], - exports: [RateLimitMonitorService], + providers: [RateLimitMonitorService, PiiEncryptionService], + exports: [RateLimitMonitorService, PiiEncryptionService], }) export class CommonModule {} diff --git a/backend/src/common/guards/tiered-throttler.guard.ts b/backend/src/common/guards/tiered-throttler.guard.ts index d715c792b..2b8edf36c 100644 --- a/backend/src/common/guards/tiered-throttler.guard.ts +++ b/backend/src/common/guards/tiered-throttler.guard.ts @@ -114,21 +114,19 @@ export class TieredThrottlerGuard extends ThrottlerGuard { return `tiered-throttle:${ip}`; } - protected async handleRequest( - requestProps: { - context: ExecutionContext; - limit: number; - ttl: number; - throttler: { name: string; limit: number; ttl: number }; - blockDuration: number; - getTracker: (req: Record) => Promise; - generateKey: ( - context: ExecutionContext, - tracker: string, - throttlerName: string, - ) => string; - }, - ): Promise { + protected async handleRequest(requestProps: { + context: ExecutionContext; + limit: number; + ttl: number; + throttler: { name: string; limit: number; ttl: number }; + blockDuration: number; + getTracker: (req: Record) => Promise; + generateKey: ( + context: ExecutionContext, + tracker: string, + throttlerName: string, + ) => string; + }): Promise { const { context, throttler } = requestProps; const request = context.switchToHttp().getRequest(); const response = context.switchToHttp().getResponse(); @@ -172,10 +170,7 @@ export class TieredThrottlerGuard extends ThrottlerGuard { timestamp: new Date(), }); - response.setHeader( - 'Retry-After', - Math.ceil(tierLimits.ttl / 1000), - ); + response.setHeader('Retry-After', Math.ceil(tierLimits.ttl / 1000)); response.setHeader('X-RateLimit-Remaining', 0); response.setHeader( 'X-RateLimit-Reset', diff --git a/backend/src/common/services/pii-encryption.service.ts b/backend/src/common/services/pii-encryption.service.ts new file mode 100644 index 000000000..e100f02c3 --- /dev/null +++ b/backend/src/common/services/pii-encryption.service.ts @@ -0,0 +1,81 @@ +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from 'crypto'; + +@Injectable() +export class PiiEncryptionService { + private readonly algorithm = 'aes-256-gcm'; + private readonly key: Buffer; + + constructor(private readonly configService: ConfigService) { + const secret = + this.configService.get('KYC_PII_ENCRYPTION_KEY') || + this.configService.get('jwt.secret') || + 'nestera-dev-fallback-key'; + + this.key = createHash('sha256').update(secret).digest(); + } + + encrypt(value: unknown): string { + try { + const iv = randomBytes(12); + const cipher = createCipheriv(this.algorithm, this.key, iv); + const plaintext = + typeof value === 'string' ? value : JSON.stringify(value ?? {}); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + return [ + iv.toString('base64'), + tag.toString('base64'), + encrypted.toString('base64'), + ].join('.'); + } catch (error) { + throw new InternalServerErrorException( + 'Failed to encrypt sensitive data', + ); + } + } + + decrypt>(payload?: string | null): T | null { + if (!payload) { + return null; + } + + try { + const [ivB64, tagB64, dataB64] = payload.split('.'); + if (!ivB64 || !tagB64 || !dataB64) { + return null; + } + + const decipher = createDecipheriv( + this.algorithm, + this.key, + Buffer.from(ivB64, 'base64'), + ); + decipher.setAuthTag(Buffer.from(tagB64, 'base64')); + + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(dataB64, 'base64')), + decipher.final(), + ]).toString('utf8'); + + try { + return JSON.parse(decrypted) as T; + } catch { + return decrypted as T; + } + } catch { + return null; + } + } +} diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index f4b573c89..d8736cc30 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -49,6 +49,11 @@ export default () => ({ pass: process.env.MAIL_PASS, from: process.env.MAIL_FROM || '"Nestera" ', }, + kyc: { + providerBaseUrl: process.env.KYC_PROVIDER_BASE_URL, + providerApiKey: process.env.KYC_PROVIDER_API_KEY, + piiEncryptionKey: process.env.KYC_PII_ENCRYPTION_KEY, + }, hospital: { endpoints: { // Hospital endpoints from environment variables diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index f836f2135..e40a7d48a 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -8,7 +8,12 @@ import { UseGuards, BadRequestException, } from '@nestjs/common'; -import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiResponse, +} from '@nestjs/swagger'; import { UserService } from '../user/user.service'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../common/guards/roles.guard'; diff --git a/backend/src/modules/alerts/alerts.controller.ts b/backend/src/modules/alerts/alerts.controller.ts new file mode 100644 index 000000000..fe621fefa --- /dev/null +++ b/backend/src/modules/alerts/alerts.controller.ts @@ -0,0 +1,70 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { CreateAlertDto } from './dto/create-alert.dto'; +import { SnoozeAlertDto } from './dto/snooze-alert.dto'; +import { AlertsService } from './alerts.service'; + +@ApiTags('alerts') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('alerts') +export class AlertsController { + constructor(private readonly alertsService: AlertsService) {} + + @Post('create') + @ApiOperation({ summary: 'Create product alert or watch' }) + @ApiResponse({ status: 201, description: 'Alert created' }) + create(@CurrentUser() user: { id: string }, @Body() dto: CreateAlertDto) { + return this.alertsService.createAlert(user.id, dto); + } + + @Get() + @ApiOperation({ summary: 'List active alerts for current user' }) + list(@CurrentUser() user: { id: string }) { + return this.alertsService.getUserAlerts(user.id); + } + + @Get('history') + @ApiOperation({ summary: 'Get alert history' }) + history(@CurrentUser() user: { id: string }) { + return this.alertsService.getAlertHistory(user.id); + } + + @Patch(':id/snooze') + @ApiOperation({ summary: 'Snooze an alert' }) + snooze( + @CurrentUser() user: { id: string }, + @Param('id') alertId: string, + @Body() dto: SnoozeAlertDto, + ) { + return this.alertsService.snoozeAlert(user.id, alertId, dto.hours); + } + + @Delete(':id') + @ApiOperation({ summary: 'Disable an alert' }) + disable(@CurrentUser() user: { id: string }, @Param('id') alertId: string) { + return this.alertsService.disableAlert(user.id, alertId); + } + + @Get('templates/common') + @ApiOperation({ summary: 'Common alert templates' }) + templates() { + return this.alertsService.alertTemplates(); + } +} diff --git a/backend/src/modules/alerts/alerts.module.ts b/backend/src/modules/alerts/alerts.module.ts new file mode 100644 index 000000000..5e1d31cb7 --- /dev/null +++ b/backend/src/modules/alerts/alerts.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MailModule } from '../mail/mail.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { SavingsProduct } from '../savings/entities/savings-product.entity'; +import { User } from '../user/entities/user.entity'; +import { AlertsController } from './alerts.controller'; +import { AlertsService } from './alerts.service'; +import { AlertHistory } from './entities/alert-history.entity'; +import { ProductAlert } from './entities/product-alert.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + ProductAlert, + AlertHistory, + SavingsProduct, + User, + ]), + NotificationsModule, + MailModule, + ], + controllers: [AlertsController], + providers: [AlertsService], + exports: [AlertsService], +}) +export class AlertsModule {} diff --git a/backend/src/modules/alerts/alerts.service.ts b/backend/src/modules/alerts/alerts.service.ts new file mode 100644 index 000000000..eaba97189 --- /dev/null +++ b/backend/src/modules/alerts/alerts.service.ts @@ -0,0 +1,247 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { MailService } from '../mail/mail.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationType } from '../notifications/entities/notification.entity'; +import { SavingsProduct } from '../savings/entities/savings-product.entity'; +import { User } from '../user/entities/user.entity'; +import { CreateAlertDto } from './dto/create-alert.dto'; +import { AlertHistory } from './entities/alert-history.entity'; +import { AlertType, ProductAlert } from './entities/product-alert.entity'; + +@Injectable() +export class AlertsService { + private readonly logger = new Logger(AlertsService.name); + + constructor( + @InjectRepository(ProductAlert) + private readonly alertRepository: Repository, + @InjectRepository(AlertHistory) + private readonly historyRepository: Repository, + @InjectRepository(SavingsProduct) + private readonly productRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly notificationsService: NotificationsService, + private readonly mailService: MailService, + ) {} + + async createAlert(userId: string, dto: CreateAlertDto) { + const payload = this.normalizeTemplate(dto); + + const alert = this.alertRepository.create({ + userId, + type: payload.type, + conditions: payload.conditions, + isActive: true, + snoozedUntil: null, + }); + + return this.alertRepository.save(alert); + } + + async getUserAlerts(userId: string) { + return this.alertRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } + + async getAlertHistory(userId: string) { + return this.historyRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + take: 200, + }); + } + + async snoozeAlert(userId: string, alertId: string, hours: number) { + const alert = await this.alertRepository.findOne({ + where: { id: alertId, userId }, + }); + + if (!alert) { + throw new NotFoundException('Alert not found'); + } + + const snoozedUntil = new Date(Date.now() + hours * 60 * 60 * 1000); + alert.snoozedUntil = snoozedUntil; + + return this.alertRepository.save(alert); + } + + async disableAlert(userId: string, alertId: string) { + const alert = await this.alertRepository.findOne({ + where: { id: alertId, userId }, + }); + + if (!alert) { + throw new NotFoundException('Alert not found'); + } + + alert.isActive = false; + return this.alertRepository.save(alert); + } + + alertTemplates() { + return [ + { + key: 'high-apy', + type: AlertType.APY_THRESHOLD, + conditions: { minApy: 8.5 }, + }, + { + key: 'new-low-risk', + type: AlertType.NEW_PRODUCT, + conditions: { riskLevel: 'LOW' }, + }, + ]; + } + + // Periodic condition evaluator for product alerts. + @Cron(CronExpression.EVERY_10_MINUTES) + async evaluateAlerts() { + const now = new Date(); + const activeAlerts = await this.alertRepository.find({ + where: { isActive: true }, + }); + + if (!activeAlerts.length) { + return; + } + + const products = await this.productRepository.find({ + where: { isActive: true }, + order: { createdAt: 'DESC' }, + take: 250, + }); + + for (const alert of activeAlerts) { + if (alert.snoozedUntil && alert.snoozedUntil > now) { + continue; + } + + const match = this.findMatchingProduct(alert, products); + if (!match) { + continue; + } + + const user = await this.userRepository.findOne({ + where: { id: alert.userId }, + }); + + const message = + alert.type === AlertType.APY_THRESHOLD + ? `A savings product reached your APY target: ${match.name} at ${match.interestRate}% APY.` + : `New savings product matched your watch: ${match.name}.`; + + await this.notificationsService.createNotification({ + userId: alert.userId, + type: NotificationType.PRODUCT_ALERT_TRIGGERED, + title: 'Savings product alert triggered', + message, + metadata: { + alertId: alert.id, + productId: match.id, + }, + }); + + if (user) { + await this.mailService.sendSavingsAlertEmail( + user.email, + user.name || 'User', + message, + ); + } + + await this.historyRepository.save( + this.historyRepository.create({ + alertId: alert.id, + userId: alert.userId, + channel: 'IN_APP', + message, + metadata: { + productId: match.id, + productName: match.name, + }, + }), + ); + + await this.historyRepository.save( + this.historyRepository.create({ + alertId: alert.id, + userId: alert.userId, + channel: 'EMAIL', + message, + metadata: { + productId: match.id, + productName: match.name, + }, + }), + ); + + await this.historyRepository.save( + this.historyRepository.create({ + alertId: alert.id, + userId: alert.userId, + channel: 'PUSH', + message, + metadata: { + productId: match.id, + productName: match.name, + simulated: true, + }, + }), + ); + + alert.snoozedUntil = new Date(Date.now() + 6 * 60 * 60 * 1000); + await this.alertRepository.save(alert); + } + + this.logger.log(`Evaluated ${activeAlerts.length} alerts`); + } + + private normalizeTemplate(dto: CreateAlertDto): CreateAlertDto { + if (dto.template === 'high-apy') { + return { + type: AlertType.APY_THRESHOLD, + conditions: { minApy: 8.5 }, + }; + } + + if (dto.template === 'new-low-risk') { + return { + type: AlertType.NEW_PRODUCT, + conditions: { riskLevel: 'LOW' }, + }; + } + + return dto; + } + + private findMatchingProduct(alert: ProductAlert, products: SavingsProduct[]) { + const conditions = alert.conditions || {}; + + if (alert.type === AlertType.APY_THRESHOLD) { + const minApy = Number(conditions['minApy'] || 0); + return products.find((product) => Number(product.interestRate) >= minApy); + } + + if (alert.type === AlertType.NEW_PRODUCT) { + const riskLevel = String(conditions['riskLevel'] || 'LOW'); + const launchedAfter = conditions['launchedAfter'] + ? new Date(String(conditions['launchedAfter'])) + : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + return products.find( + (product) => + String(product.riskLevel || 'LOW') === riskLevel && + new Date(product.createdAt) >= launchedAfter, + ); + } + + return undefined; + } +} diff --git a/backend/src/modules/alerts/dto/create-alert.dto.ts b/backend/src/modules/alerts/dto/create-alert.dto.ts new file mode 100644 index 000000000..daf712478 --- /dev/null +++ b/backend/src/modules/alerts/dto/create-alert.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsObject, IsOptional } from 'class-validator'; +import { AlertType } from '../entities/product-alert.entity'; + +export class CreateAlertDto { + @ApiProperty({ enum: AlertType }) + @IsEnum(AlertType) + type!: AlertType; + + @ApiProperty({ description: 'Alert conditions object' }) + @IsObject() + conditions!: Record; + + @ApiPropertyOptional({ description: 'Optional template key' }) + @IsOptional() + template?: string; +} diff --git a/backend/src/modules/alerts/dto/snooze-alert.dto.ts b/backend/src/modules/alerts/dto/snooze-alert.dto.ts new file mode 100644 index 000000000..5390a466b --- /dev/null +++ b/backend/src/modules/alerts/dto/snooze-alert.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, Min } from 'class-validator'; + +export class SnoozeAlertDto { + @ApiProperty({ minimum: 1, description: 'Number of hours to snooze alert' }) + @IsInt() + @Min(1) + hours!: number; +} diff --git a/backend/src/modules/alerts/entities/alert-history.entity.ts b/backend/src/modules/alerts/entities/alert-history.entity.ts new file mode 100644 index 000000000..5a8676088 --- /dev/null +++ b/backend/src/modules/alerts/entities/alert-history.entity.ts @@ -0,0 +1,32 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity('alert_history') +@Index(['alertId', 'createdAt']) +export class AlertHistory { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column('uuid') + alertId!: string; + + @Column('uuid') + userId!: string; + + @Column({ type: 'varchar' }) + channel!: 'IN_APP' | 'EMAIL' | 'PUSH'; + + @Column({ type: 'varchar' }) + message!: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata!: Record | null; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/backend/src/modules/alerts/entities/product-alert.entity.ts b/backend/src/modules/alerts/entities/product-alert.entity.ts new file mode 100644 index 000000000..5c201ac1f --- /dev/null +++ b/backend/src/modules/alerts/entities/product-alert.entity.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum AlertType { + APY_THRESHOLD = 'APY_THRESHOLD', + NEW_PRODUCT = 'NEW_PRODUCT', +} + +@Entity('product_alerts') +@Index(['userId', 'isActive']) +export class ProductAlert { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column('uuid') + userId!: string; + + @Column({ type: 'enum', enum: AlertType }) + type!: AlertType; + + @Column({ type: 'jsonb' }) + conditions!: Record; + + @Column({ type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ type: 'timestamp', nullable: true }) + snoozedUntil!: Date | null; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/backend/src/modules/analytics/analytics.controller.ts b/backend/src/modules/analytics/analytics.controller.ts index faa6661a5..3d2a55f1f 100644 --- a/backend/src/modules/analytics/analytics.controller.ts +++ b/backend/src/modules/analytics/analytics.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get, + Post, + Body, Query, UseGuards, NotFoundException, @@ -15,6 +17,8 @@ import { AnalyticsService } from './analytics.service'; import { PortfolioTimelineQueryDto } from './dto/portfolio-timeline-query.dto'; import { AssetAllocationDto } from './dto/asset-allocation.dto'; import { YieldBreakdownDto } from './dto/yield-breakdown.dto'; +import { RebalancingQueryDto } from './dto/rebalancing-query.dto'; +import { ExecuteRebalancingDto } from './dto/execute-rebalancing.dto'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; @@ -96,4 +100,37 @@ export class AnalyticsController { ): Promise { return this.analyticsService.getYieldBreakdown(user.id); } + + @Get('rebalancing-suggestions') + @ApiOperation({ + summary: 'Get risk-adjusted portfolio rebalancing suggestions', + }) + @ApiResponse({ + status: 200, + description: 'Rebalancing recommendation payload', + }) + async getRebalancingSuggestions( + @CurrentUser() user: { id: string }, + @Query() query: RebalancingQueryDto, + ) { + return this.analyticsService.getRebalancingSuggestions( + user.id, + query.riskProfile || 'balanced', + ); + } + + @Post('rebalancing-suggestions/execute') + @ApiOperation({ + summary: 'Execute one-click portfolio rebalancing', + }) + @ApiResponse({ status: 201, description: 'Rebalancing execution recorded' }) + async executeRebalancing( + @CurrentUser() user: { id: string }, + @Body() body: ExecuteRebalancingDto, + ) { + return this.analyticsService.executeRebalancing( + user.id, + body.riskProfile || 'balanced', + ); + } } diff --git a/backend/src/modules/analytics/analytics.module.ts b/backend/src/modules/analytics/analytics.module.ts index e6b26e1d1..b6ba11f4c 100644 --- a/backend/src/modules/analytics/analytics.module.ts +++ b/backend/src/modules/analytics/analytics.module.ts @@ -3,14 +3,24 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AnalyticsController } from './analytics.controller'; import { AnalyticsService } from './analytics.service'; import { BlockchainModule } from '../blockchain/blockchain.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { User } from '../user/entities/user.entity'; +import { UserSubscription } from '../savings/entities/user-subscription.entity'; import { ProcessedStellarEvent } from '../blockchain/entities/processed-event.entity'; import { LedgerTransaction } from '../blockchain/entities/transaction.entity'; +import { RebalancingExecution } from './entities/rebalancing-execution.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([User, ProcessedStellarEvent]), + TypeOrmModule.forFeature([ + User, + ProcessedStellarEvent, + LedgerTransaction, + UserSubscription, + RebalancingExecution, + ]), BlockchainModule, // Import to use OracleService for USD conversion + NotificationsModule, ], controllers: [AnalyticsController], providers: [AnalyticsService], diff --git a/backend/src/modules/analytics/analytics.service.ts b/backend/src/modules/analytics/analytics.service.ts index 6fcebac60..2d5f4f4fe 100644 --- a/backend/src/modules/analytics/analytics.service.ts +++ b/backend/src/modules/analytics/analytics.service.ts @@ -1,8 +1,14 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Between } from 'typeorm'; import { User } from '../user/entities/user.entity'; +import { + UserSubscription, + SubscriptionStatus, +} from '../savings/entities/user-subscription.entity'; import { ProcessedStellarEvent } from '../blockchain/entities/processed-event.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationType } from '../notifications/entities/notification.entity'; import { LedgerTransaction, LedgerTransactionType, @@ -16,6 +22,7 @@ import { AssetAllocationItemDto, } from './dto/asset-allocation.dto'; import { YieldBreakdownDto } from './dto/yield-breakdown.dto'; +import { RebalancingExecution } from './entities/rebalancing-execution.entity'; @Injectable() export class AnalyticsService { @@ -31,6 +38,14 @@ export class AnalyticsService { private readonly blockchainSavingsService: BlockchainSavingsService, private readonly stellarService: StellarService, private readonly oracleService: OracleService, + @Optional() + @InjectRepository(UserSubscription) + private readonly subscriptionRepository?: Repository, + @Optional() + @InjectRepository(RebalancingExecution) + private readonly rebalancingRepository?: Repository, + @Optional() + private readonly notificationsService?: NotificationsService, ) {} /** @@ -254,6 +269,135 @@ export class AnalyticsService { }; } + async getRebalancingSuggestions( + userId: string, + riskProfile: 'conservative' | 'balanced' | 'growth' = 'balanced', + ) { + if (!this.subscriptionRepository) { + return { + riskProfile, + totalValue: 0, + currentAllocation: [], + optimalAllocation: this.getOptimalAllocation(riskProfile), + recommendations: [], + backtest: this.buildBacktestSnapshot([], []), + }; + } + + const subscriptions = await this.subscriptionRepository.find({ + where: { + userId, + status: SubscriptionStatus.ACTIVE, + }, + relations: ['product'], + }); + + if (!subscriptions.length) { + return { + riskProfile, + totalValue: 0, + currentAllocation: [], + optimalAllocation: this.getOptimalAllocation(riskProfile), + recommendations: [], + backtest: this.buildBacktestSnapshot([], []), + }; + } + + const totalValue = subscriptions.reduce( + (sum, item) => sum + Number(item.amount), + 0, + ); + + const currentByRisk = this.aggregateCurrentRiskAllocation(subscriptions); + const optimal = this.getOptimalAllocation(riskProfile); + + const recommendations = optimal.map((target) => { + const currentPct = currentByRisk[target.risk] || 0; + const deltaPct = Number((target.targetPct - currentPct).toFixed(2)); + const deltaAmount = Number(((deltaPct / 100) * totalValue).toFixed(2)); + + return { + risk: target.risk, + currentPct, + targetPct: target.targetPct, + deltaPct, + amountToMove: deltaAmount, + action: + deltaAmount > 0 ? 'increase' : deltaAmount < 0 ? 'decrease' : 'hold', + }; + }); + + if ( + this.notificationsService && + recommendations.some((rec) => Math.abs(rec.deltaPct) >= 10) + ) { + await this.notificationsService.createNotification({ + userId, + type: NotificationType.REBALANCING_RECOMMENDED, + title: 'Portfolio rebalancing recommended', + message: + 'Your savings allocation has drifted from target risk mix. Review suggestions.', + metadata: { riskProfile, recommendations }, + }); + } + + return { + riskProfile, + totalValue: Number(totalValue.toFixed(2)), + currentAllocation: Object.entries(currentByRisk).map(([risk, pct]) => ({ + risk, + pct, + })), + optimalAllocation: optimal, + recommendations, + backtest: this.buildBacktestSnapshot(recommendations, subscriptions), + }; + } + + async executeRebalancing( + userId: string, + riskProfile: 'conservative' | 'balanced' | 'growth' = 'balanced', + ) { + if (!this.rebalancingRepository) { + return { + executionId: null, + status: 'SKIPPED', + executedAt: new Date(), + recommendation: await this.getRebalancingSuggestions( + userId, + riskProfile, + ), + }; + } + + const suggestion = await this.getRebalancingSuggestions( + userId, + riskProfile, + ); + + const execution = this.rebalancingRepository.create({ + userId, + riskProfile, + recommendation: suggestion, + executionResult: { + executedAt: new Date().toISOString(), + plan: suggestion.recommendations.filter( + (item: { action: string }) => item.action !== 'hold', + ), + }, + status: 'EXECUTED', + }); + + const saved = await this.rebalancingRepository.save(execution); + + return { + executionId: saved.id, + status: saved.status, + executedAt: saved.createdAt, + recommendation: suggestion, + }; + } + private getPoolName(poolId: string): string { // Map pool IDs to human-readable names const poolNames: Record = { @@ -265,6 +409,96 @@ export class AnalyticsService { return poolNames[poolId] || poolId; } + private aggregateCurrentRiskAllocation(subscriptions: UserSubscription[]) { + const total = subscriptions.reduce( + (sum, subscription) => sum + Number(subscription.amount), + 0, + ); + + const riskTotals: Record = { + LOW: 0, + MEDIUM: 0, + HIGH: 0, + }; + + for (const subscription of subscriptions) { + const risk = subscription.product?.riskLevel || 'LOW'; + riskTotals[risk] = (riskTotals[risk] || 0) + Number(subscription.amount); + } + + if (total === 0) { + return { LOW: 0, MEDIUM: 0, HIGH: 0 }; + } + + return { + LOW: Number((((riskTotals.LOW || 0) / total) * 100).toFixed(2)), + MEDIUM: Number((((riskTotals.MEDIUM || 0) / total) * 100).toFixed(2)), + HIGH: Number((((riskTotals.HIGH || 0) / total) * 100).toFixed(2)), + }; + } + + private getOptimalAllocation( + riskProfile: 'conservative' | 'balanced' | 'growth', + ) { + if (riskProfile === 'conservative') { + return [ + { risk: 'LOW', targetPct: 70 }, + { risk: 'MEDIUM', targetPct: 25 }, + { risk: 'HIGH', targetPct: 5 }, + ]; + } + + if (riskProfile === 'growth') { + return [ + { risk: 'LOW', targetPct: 20 }, + { risk: 'MEDIUM', targetPct: 35 }, + { risk: 'HIGH', targetPct: 45 }, + ]; + } + + return [ + { risk: 'LOW', targetPct: 45 }, + { risk: 'MEDIUM', targetPct: 35 }, + { risk: 'HIGH', targetPct: 20 }, + ]; + } + + private buildBacktestSnapshot( + recommendations: Array<{ risk: string; targetPct: number }> = [], + subscriptions: UserSubscription[] = [], + ) { + const weightedApy = subscriptions.reduce((sum, entry) => { + const amount = Number(entry.amount); + const apy = Number(entry.product?.interestRate || 0); + return sum + amount * apy; + }, 0); + + const principal = subscriptions.reduce( + (sum, entry) => sum + Number(entry.amount), + 0, + ); + const currentExpectedYield = principal > 0 ? weightedApy / principal : 0; + + const suggestedExpectedYield = recommendations.reduce((sum, rec) => { + const benchmarkByRisk: Record = { + LOW: 5, + MEDIUM: 8, + HIGH: 12, + }; + return sum + (rec.targetPct / 100) * benchmarkByRisk[rec.risk]; + }, 0); + + return { + timeframe: '90d-simulated', + currentAnnualizedApy: Number(currentExpectedYield.toFixed(2)), + suggestedAnnualizedApy: Number(suggestedExpectedYield.toFixed(2)), + estimatedImprovementPct: Number( + (suggestedExpectedYield - currentExpectedYield).toFixed(2), + ), + note: 'Backtest uses risk-bucket benchmark APYs over a simulated 90-day horizon.', + }; + } + private extractAmount(event: ProcessedStellarEvent): number { try { const data = event.eventData as any; diff --git a/backend/src/modules/analytics/dto/execute-rebalancing.dto.ts b/backend/src/modules/analytics/dto/execute-rebalancing.dto.ts new file mode 100644 index 000000000..252d1398f --- /dev/null +++ b/backend/src/modules/analytics/dto/execute-rebalancing.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +export class ExecuteRebalancingDto { + @ApiPropertyOptional({ + enum: ['conservative', 'balanced', 'growth'], + default: 'balanced', + }) + @IsOptional() + @IsIn(['conservative', 'balanced', 'growth']) + riskProfile?: 'conservative' | 'balanced' | 'growth'; +} diff --git a/backend/src/modules/analytics/dto/rebalancing-query.dto.ts b/backend/src/modules/analytics/dto/rebalancing-query.dto.ts new file mode 100644 index 000000000..0838258e8 --- /dev/null +++ b/backend/src/modules/analytics/dto/rebalancing-query.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +export class RebalancingQueryDto { + @ApiPropertyOptional({ + enum: ['conservative', 'balanced', 'growth'], + default: 'balanced', + }) + @IsOptional() + @IsIn(['conservative', 'balanced', 'growth']) + riskProfile?: 'conservative' | 'balanced' | 'growth'; +} diff --git a/backend/src/modules/analytics/entities/rebalancing-execution.entity.ts b/backend/src/modules/analytics/entities/rebalancing-execution.entity.ts new file mode 100644 index 000000000..8be8e071f --- /dev/null +++ b/backend/src/modules/analytics/entities/rebalancing-execution.entity.ts @@ -0,0 +1,32 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity('rebalancing_executions') +@Index(['userId', 'createdAt']) +export class RebalancingExecution { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column('uuid') + userId!: string; + + @Column({ type: 'varchar' }) + riskProfile!: 'conservative' | 'balanced' | 'growth'; + + @Column({ type: 'jsonb' }) + recommendation!: Record; + + @Column({ type: 'jsonb', nullable: true }) + executionResult!: Record | null; + + @Column({ type: 'varchar', default: 'EXECUTED' }) + status!: 'EXECUTED' | 'FAILED'; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/backend/src/modules/challenges/challenges.controller.ts b/backend/src/modules/challenges/challenges.controller.ts new file mode 100644 index 000000000..5f4831432 --- /dev/null +++ b/backend/src/modules/challenges/challenges.controller.ts @@ -0,0 +1,67 @@ +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { CreateChallengeDto } from './dto/create-challenge.dto'; +import { ChallengesService } from './challenges.service'; + +@ApiTags('challenges') +@Controller('challenges') +export class ChallengesController { + constructor(private readonly challengesService: ChallengesService) {} + + @Get() + @ApiOperation({ summary: 'List active savings challenges' }) + listChallenges() { + return this.challengesService.listChallenges(); + } + + @Post('admin/create') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Admin API for creating challenges' }) + createChallenge(@Body() dto: CreateChallengeDto) { + return this.challengesService.createChallenge(dto); + } + + @Post('join/:id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Join a challenge' }) + @ApiResponse({ status: 201, description: 'Joined challenge' }) + joinChallenge( + @Param('id') challengeId: string, + @CurrentUser() user: { id: string }, + ) { + return this.challengesService.joinChallenge(user.id, challengeId); + } + + @Get(':id/leaderboard') + @ApiOperation({ summary: 'Leaderboard for a challenge' }) + leaderboard(@Param('id') challengeId: string) { + return this.challengesService.getChallengeLeaderboard(challengeId); + } + + @Get('achievements/me') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'List current user challenge achievements' }) + myAchievements(@CurrentUser() user: { id: string }) { + return this.challengesService.getUserAchievements(user.id); + } + + @Get('achievements/share/:achievementId') + @ApiOperation({ summary: 'Social sharing payload for an achievement' }) + sharePayload(@Param('achievementId') achievementId: string) { + return this.challengesService.socialSharePayload(achievementId); + } +} diff --git a/backend/src/modules/challenges/challenges.module.ts b/backend/src/modules/challenges/challenges.module.ts new file mode 100644 index 000000000..db9c030be --- /dev/null +++ b/backend/src/modules/challenges/challenges.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { User } from '../user/entities/user.entity'; +import { UserSubscription } from '../savings/entities/user-subscription.entity'; +import { ChallengeAchievement } from './entities/challenge-achievement.entity'; +import { ChallengeParticipant } from './entities/challenge-participant.entity'; +import { SavingsChallenge } from './entities/savings-challenge.entity'; +import { ChallengesController } from './challenges.controller'; +import { ChallengesService } from './challenges.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + SavingsChallenge, + ChallengeParticipant, + ChallengeAchievement, + UserSubscription, + User, + ]), + NotificationsModule, + ], + controllers: [ChallengesController], + providers: [ChallengesService], + exports: [ChallengesService], +}) +export class ChallengesModule {} diff --git a/backend/src/modules/challenges/challenges.service.ts b/backend/src/modules/challenges/challenges.service.ts new file mode 100644 index 000000000..c1374670e --- /dev/null +++ b/backend/src/modules/challenges/challenges.service.ts @@ -0,0 +1,257 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { randomUUID } from 'crypto'; +import { + UserSubscription, + SubscriptionStatus, +} from '../savings/entities/user-subscription.entity'; +import { User } from '../user/entities/user.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationType } from '../notifications/entities/notification.entity'; +import { CreateChallengeDto } from './dto/create-challenge.dto'; +import { ChallengeAchievement } from './entities/challenge-achievement.entity'; +import { ChallengeParticipant } from './entities/challenge-participant.entity'; +import { SavingsChallenge } from './entities/savings-challenge.entity'; + +@Injectable() +export class ChallengesService { + private readonly logger = new Logger(ChallengesService.name); + + constructor( + @InjectRepository(SavingsChallenge) + private readonly challengeRepository: Repository, + @InjectRepository(ChallengeParticipant) + private readonly participantRepository: Repository, + @InjectRepository(ChallengeAchievement) + private readonly achievementRepository: Repository, + @InjectRepository(UserSubscription) + private readonly subscriptionRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly notificationsService: NotificationsService, + private readonly eventEmitter: EventEmitter2, + ) {} + + async createChallenge(dto: CreateChallengeDto) { + const startsAt = new Date(dto.startsAt); + const endsAt = new Date(dto.endsAt); + + if (startsAt >= endsAt) { + throw new BadRequestException('Challenge start must be before end date'); + } + + const challenge = this.challengeRepository.create({ + title: dto.title, + description: dto.description, + targetAmount: dto.targetAmount, + startsAt, + endsAt, + badgeName: dto.badgeName || 'Challenger', + isActive: true, + }); + + return this.challengeRepository.save(challenge); + } + + async joinChallenge(userId: string, challengeId: string) { + const challenge = await this.challengeRepository.findOne({ + where: { id: challengeId, isActive: true }, + }); + if (!challenge) { + throw new NotFoundException('Challenge not found'); + } + + const existing = await this.participantRepository.findOne({ + where: { challengeId, userId }, + }); + + if (existing) { + return existing; + } + + const progressAmount = await this.calculateProgress(userId, challenge); + + const participant = this.participantRepository.create({ + challengeId, + userId, + progressAmount, + completed: progressAmount >= Number(challenge.targetAmount), + completedAt: + progressAmount >= Number(challenge.targetAmount) ? new Date() : null, + }); + + const saved = await this.participantRepository.save(participant); + await this.handleCompletionIfNeeded(saved, challenge); + + return saved; + } + + async getChallengeLeaderboard(challengeId: string) { + const challenge = await this.challengeRepository.findOne({ + where: { id: challengeId }, + }); + if (!challenge) { + throw new NotFoundException('Challenge not found'); + } + + await this.refreshChallengeProgress(challengeId); + + const participants = await this.participantRepository.find({ + where: { challengeId }, + order: { progressAmount: 'DESC', updatedAt: 'ASC' }, + take: 100, + }); + + return { + challenge, + leaderboard: participants.map((participant, idx) => ({ + rank: idx + 1, + userId: participant.userId, + progressAmount: Number(participant.progressAmount), + completed: participant.completed, + })), + }; + } + + async getUserAchievements(userId: string) { + return this.achievementRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } + + async socialSharePayload(achievementId: string) { + const achievement = await this.achievementRepository.findOne({ + where: { id: achievementId }, + }); + + if (!achievement) { + throw new NotFoundException('Achievement not found'); + } + + const challenge = await this.challengeRepository.findOne({ + where: { id: achievement.challengeId }, + }); + + return { + title: `I just earned the ${achievement.badgeName} badge on Nestera!`, + message: `Completed ${challenge?.title || 'a savings challenge'} and unlocked ${achievement.badgeName}.`, + shareUrl: `https://nestera.app/challenges/share/${achievement.shareCode}`, + hashtags: ['Nestera', 'SavingsChallenge', 'SmartMoney'], + }; + } + + async refreshChallengeProgress(challengeId: string) { + const challenge = await this.challengeRepository.findOne({ + where: { id: challengeId }, + }); + if (!challenge) { + throw new NotFoundException('Challenge not found'); + } + + const participants = await this.participantRepository.find({ + where: { challengeId }, + }); + + for (const participant of participants) { + const progressAmount = await this.calculateProgress( + participant.userId, + challenge, + ); + + participant.progressAmount = progressAmount; + participant.completed = progressAmount >= Number(challenge.targetAmount); + if (participant.completed && !participant.completedAt) { + participant.completedAt = new Date(); + } + + await this.participantRepository.save(participant); + await this.handleCompletionIfNeeded(participant, challenge); + } + } + + async listChallenges() { + return this.challengeRepository.find({ + where: { isActive: true }, + order: { startsAt: 'ASC' }, + }); + } + + private async calculateProgress(userId: string, challenge: SavingsChallenge) { + const subscriptions = await this.subscriptionRepository.find({ + where: { + userId, + status: SubscriptionStatus.ACTIVE, + }, + }); + + const total = subscriptions.reduce( + (sum, entry) => sum + Number(entry.amount), + 0, + ); + + return Number(total.toFixed(2)); + } + + private async handleCompletionIfNeeded( + participant: ChallengeParticipant, + challenge: SavingsChallenge, + ) { + if (!participant.completed) { + return; + } + + const existing = await this.achievementRepository.findOne({ + where: { + userId: participant.userId, + challengeId: participant.challengeId, + }, + }); + + if (existing) { + return; + } + + const user = await this.userRepository.findOne({ + where: { id: participant.userId }, + }); + + const achievement = this.achievementRepository.create({ + userId: participant.userId, + challengeId: participant.challengeId, + badgeName: challenge.badgeName, + shareCode: randomUUID(), + }); + + const saved = await this.achievementRepository.save(achievement); + + await this.notificationsService.createNotification({ + userId: participant.userId, + type: NotificationType.CHALLENGE_BADGE_EARNED, + title: 'Badge earned', + message: `You completed ${challenge.title} and earned the ${challenge.badgeName} badge.`, + metadata: { + challengeId: challenge.id, + achievementId: saved.id, + }, + }); + + this.eventEmitter.emit('challenge.completed', { + userId: participant.userId, + challengeId: challenge.id, + badgeName: challenge.badgeName, + userEmail: user?.email || null, + }); + + this.logger.log( + `User ${participant.userId} completed challenge ${challenge.id}`, + ); + } +} diff --git a/backend/src/modules/challenges/dto/create-challenge.dto.ts b/backend/src/modules/challenges/dto/create-challenge.dto.ts new file mode 100644 index 000000000..f0947e62d --- /dev/null +++ b/backend/src/modules/challenges/dto/create-challenge.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsNumber, + IsOptional, + IsString, + Min, +} from 'class-validator'; + +export class CreateChallengeDto { + @ApiProperty() + @IsString() + title: string; + + @ApiProperty() + @IsString() + description: string; + + @ApiProperty({ minimum: 1 }) + @IsNumber() + @Min(1) + targetAmount: number; + + @ApiProperty() + @IsDateString() + startsAt: string; + + @ApiProperty() + @IsDateString() + endsAt: string; + + @ApiPropertyOptional({ default: 'Challenger' }) + @IsOptional() + @IsString() + badgeName?: string; +} diff --git a/backend/src/modules/challenges/entities/challenge-achievement.entity.ts b/backend/src/modules/challenges/entities/challenge-achievement.entity.ts new file mode 100644 index 000000000..82bc8b784 --- /dev/null +++ b/backend/src/modules/challenges/entities/challenge-achievement.entity.ts @@ -0,0 +1,29 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity('challenge_achievements') +@Index(['userId', 'challengeId']) +export class ChallengeAchievement { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column('uuid') + userId!: string; + + @Column('uuid') + challengeId!: string; + + @Column({ type: 'varchar' }) + badgeName!: string; + + @Column({ type: 'varchar', unique: true }) + shareCode!: string; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/backend/src/modules/challenges/entities/challenge-participant.entity.ts b/backend/src/modules/challenges/entities/challenge-participant.entity.ts new file mode 100644 index 000000000..171e79668 --- /dev/null +++ b/backend/src/modules/challenges/entities/challenge-participant.entity.ts @@ -0,0 +1,37 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity('challenge_participants') +@Index(['challengeId', 'userId'], { unique: true }) +@Index(['challengeId', 'progressAmount']) +export class ChallengeParticipant { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column('uuid') + challengeId!: string; + + @Column('uuid') + userId!: string; + + @Column({ type: 'decimal', precision: 14, scale: 2, default: 0 }) + progressAmount!: number; + + @Column({ type: 'boolean', default: false }) + completed!: boolean; + + @Column({ type: 'timestamp', nullable: true }) + completedAt!: Date | null; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/backend/src/modules/challenges/entities/savings-challenge.entity.ts b/backend/src/modules/challenges/entities/savings-challenge.entity.ts new file mode 100644 index 000000000..4bb627931 --- /dev/null +++ b/backend/src/modules/challenges/entities/savings-challenge.entity.ts @@ -0,0 +1,42 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity('savings_challenges') +@Index(['startsAt', 'endsAt']) +export class SavingsChallenge { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column() + title!: string; + + @Column({ type: 'text' }) + description!: string; + + @Column({ type: 'decimal', precision: 14, scale: 2 }) + targetAmount!: number; + + @Column({ type: 'timestamp' }) + startsAt!: Date; + + @Column({ type: 'timestamp' }) + endsAt!: Date; + + @Column({ type: 'varchar', default: 'Challenger' }) + badgeName!: string; + + @Column({ type: 'boolean', default: true }) + isActive!: boolean; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/backend/src/modules/kyc/dto/initiate-kyc.dto.ts b/backend/src/modules/kyc/dto/initiate-kyc.dto.ts new file mode 100644 index 000000000..53a0e7021 --- /dev/null +++ b/backend/src/modules/kyc/dto/initiate-kyc.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { KycProvider } from '../entities/kyc-verification.entity'; + +export class InitiateKycDto { + @ApiProperty({ enum: KycProvider, default: KycProvider.SUMSUB }) + @IsEnum(KycProvider) + provider!: KycProvider; + + @ApiPropertyOptional({ + description: 'Government ID number (encrypted at rest)', + }) + @IsOptional() + @IsString() + idNumber?: string; + + @ApiPropertyOptional({ + description: 'Document type (passport, national_id, etc.)', + }) + @IsOptional() + @IsString() + documentType?: string; +} diff --git a/backend/src/modules/kyc/dto/kyc-webhook.dto.ts b/backend/src/modules/kyc/dto/kyc-webhook.dto.ts new file mode 100644 index 000000000..5f7e6f941 --- /dev/null +++ b/backend/src/modules/kyc/dto/kyc-webhook.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { KycVerificationStatus } from '../entities/kyc-verification.entity'; + +export class KycWebhookDto { + @ApiProperty({ example: 'prov_123' }) + @IsString() + providerReference!: string; + + @ApiProperty({ enum: KycVerificationStatus }) + @IsEnum(KycVerificationStatus) + status!: KycVerificationStatus; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + reason?: string; +} diff --git a/backend/src/modules/kyc/entities/kyc-compliance-report.entity.ts b/backend/src/modules/kyc/entities/kyc-compliance-report.entity.ts new file mode 100644 index 000000000..1b750da30 --- /dev/null +++ b/backend/src/modules/kyc/entities/kyc-compliance-report.entity.ts @@ -0,0 +1,33 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity('kyc_compliance_reports') +@Index(['generatedAt']) +@Index(['status']) +export class KycComplianceReport { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar' }) + regulator!: string; + + @Column({ type: 'varchar' }) + period!: string; + + @Column({ type: 'varchar' }) + status!: 'DRAFT' | 'FINAL'; + + @Column({ type: 'jsonb' }) + summary!: Record; + + @Column({ type: 'jsonb' }) + payload!: Record; + + @CreateDateColumn() + generatedAt!: Date; +} diff --git a/backend/src/modules/kyc/entities/kyc-verification.entity.ts b/backend/src/modules/kyc/entities/kyc-verification.entity.ts new file mode 100644 index 000000000..55e3446d0 --- /dev/null +++ b/backend/src/modules/kyc/entities/kyc-verification.entity.ts @@ -0,0 +1,66 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum KycProvider { + ONFIDO = 'ONFIDO', + JUMIO = 'JUMIO', + SUMSUB = 'SUMSUB', +} + +export enum KycVerificationStatus { + INITIATED = 'INITIATED', + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', +} + +@Entity('kyc_verifications') +@Index(['userId', 'createdAt']) +@Index(['providerReference'], { unique: true }) +export class KycVerification { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column('uuid') + userId!: string; + + @Column({ type: 'enum', enum: KycProvider }) + provider!: KycProvider; + + @Column({ type: 'varchar', unique: true }) + providerReference!: string; + + @Column({ + type: 'enum', + enum: KycVerificationStatus, + default: KycVerificationStatus.INITIATED, + }) + status!: KycVerificationStatus; + + @Column({ type: 'text', nullable: true }) + encryptedPii!: string | null; + + @Column({ type: 'text', nullable: true }) + encryptedProviderResponse!: string | null; + + @Column({ type: 'text', nullable: true }) + encryptedWebhookPayload!: string | null; + + @Column({ type: 'varchar', nullable: true }) + failureReason!: string | null; + + @Column({ type: 'timestamp', nullable: true }) + completedAt!: Date | null; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/backend/src/modules/kyc/kyc.controller.ts b/backend/src/modules/kyc/kyc.controller.ts new file mode 100644 index 000000000..156baaff1 --- /dev/null +++ b/backend/src/modules/kyc/kyc.controller.ts @@ -0,0 +1,70 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Request } from 'express'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { InitiateKycDto } from './dto/initiate-kyc.dto'; +import { KycWebhookDto } from './dto/kyc-webhook.dto'; +import { KycService } from './kyc.service'; + +@ApiTags('kyc') +@Controller() +export class KycController { + constructor(private readonly kycService: KycService) {} + + @Post('user/kyc/initiate') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Initiate third-party KYC verification' }) + @ApiResponse({ status: 201, description: 'KYC initiated' }) + initiate(@CurrentUser() user: { id: string }, @Body() dto: InitiateKycDto) { + return this.kycService.initiateVerification(user.id, dto); + } + + @Post('webhooks/kyc/status') + @ApiOperation({ summary: 'Handle KYC provider webhook status updates' }) + @ApiResponse({ status: 201, description: 'Webhook processed' }) + handleStatusWebhook(@Body() dto: KycWebhookDto, @Req() req: Request) { + return this.kycService.handleWebhook(dto, req.body); + } + + @Get('user/kyc/verifications') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'List current user KYC verification records' }) + getMyVerifications(@CurrentUser() user: { id: string }) { + return this.kycService.listUserVerifications(user.id); + } + + @Get('admin/kyc/reports') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Generate compliance report for regulators' }) + getComplianceReport( + @Query('regulator') regulator: string, + @Query('period') period: string, + ) { + return this.kycService.getComplianceReport( + regulator || 'default-regulator', + period || 'current-month', + ); + } +} diff --git a/backend/src/modules/kyc/kyc.module.ts b/backend/src/modules/kyc/kyc.module.ts new file mode 100644 index 000000000..802335a7d --- /dev/null +++ b/backend/src/modules/kyc/kyc.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '../user/entities/user.entity'; +import { KycComplianceReport } from './entities/kyc-compliance-report.entity'; +import { KycVerification } from './entities/kyc-verification.entity'; +import { KycController } from './kyc.controller'; +import { KycService } from './kyc.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([KycVerification, KycComplianceReport, User]), + ], + controllers: [KycController], + providers: [KycService], + exports: [KycService], +}) +export class KycModule {} diff --git a/backend/src/modules/kyc/kyc.service.ts b/backend/src/modules/kyc/kyc.service.ts new file mode 100644 index 000000000..c8cc6f5e6 --- /dev/null +++ b/backend/src/modules/kyc/kyc.service.ts @@ -0,0 +1,246 @@ +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import axios from 'axios'; +import { Repository } from 'typeorm'; +import { User } from '../user/entities/user.entity'; +import { PiiEncryptionService } from '../../common/services/pii-encryption.service'; +import { KycComplianceReport } from './entities/kyc-compliance-report.entity'; +import { + KycProvider, + KycVerification, + KycVerificationStatus, +} from './entities/kyc-verification.entity'; +import { InitiateKycDto } from './dto/initiate-kyc.dto'; +import { KycWebhookDto } from './dto/kyc-webhook.dto'; + +@Injectable() +export class KycService { + private readonly logger = new Logger(KycService.name); + + constructor( + @InjectRepository(KycVerification) + private readonly verificationRepository: Repository, + @InjectRepository(KycComplianceReport) + private readonly reportRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly configService: ConfigService, + private readonly piiEncryptionService: PiiEncryptionService, + ) {} + + async initiateVerification(userId: string, dto: InitiateKycDto) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + const providerResponse = await this.createProviderCheck(user, dto); + + const verification = this.verificationRepository.create({ + userId, + provider: dto.provider, + providerReference: providerResponse.providerReference, + status: KycVerificationStatus.PENDING, + encryptedPii: this.piiEncryptionService.encrypt({ + idNumber: dto.idNumber ?? null, + documentType: dto.documentType ?? null, + email: user.email, + }), + encryptedProviderResponse: this.piiEncryptionService.encrypt( + providerResponse.raw, + ), + }); + + const saved = await this.verificationRepository.save(verification); + await this.userRepository.update(userId, { + kycStatus: 'PENDING', + }); + + return { + verificationId: saved.id, + providerReference: saved.providerReference, + provider: saved.provider, + status: saved.status, + verificationUrl: providerResponse.verificationUrl, + }; + } + + async handleWebhook(dto: KycWebhookDto, rawPayload: unknown) { + const verification = await this.verificationRepository.findOne({ + where: { providerReference: dto.providerReference }, + }); + + if (!verification) { + throw new NotFoundException('KYC verification not found'); + } + + const status = this.normalizeStatus(dto.status); + + verification.status = status; + verification.failureReason = dto.reason || null; + verification.encryptedWebhookPayload = + this.piiEncryptionService.encrypt(rawPayload); + + if ( + status === KycVerificationStatus.APPROVED || + status === KycVerificationStatus.REJECTED + ) { + verification.completedAt = new Date(); + } + + await this.verificationRepository.save(verification); + + const isApproved = status === KycVerificationStatus.APPROVED; + await this.userRepository.update(verification.userId, { + kycStatus: isApproved + ? 'APPROVED' + : status === KycVerificationStatus.REJECTED + ? 'REJECTED' + : 'PENDING', + kycRejectionReason: isApproved ? undefined : dto.reason || undefined, + tier: isApproved ? 'VERIFIED' : 'FREE', + }); + + return { ok: true }; + } + + async getComplianceReport(regulator: string, period: string) { + const rows = await this.verificationRepository.find({ + where: {}, + order: { createdAt: 'DESC' }, + take: 1000, + }); + + const summary = { + totalChecks: rows.length, + approved: rows.filter((r) => r.status === KycVerificationStatus.APPROVED) + .length, + rejected: rows.filter((r) => r.status === KycVerificationStatus.REJECTED) + .length, + pending: rows.filter((r) => r.status === KycVerificationStatus.PENDING) + .length, + }; + + const payload = { + regulator, + period, + generatedAt: new Date().toISOString(), + verifications: rows.map((row) => ({ + id: row.id, + userId: row.userId, + provider: row.provider, + providerReference: row.providerReference, + status: row.status, + completedAt: row.completedAt, + createdAt: row.createdAt, + })), + }; + + const report = this.reportRepository.create({ + regulator, + period, + status: 'FINAL', + summary, + payload, + }); + + const saved = await this.reportRepository.save(report); + return { + id: saved.id, + regulator: saved.regulator, + period: saved.period, + status: saved.status, + summary: saved.summary, + generatedAt: saved.generatedAt, + }; + } + + async listUserVerifications(userId: string) { + return this.verificationRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } + + private normalizeStatus(status: KycVerificationStatus) { + if ( + status !== KycVerificationStatus.APPROVED && + status !== KycVerificationStatus.REJECTED && + status !== KycVerificationStatus.PENDING + ) { + throw new BadRequestException('Invalid KYC status'); + } + + return status; + } + + private async createProviderCheck(user: User, dto: InitiateKycDto) { + const baseUrl = this.configService.get('KYC_PROVIDER_BASE_URL'); + const apiKey = this.configService.get('KYC_PROVIDER_API_KEY'); + + const providerReference = `${dto.provider.toLowerCase()}_${Date.now()}_${Math.floor(Math.random() * 1000)}`; + + if (!baseUrl || !apiKey) { + this.logger.warn( + 'KYC provider configuration missing. Falling back to simulated provider response.', + ); + return { + providerReference, + verificationUrl: `https://kyc.example.com/session/${providerReference}`, + raw: { + simulated: true, + userEmail: user.email, + provider: dto.provider, + }, + }; + } + + try { + const response = await axios.post( + `${baseUrl}/verifications`, + { + applicant: { + userId: user.id, + email: user.email, + name: user.name, + }, + provider: dto.provider, + }, + { + timeout: 10000, + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }, + ); + + return { + providerReference: response.data?.id || providerReference, + verificationUrl: + response.data?.verificationUrl || + `https://kyc.example.com/session/${providerReference}`, + raw: response.data, + }; + } catch (error) { + this.logger.error( + 'Provider API call failed, using resilient simulated response', + ); + return { + providerReference, + verificationUrl: `https://kyc.example.com/session/${providerReference}`, + raw: { + simulated: true, + providerError: true, + provider: dto.provider, + }, + }; + } + } +} diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 4821ab88a..6ef320a4f 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -154,4 +154,28 @@ export class MailService { ); } } + + async sendSavingsAlertEmail( + userEmail: string, + name: string, + message: string, + ): Promise { + try { + await this.mailerService.sendMail({ + to: userEmail, + subject: 'Savings product alert', + template: './generic-notification', + context: { + name: name || 'User', + message, + }, + }); + this.logger.log(`Savings alert email sent to ${userEmail}`); + } catch (error) { + this.logger.error( + `Failed to send savings alert email to ${userEmail}`, + error, + ); + } + } } diff --git a/backend/src/modules/notifications/entities/notification.entity.ts b/backend/src/modules/notifications/entities/notification.entity.ts index a1ac58f08..dc28b0f18 100644 --- a/backend/src/modules/notifications/entities/notification.entity.ts +++ b/backend/src/modules/notifications/entities/notification.entity.ts @@ -18,6 +18,9 @@ export enum NotificationType { GOAL_MILESTONE = 'GOAL_MILESTONE', GOAL_COMPLETED = 'GOAL_COMPLETED', WITHDRAWAL_COMPLETED = 'WITHDRAWAL_COMPLETED', + CHALLENGE_BADGE_EARNED = 'CHALLENGE_BADGE_EARNED', + PRODUCT_ALERT_TRIGGERED = 'PRODUCT_ALERT_TRIGGERED', + REBALANCING_RECOMMENDED = 'REBALANCING_RECOMMENDED', } @Entity('notifications') diff --git a/backend/src/modules/savings/savings.controller.ts b/backend/src/modules/savings/savings.controller.ts index 3da2160e2..a9ca2aa5f 100644 --- a/backend/src/modules/savings/savings.controller.ts +++ b/backend/src/modules/savings/savings.controller.ts @@ -156,7 +156,10 @@ export class SavingsController { description: 'Withdrawal request created', type: WithdrawalResponseDto, }) - @ApiResponse({ status: 400, description: 'Invalid request or insufficient balance' }) + @ApiResponse({ + status: 400, + description: 'Invalid request or insufficient balance', + }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 404, description: 'Subscription not found' }) async withdraw( diff --git a/backend/src/modules/savings/savings.service.ts b/backend/src/modules/savings/savings.service.ts index b9af4f52b..d5c787253 100644 --- a/backend/src/modules/savings/savings.service.ts +++ b/backend/src/modules/savings/savings.service.ts @@ -8,7 +8,11 @@ import { import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { ConfigService } from '@nestjs/config'; import { Cache } from 'cache-manager'; -import { SavingsProduct, SavingsProductType, RiskLevel } from './entities/savings-product.entity'; +import { + SavingsProduct, + SavingsProductType, + RiskLevel, +} from './entities/savings-product.entity'; import { UserSubscription, SubscriptionStatus, @@ -477,10 +481,7 @@ export class SavingsService { } // Calculate penalty for early withdrawal from locked (FIXED) products - const penalty = this.calculateEarlyWithdrawalPenalty( - subscription, - amount, - ); + const penalty = this.calculateEarlyWithdrawalPenalty(subscription, amount); const netAmount = Number((amount - penalty).toFixed(7)); // Estimated completion: 1 hour for processing diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index 389b317cb..e0b4781ef 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -35,6 +35,9 @@ export class User { @Column({ type: 'varchar', default: 'NOT_SUBMITTED' }) kycStatus: 'NOT_SUBMITTED' | 'PENDING' | 'APPROVED' | 'REJECTED'; + @Column({ type: 'varchar', default: 'FREE' }) + tier: 'FREE' | 'VERIFIED' | 'PREMIUM' | 'ENTERPRISE'; + @Column({ nullable: true }) kycDocumentUrl: string; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index e4dbf2e6a..ca8b03994 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -9,7 +9,6 @@ "target": "ES2023", "sourceMap": true, "outDir": "./dist", - "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strictNullChecks": true,