diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 219f2188..ff9f5477 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -19,6 +19,7 @@ import { WebhooksModule } from './modules/webhooks/webhooks.module'; import { ClaimsModule } from './modules/claims/claims.module'; import { DisputesModule } from './modules/disputes/disputes.module'; import { AdminAnalyticsModule } from './modules/admin-analytics/admin-analytics.module'; +import { SavingsModule } from './modules/savings/savings.module'; @Module({ imports: [ @@ -51,6 +52,7 @@ import { AdminAnalyticsModule } from './modules/admin-analytics/admin-analytics. ClaimsModule, DisputesModule, AdminAnalyticsModule, + SavingsModule, ThrottlerModule.forRoot([ { ttl: 60000, diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 66cfdd02..abd1dd6c 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -36,7 +36,7 @@ export class AuthService { return { user, - accessToken: this.generateToken(user.id, user.email), + accessToken: this.generateToken(user.id, user.email, user.role), }; } @@ -47,7 +47,7 @@ export class AuthService { } return { - accessToken: this.generateToken(user.id, user.email), + accessToken: this.generateToken(user.id, user.email, user.role), }; } @@ -60,8 +60,8 @@ export class AuthService { return null; } - private generateToken(userId: string, email: string) { - return this.jwtService.sign({ sub: userId, email }); + private generateToken(userId: string, email: string, role = 'USER') { + return this.jwtService.sign({ sub: userId, email, role }); } async generateNonce(publicKey: string): Promise<{ nonce: string }> { @@ -118,7 +118,7 @@ export class AuthService { } return { - accessToken: this.generateToken(user.id, user.email), + accessToken: this.generateToken(user.id, user.email, user.role), }; } diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts index ddccfec9..5ec26e10 100644 --- a/backend/src/auth/strategies/jwt.strategy.ts +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -18,7 +18,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - async validate(payload: { sub: string; email: string }) { - return { id: payload.sub, email: payload.email }; + async validate(payload: { sub: string; email: string; role?: string }) { + return { + id: payload.sub, + email: payload.email, + role: payload.role ?? 'USER', + }; } } diff --git a/backend/src/modules/admin/admin-savings.controller.ts b/backend/src/modules/admin/admin-savings.controller.ts new file mode 100644 index 00000000..9e56fbe3 --- /dev/null +++ b/backend/src/modules/admin/admin-savings.controller.ts @@ -0,0 +1,58 @@ +import { + Controller, + Post, + Patch, + Body, + Param, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBody, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { SavingsService } from '../savings/savings.service'; +import { SavingsProduct } from '../savings/entities/savings-product.entity'; +import { CreateProductDto } from '../savings/dto/create-product.dto'; +import { UpdateProductDto } from '../savings/dto/update-product.dto'; +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'; + +@ApiTags('admin/savings') +@Controller('admin/savings') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(Role.ADMIN) +@ApiBearerAuth() +export class AdminSavingsController { + constructor(private readonly savingsService: SavingsService) {} + + @Post('products') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Create a savings product (admin)' }) + @ApiBody({ type: CreateProductDto }) + @ApiResponse({ status: 201, description: 'Product created', type: SavingsProduct }) + @ApiResponse({ status: 400, description: 'Invalid product data' }) + @ApiResponse({ status: 403, description: 'Forbidden - Admin required' }) + async createProduct(@Body() dto: CreateProductDto): Promise { + return await this.savingsService.createProduct(dto); + } + + @Patch('products/:id') + @ApiOperation({ summary: 'Update a savings product (admin)' }) + @ApiBody({ type: UpdateProductDto }) + @ApiResponse({ status: 200, description: 'Product updated', type: SavingsProduct }) + @ApiResponse({ status: 404, description: 'Product not found' }) + @ApiResponse({ status: 403, description: 'Forbidden - Admin required' }) + async updateProduct( + @Param('id') id: string, + @Body() dto: UpdateProductDto, + ): Promise { + return await this.savingsService.updateProduct(id, dto); + } +} diff --git a/backend/src/modules/admin/admin.module.ts b/backend/src/modules/admin/admin.module.ts index 2470d63a..c369f2a4 100644 --- a/backend/src/modules/admin/admin.module.ts +++ b/backend/src/modules/admin/admin.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { UserModule } from '../user/user.module'; +import { SavingsModule } from '../savings/savings.module'; import { AdminController } from './admin.controller'; +import { AdminSavingsController } from './admin-savings.controller'; @Module({ - imports: [UserModule], - controllers: [AdminController], + imports: [UserModule, SavingsModule], + controllers: [AdminController, AdminSavingsController], }) export class AdminModule {} diff --git a/backend/src/modules/savings/dto/create-product.dto.ts b/backend/src/modules/savings/dto/create-product.dto.ts new file mode 100644 index 00000000..95c1388a --- /dev/null +++ b/backend/src/modules/savings/dto/create-product.dto.ts @@ -0,0 +1,60 @@ +import { + IsString, + IsNotEmpty, + IsEnum, + IsNumber, + IsOptional, + Min, + Max, + MinLength, + MaxLength, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { SavingsProductType } from '../entities/savings-product.entity'; + +export class CreateProductDto { + @ApiProperty({ example: 'Fixed 12-Month Plan', description: 'Product name' }) + @IsString() + @IsNotEmpty() + @MinLength(2) + @MaxLength(200) + name: string; + + @ApiProperty({ enum: SavingsProductType, description: 'Product type' }) + @IsEnum(SavingsProductType) + type: SavingsProductType; + + @ApiPropertyOptional({ description: 'Product description' }) + @IsOptional() + @IsString() + @MaxLength(2000) + description?: string; + + @ApiProperty({ example: 8.5, description: 'Annual interest rate (%)' }) + @IsNumber() + @Min(0) + @Max(100) + interestRate: number; + + @ApiProperty({ example: 1000, description: 'Minimum subscription amount' }) + @IsNumber() + @Min(0) + minAmount: number; + + @ApiProperty({ example: 1000000, description: 'Maximum subscription amount' }) + @IsNumber() + @Min(0) + maxAmount: number; + + @ApiPropertyOptional({ example: 12, description: 'Tenure in months (e.g. for fixed)' }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(360) + tenureMonths?: number; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + isActive?: boolean; +} + diff --git a/backend/src/modules/savings/dto/subscribe.dto.ts b/backend/src/modules/savings/dto/subscribe.dto.ts new file mode 100644 index 00000000..c108d2d3 --- /dev/null +++ b/backend/src/modules/savings/dto/subscribe.dto.ts @@ -0,0 +1,13 @@ +import { IsUUID, IsNumber, Min } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SubscribeDto { + @ApiProperty({ description: 'Savings product ID to subscribe to' }) + @IsUUID() + productId: string; + + @ApiProperty({ example: 5000, description: 'Amount to subscribe' }) + @IsNumber() + @Min(0.01) + amount: number; +} diff --git a/backend/src/modules/savings/dto/update-product.dto.ts b/backend/src/modules/savings/dto/update-product.dto.ts new file mode 100644 index 00000000..87b9d7f3 --- /dev/null +++ b/backend/src/modules/savings/dto/update-product.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateProductDto } from './create-product.dto'; + +export class UpdateProductDto extends PartialType(CreateProductDto) {} diff --git a/backend/src/modules/savings/entities/savings-product.entity.ts b/backend/src/modules/savings/entities/savings-product.entity.ts new file mode 100644 index 00000000..94ccaeab --- /dev/null +++ b/backend/src/modules/savings/entities/savings-product.entity.ts @@ -0,0 +1,53 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from 'typeorm'; +import { UserSubscription } from './user-subscription.entity'; + +export enum SavingsProductType { + FIXED = 'FIXED', + FLEXIBLE = 'FLEXIBLE', +} + +@Entity('savings_products') +export class SavingsProduct { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ type: 'enum', enum: SavingsProductType }) + type: SavingsProductType; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column('decimal', { precision: 5, scale: 2, default: 0 }) + interestRate: number; + + @Column('decimal', { precision: 14, scale: 2 }) + minAmount: number; + + @Column('decimal', { precision: 14, scale: 2 }) + maxAmount: number; + + @Column('int', { nullable: true }) + tenureMonths: number | null; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @OneToMany(() => UserSubscription, (sub) => sub.product) + subscriptions: UserSubscription[]; +} diff --git a/backend/src/modules/savings/entities/user-subscription.entity.ts b/backend/src/modules/savings/entities/user-subscription.entity.ts new file mode 100644 index 00000000..373497e0 --- /dev/null +++ b/backend/src/modules/savings/entities/user-subscription.entity.ts @@ -0,0 +1,50 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { SavingsProduct } from './savings-product.entity'; + +export enum SubscriptionStatus { + ACTIVE = 'ACTIVE', + MATURED = 'MATURED', + CANCELLED = 'CANCELLED', +} + +@Entity('user_subscriptions') +export class UserSubscription { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + userId: string; + + @Column('uuid') + productId: string; + + @Column('decimal', { precision: 14, scale: 2 }) + amount: number; + + @Column({ type: 'enum', enum: SubscriptionStatus, default: SubscriptionStatus.ACTIVE }) + status: SubscriptionStatus; + + @Column({ type: 'date' }) + startDate: Date; + + @Column({ type: 'date', nullable: true }) + endDate: Date | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @ManyToOne(() => SavingsProduct, (product) => product.subscriptions, { eager: true }) + @JoinColumn({ name: 'productId' }) + product: SavingsProduct; +} diff --git a/backend/src/modules/savings/savings.controller.ts b/backend/src/modules/savings/savings.controller.ts new file mode 100644 index 00000000..108c8e1b --- /dev/null +++ b/backend/src/modules/savings/savings.controller.ts @@ -0,0 +1,63 @@ +import { + Controller, + Get, + Post, + Body, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBody, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { SavingsService } from './savings.service'; +import { SavingsProduct } from './entities/savings-product.entity'; +import { UserSubscription } from './entities/user-subscription.entity'; +import { SubscribeDto } from './dto/subscribe.dto'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; + +@ApiTags('savings') +@Controller('savings') +export class SavingsController { + constructor(private readonly savingsService: SavingsService) {} + + @Get('products') + @ApiOperation({ summary: 'List all savings products' }) + @ApiResponse({ status: 200, description: 'List of savings products' }) + async getProducts(): Promise { + return await this.savingsService.findAllProducts(true); + } + + @Post('subscribe') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.CREATED) + @ApiBearerAuth() + @ApiOperation({ summary: 'Subscribe to a savings product' }) + @ApiBody({ type: SubscribeDto }) + @ApiResponse({ status: 201, description: 'Subscription created', type: UserSubscription }) + @ApiResponse({ status: 400, description: 'Invalid product or amount' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async subscribe( + @Body() dto: SubscribeDto, + @CurrentUser() user: { id: string; email: string }, + ): Promise { + return await this.savingsService.subscribe(user.id, dto.productId, dto.amount); + } + + @Get('my-subscriptions') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get current user subscriptions' }) + @ApiResponse({ status: 200, description: 'List of user subscriptions' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getMySubscriptions( + @CurrentUser() user: { id: string; email: string }, + ): Promise { + return await this.savingsService.findMySubscriptions(user.id); + } +} diff --git a/backend/src/modules/savings/savings.module.ts b/backend/src/modules/savings/savings.module.ts new file mode 100644 index 00000000..ccda69b2 --- /dev/null +++ b/backend/src/modules/savings/savings.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SavingsController } from './savings.controller'; +import { SavingsService } from './savings.service'; +import { SavingsProduct } from './entities/savings-product.entity'; +import { UserSubscription } from './entities/user-subscription.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([SavingsProduct, UserSubscription]), + ], + controllers: [SavingsController], + providers: [SavingsService], + exports: [SavingsService], +}) +export class SavingsModule {} diff --git a/backend/src/modules/savings/savings.service.ts b/backend/src/modules/savings/savings.service.ts new file mode 100644 index 00000000..ff6e7187 --- /dev/null +++ b/backend/src/modules/savings/savings.service.ts @@ -0,0 +1,107 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SavingsProduct } from './entities/savings-product.entity'; +import { + UserSubscription, + SubscriptionStatus, +} from './entities/user-subscription.entity'; +import { CreateProductDto } from './dto/create-product.dto'; +import { UpdateProductDto } from './dto/update-product.dto'; + +@Injectable() +export class SavingsService { + private readonly logger = new Logger(SavingsService.name); + + constructor( + @InjectRepository(SavingsProduct) + private readonly productRepository: Repository, + @InjectRepository(UserSubscription) + private readonly subscriptionRepository: Repository, + ) {} + + async createProduct(dto: CreateProductDto): Promise { + if (dto.minAmount > dto.maxAmount) { + throw new BadRequestException('minAmount must be less than or equal to maxAmount'); + } + const product = this.productRepository.create({ + ...dto, + isActive: dto.isActive ?? true, + }); + return await this.productRepository.save(product); + } + + async updateProduct( + id: string, + dto: UpdateProductDto, + ): Promise { + const product = await this.productRepository.findOneBy({ id }); + if (!product) { + throw new NotFoundException(`Savings product ${id} not found`); + } + if (dto.minAmount != null && dto.maxAmount != null && dto.minAmount > dto.maxAmount) { + throw new BadRequestException('minAmount must be less than or equal to maxAmount'); + } + Object.assign(product, dto); + return await this.productRepository.save(product); + } + + async findAllProducts(activeOnly = false): Promise { + return await this.productRepository.find({ + where: activeOnly ? { isActive: true } : undefined, + order: { createdAt: 'DESC' }, + }); + } + + async findOneProduct(id: string): Promise { + const product = await this.productRepository.findOneBy({ id }); + if (!product) { + throw new NotFoundException(`Savings product ${id} not found`); + } + return product; + } + + async subscribe( + userId: string, + productId: string, + amount: number, + ): Promise { + const product = await this.findOneProduct(productId); + if (!product.isActive) { + throw new BadRequestException('This savings product is not available for subscription'); + } + if (amount < Number(product.minAmount) || amount > Number(product.maxAmount)) { + throw new BadRequestException( + `Amount must be between ${product.minAmount} and ${product.maxAmount}`, + ); + } + + const subscription = this.subscriptionRepository.create({ + userId, + productId: product.id, + amount, + status: SubscriptionStatus.ACTIVE, + startDate: new Date(), + endDate: product.tenureMonths + ? (() => { + const d = new Date(); + d.setMonth(d.getMonth() + product.tenureMonths!); + return d; + })() + : null, + }); + return await this.subscriptionRepository.save(subscription); + } + + async findMySubscriptions(userId: string): Promise { + return await this.subscriptionRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index ddbf0221..788fe7c1 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -29,6 +29,9 @@ export class User { @Column({ nullable: true }) avatarUrl: string; + @Column({ type: 'varchar', default: 'USER' }) + role: 'USER' | 'ADMIN'; + @Column({ type: 'varchar', default: 'NOT_SUBMITTED' }) kycStatus: 'NOT_SUBMITTED' | 'PENDING' | 'APPROVED' | 'REJECTED';