Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -51,6 +52,7 @@ import { AdminAnalyticsModule } from './modules/admin-analytics/admin-analytics.
ClaimsModule,
DisputesModule,
AdminAnalyticsModule,
SavingsModule,
ThrottlerModule.forRoot([
{
ttl: 60000,
Expand Down
10 changes: 5 additions & 5 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}

Expand All @@ -47,7 +47,7 @@ export class AuthService {
}

return {
accessToken: this.generateToken(user.id, user.email),
accessToken: this.generateToken(user.id, user.email, user.role),
};
}

Expand All @@ -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 }> {
Expand Down Expand Up @@ -118,7 +118,7 @@ export class AuthService {
}

return {
accessToken: this.generateToken(user.id, user.email),
accessToken: this.generateToken(user.id, user.email, user.role),
};
}

Expand Down
8 changes: 6 additions & 2 deletions backend/src/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
}
}
58 changes: 58 additions & 0 deletions backend/src/modules/admin/admin-savings.controller.ts
Original file line number Diff line number Diff line change
@@ -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<SavingsProduct> {
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<SavingsProduct> {
return await this.savingsService.updateProduct(id, dto);
}
}
6 changes: 4 additions & 2 deletions backend/src/modules/admin/admin.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
60 changes: 60 additions & 0 deletions backend/src/modules/savings/dto/create-product.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}

13 changes: 13 additions & 0 deletions backend/src/modules/savings/dto/subscribe.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 4 additions & 0 deletions backend/src/modules/savings/dto/update-product.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateProductDto } from './create-product.dto';

export class UpdateProductDto extends PartialType(CreateProductDto) {}
53 changes: 53 additions & 0 deletions backend/src/modules/savings/entities/savings-product.entity.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
50 changes: 50 additions & 0 deletions backend/src/modules/savings/entities/user-subscription.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
63 changes: 63 additions & 0 deletions backend/src/modules/savings/savings.controller.ts
Original file line number Diff line number Diff line change
@@ -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<SavingsProduct[]> {
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<UserSubscription> {
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<UserSubscription[]> {
return await this.savingsService.findMySubscriptions(user.id);
}
}
Loading