Refactored the whole app based on the requirements shared

This commit is contained in:
Stephanos A
2026-05-21 08:48:28 +03:00
parent 0c4f22c85c
commit 69b27ecb4b
84 changed files with 6880 additions and 12659 deletions

View File

@@ -0,0 +1,57 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { IamGuard, IamRoles } from '../../common/iam-adapter';
import { UserRole } from '@prisma/client';
@ApiTags('Agents')
@Controller('agents')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
export class AgentsController {
constructor(private service: AgentsService) {}
@Post('bookings')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Create agent booking with cash payment' })
createBooking(@Body() dto: CreateAgentBookingDto) {
return this.service.createAgentBooking(dto);
}
@Post('shifts/open')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Open agent shift' })
openShift(@Body() dto: OpenShiftDto) {
return this.service.openShift(dto);
}
@Post('shifts/close')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Close agent shift' })
closeShift(@Body() dto: CloseShiftDto) {
return this.service.closeShift(dto);
}
@Get(':agentId/commissions')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Get agent commissions' })
getCommissions(
@Param('agentId') agentId: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string
) {
return this.service.getCommissions(
agentId,
dateFrom ? new Date(dateFrom) : undefined,
dateTo ? new Date(dateTo) : undefined
);
}
@Get(':agentId/shifts')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Get agent shifts' })
getShifts(@Param('agentId') agentId: string) {
return this.service.getShifts(agentId);
}
}

View File

@@ -0,0 +1,33 @@
import { IsString, IsInt, IsBoolean, IsOptional, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AgentPassengerDto {
@ApiProperty() @IsString() fullName: string;
@ApiProperty() @IsString() phone: string;
@ApiProperty() @IsString() email: string;
@ApiProperty() @IsString() seatId: string;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentType?: string;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
}
export class CreateAgentBookingDto {
@ApiProperty() @IsString() agentId: string;
@ApiProperty() @IsString() tripId: string;
@ApiProperty({ type: [AgentPassengerDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => AgentPassengerDto) passengers: AgentPassengerDto[];
@ApiProperty() @IsString() paymentMethod: string;
@ApiPropertyOptional() @IsOptional() @IsInt() cashReceived?: number;
@ApiPropertyOptional() @IsOptional() @IsBoolean() paperTicket?: boolean;
@ApiPropertyOptional() @IsOptional() @IsString() serviceClass?: string;
}
export class OpenShiftDto {
@ApiProperty() @IsString() agentId: string;
@ApiPropertyOptional() @IsOptional() @IsInt() openingBalance?: number;
}
export class CloseShiftDto {
@ApiProperty() @IsString() shiftId: string;
@ApiProperty() @IsInt() closingBalance: number;
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { AgentsController } from './agents.controller';
import { AgentsService } from './agents.service';
@Module({
imports: [HttpModule],
controllers: [AgentsController],
providers: [AgentsService],
exports: [AgentsService]
})
export class AgentsModule {}

View File

@@ -0,0 +1,131 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
@Injectable()
export class AgentsService {
constructor(private prisma: PrismaService) {}
async createAgentBooking(dto: CreateAgentBookingDto) {
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId }, include: { user: { include: { passenger: true } } } });
if (!agent || !agent.active) throw new NotFoundException('Agent not found or inactive');
if (!agent.user.passenger) throw new BadRequestException('Agent must have passenger account');
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId } });
if (!trip) throw new NotFoundException('Trip not found');
const seatIds = dto.passengers.map(p => p.seatId);
const seats = await this.prisma.seat.findMany({ where: { id: { in: seatIds } } });
if (seats.length !== seatIds.length) throw new BadRequestException('Invalid seat selection');
const baseFare = 45000 * dto.passengers.length;
const totalMinor = baseFare;
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: agent.user.passenger.id,
tripId: dto.tripId,
status: dto.paymentMethod === 'CASH' ? 'CONFIRMED' : 'PENDING_PAYMENT',
totalMinor,
seats: {
create: dto.passengers.map(p => ({
seatId: p.seatId,
passengerName: p.fullName,
idDocumentType: p.idDocumentType,
idDocumentNumber: p.idDocumentNumber
}))
}
},
include: { seats: true }
});
await this.prisma.seat.updateMany({
where: { id: { in: seatIds } },
data: { status: 'BOOKED' }
});
const changeGiven = dto.cashReceived ? dto.cashReceived - totalMinor : 0;
await this.prisma.agentBooking.create({
data: {
agentId: dto.agentId,
bookingId: booking.id,
paymentMethod: dto.paymentMethod,
cashReceived: dto.cashReceived,
changeGiven,
paperTicket: dto.paperTicket ?? false
}
});
const commissionAmount = Math.floor(totalMinor * agent.commissionRate / 100);
await this.prisma.agentCommission.create({
data: {
agentId: dto.agentId,
bookingId: booking.id,
amountMinor: commissionAmount,
rate: agent.commissionRate
}
});
return { booking, commission: commissionAmount };
}
async openShift(dto: OpenShiftDto) {
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId } });
if (!agent) throw new NotFoundException('Agent not found');
const openShift = await this.prisma.agentShift.findFirst({
where: { agentId: dto.agentId, closedAt: null }
});
if (openShift) throw new BadRequestException('Shift already open');
return this.prisma.agentShift.create({
data: {
agentId: dto.agentId,
openingBalance: dto.openingBalance ?? 0
}
});
}
async closeShift(dto: CloseShiftDto) {
const shift = await this.prisma.agentShift.findUnique({ where: { id: dto.shiftId } });
if (!shift) throw new NotFoundException('Shift not found');
if (shift.closedAt) throw new BadRequestException('Shift already closed');
return this.prisma.agentShift.update({
where: { id: dto.shiftId },
data: {
closedAt: new Date(),
closingBalance: dto.closingBalance,
notes: dto.notes,
reconciled: true
}
});
}
async getCommissions(agentId: string, dateFrom?: Date, dateTo?: Date) {
return this.prisma.agentCommission.findMany({
where: {
agentId,
createdAt: {
gte: dateFrom,
lte: dateTo
}
},
orderBy: { createdAt: 'desc' }
});
}
async getShifts(agentId: string) {
return this.prisma.agentShift.findMany({
where: { agentId },
orderBy: { openedAt: 'desc' },
take: 20
});
}
}

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto } from './auth.dto';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
@ApiTags('Auth')
@Controller('auth')
@@ -9,10 +9,73 @@ export class AuthController {
constructor(private service: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Register new user' })
@ApiOperation({
summary: 'Register new passenger account',
description: 'Create a new passenger account with email, phone, and password. Returns user details and JWT token for immediate login.'
})
@ApiResponse({ status: 201, description: 'Account created successfully. Returns user object and JWT token.' })
@ApiResponse({ status: 400, description: 'Validation error (invalid email, weak password, etc.)' })
@ApiResponse({ status: 409, description: 'Email or phone already registered' })
@ApiBody({ type: RegisterDto })
register(@Body() dto: RegisterDto) { return this.service.register(dto); }
@Post('login')
@ApiOperation({ summary: 'Login and get JWT' })
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Login with email and password',
description: 'Authenticate user and receive JWT token. Token expires in 7 days by default. Failed login attempts are tracked and account may be locked after 5 consecutive failures.'
})
@ApiResponse({ status: 200, description: 'Login successful. Returns JWT token and user details.' })
@ApiResponse({ status: 401, description: 'Invalid credentials or account locked' })
@ApiResponse({ status: 403, description: 'Account temporarily blocked due to fraud detection' })
@ApiBody({ type: LoginDto })
login(@Body() dto: LoginDto) { return this.service.login(dto); }
@Post('otp/request')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Request OTP verification code',
description: 'Send a 6-digit OTP code to user email. Code expires in 10 minutes. Used for registration verification, password reset, or two-factor authentication.'
})
@ApiResponse({ status: 200, description: 'OTP sent successfully to email' })
@ApiResponse({ status: 404, description: 'Email not found (for PASSWORD_RESET purpose)' })
@ApiResponse({ status: 429, description: 'Too many OTP requests. Please wait before requesting again.' })
@ApiBody({ type: RequestOtpDto })
requestOtp(@Body() dto: RequestOtpDto) { return this.service.requestOtp(dto); }
@Post('otp/verify')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Verify OTP code',
description: 'Validate the 6-digit OTP code sent to user email. Code must match and not be expired.'
})
@ApiResponse({ status: 200, description: 'OTP verified successfully' })
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
@ApiResponse({ status: 404, description: 'No OTP found for this email and purpose' })
@ApiBody({ type: VerifyOtpDto })
verifyOtp(@Body() dto: VerifyOtpDto) { return this.service.verifyOtp(dto); }
@Post('password/reset-request')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Request password reset link',
description: 'Send password reset link to user email. Link contains a secure token valid for 1 hour.'
})
@ApiResponse({ status: 200, description: 'Password reset email sent successfully' })
@ApiResponse({ status: 404, description: 'Email not found' })
@ApiResponse({ status: 429, description: 'Too many reset requests. Please wait before trying again.' })
@ApiBody({ type: RequestPasswordResetDto })
requestPasswordReset(@Body() dto: RequestPasswordResetDto) { return this.service.requestPasswordReset(dto); }
@Post('password/reset')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Reset password with token',
description: 'Reset user password using the token received via email. Token is single-use and expires after 1 hour.'
})
@ApiResponse({ status: 200, description: 'Password reset successfully' })
@ApiResponse({ status: 400, description: 'Invalid, expired, or already used token' })
@ApiResponse({ status: 404, description: 'User not found' })
@ApiBody({ type: ResetPasswordDto })
resetPassword(@Body() dto: ResetPasswordDto) { return this.service.resetPassword(dto); }
}

View File

@@ -1,14 +1,152 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ example: 'Kelemu Ketsela' }) @IsString() fullName: string;
@ApiProperty({ example: 'kelemu@email.com' }) @IsEmail() email: string;
@ApiProperty({ example: '+251912345678' }) @IsString() phone: string;
@ApiProperty({ example: 'password123', minLength: 8 }) @IsString() @MinLength(8) password: string;
@ApiProperty({
description: 'Full name of the passenger',
example: 'Kelemu Ketsela',
minLength: 2,
maxLength: 100
})
@IsString()
fullName: string;
@ApiProperty({
description: 'Email address (must be unique)',
example: 'kelemu@email.com',
format: 'email'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Phone number with country code',
example: '+251912345678',
pattern: '^\\+[1-9]\\d{1,14}$'
})
@IsString()
phone: string;
@ApiProperty({
description: 'Password (minimum 8 characters)',
example: 'SecurePass123',
minLength: 8,
format: 'password'
})
@IsString()
@MinLength(8)
password: string;
@ApiPropertyOptional({
description: 'Nationality of the passenger',
example: 'Ethiopian'
})
@IsOptional()
@IsString()
nationality?: string;
@ApiPropertyOptional({
description: 'National ID number',
example: 'ET123456789'
})
@IsOptional()
@IsString()
nationalId?: string;
@ApiPropertyOptional({
description: 'Passport number for international travelers',
example: 'P1234567'
})
@IsOptional()
@IsString()
passportNumber?: string;
}
export class LoginDto {
@ApiProperty({ example: 'kelemu@email.com' }) @IsEmail() email: string;
@ApiProperty({ example: 'password123' }) @IsString() password: string;
@ApiProperty({
description: 'Registered email address',
example: 'kelemu@email.com',
format: 'email'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Account password',
example: 'password123',
format: 'password'
})
@IsString()
password: string;
}
export class RequestOtpDto {
@ApiProperty({
description: 'Email address to send OTP',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Purpose of OTP (REGISTRATION, PASSWORD_RESET, VERIFICATION)',
example: 'REGISTRATION',
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
})
@IsString()
purpose: string;
}
export class VerifyOtpDto {
@ApiProperty({
description: 'Email address',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: '6-digit OTP code',
example: '123456',
minLength: 6,
maxLength: 6
})
@IsString()
code: string;
@ApiProperty({
description: 'Purpose of OTP verification',
example: 'REGISTRATION',
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
})
@IsString()
purpose: string;
}
export class RequestPasswordResetDto {
@ApiProperty({
description: 'Email address of the account',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
}
export class ResetPasswordDto {
@ApiProperty({
description: 'Password reset token received via email',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
@IsString()
token: string;
@ApiProperty({
description: 'New password (minimum 8 characters)',
example: 'NewSecurePass123',
minLength: 8,
format: 'password'
})
@IsString()
@MinLength(8)
newPassword: string;
}

View File

@@ -1,8 +1,9 @@
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
import { Injectable, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../../common/prisma.service';
import { RegisterDto, LoginDto } from './auth.dto';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
@Injectable()
export class AuthService {
@@ -15,28 +16,115 @@ export class AuthService {
if (exists) throw new ConflictException('Email or phone already registered');
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.create({
data: { fullName: dto.fullName, email: dto.email, phone: dto.phone, passwordHash },
data: {
fullName: dto.fullName,
email: dto.email,
phone: dto.phone,
passwordHash,
nationality: dto.nationality,
nationalId: dto.nationalId,
passportNumber: dto.passportNumber
},
});
const passenger = await this.prisma.passenger.create({ data: { userId: user.id } });
await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.userPreferences.create({ data: { userId: user.id } });
await this.createAuditLog(user.id, 'USER_REGISTERED', 'User', user.id, null, { email: user.email });
return this.signToken(user.id, user.email, user.role, passenger.id);
}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
include: { passenger: true },
include: { passenger: true, agent: true },
});
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
if (!user) throw new UnauthorizedException('Invalid credentials');
if (user.lockedUntil && user.lockedUntil > new Date()) {
throw new UnauthorizedException(`Account locked until ${user.lockedUntil.toISOString()}`);
}
if (!(await bcrypt.compare(dto.password, user.passwordHash))) {
await this.prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: { increment: 1 },
lockedUntil: user.failedLoginAttempts >= 4 ? new Date(Date.now() + 15 * 60 * 1000) : null
}
});
throw new UnauthorizedException('Invalid credentials');
}
return this.signToken(user.id, user.email, user.role, user.passenger?.id);
await this.prisma.user.update({
where: { id: user.id },
data: { failedLoginAttempts: 0, lockedUntil: null }
});
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
return this.signToken(user.id, user.email, user.role, user.passenger?.id, user.agent?.id);
}
private signToken(userId: string, email: string, role: string, passengerId?: string) {
const token = this.jwt.sign({ sub: userId, email, role, passengerId });
return { token, user: { id: userId, email, role, passengerId } };
async requestOtp(dto: RequestOtpDto) {
const code = Math.floor(100000 + Math.random() * 900000).toString();
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
await this.prisma.otpCode.create({
data: { email: dto.email, code, purpose: dto.purpose, expiresAt }
});
console.log(`[OTP] ${dto.email} - ${code} (${dto.purpose})`);
return { sent: true, expiresIn: 600 };
}
async verifyOtp(dto: VerifyOtpDto) {
const otp = await this.prisma.otpCode.findFirst({
where: { email: dto.email, code: dto.code, purpose: dto.purpose, verified: false, expiresAt: { gt: new Date() } },
orderBy: { createdAt: 'desc' }
});
if (!otp) throw new BadRequestException('Invalid or expired OTP');
await this.prisma.otpCode.update({ where: { id: otp.id }, data: { verified: true } });
return { verified: true };
}
async requestPasswordReset(dto: RequestPasswordResetDto) {
const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
if (!user) return { sent: true };
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
await this.prisma.passwordResetToken.create({
data: { userId: user.id, token, expiresAt }
});
console.log(`[PASSWORD_RESET] ${dto.email} - ${token}`);
return { sent: true };
}
async resetPassword(dto: ResetPasswordDto) {
const resetToken = await this.prisma.passwordResetToken.findUnique({
where: { token: dto.token }
});
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
throw new BadRequestException('Invalid or expired reset token');
}
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
await this.prisma.user.update({
where: { id: resetToken.userId },
data: { passwordHash, failedLoginAttempts: 0, lockedUntil: null }
});
await this.prisma.passwordResetToken.update({
where: { id: resetToken.id },
data: { used: true }
});
await this.createAuditLog(resetToken.userId, 'PASSWORD_RESET', 'User', resetToken.userId, null, null);
return { reset: true };
}
private signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
const token = this.jwt.sign({ sub: userId, email, role, passengerId, agentId });
return { token, user: { id: userId, email, role, passengerId, agentId } };
}
private async createAuditLog(userId: string, action: string, entityType: string, entityId: string, oldData: any, newData: any) {
await this.prisma.auditLog.create({
data: { userId, action, entityType, entityId, oldData, newData }
});
}
}

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { CreateBookingDto } from './bookings.dto';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Booking')
@@ -10,7 +10,28 @@ import { JwtGuard } from '../../common/jwt.guard';
@ApiBearerAuth('JWT-auth')
export class BookingsController {
constructor(private service: BookingsService) {}
@Post() @ApiOperation({ summary: 'Create booking from seat hold' }) create(@Body() dto: CreateBookingDto) { return this.service.create(dto); }
@Get(':bookingRef') @ApiOperation({ summary: 'Get booking by reference' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); }
@Delete(':bookingRef')@ApiOperation({ summary: 'Cancel booking' }) cancel(@Param('bookingRef') ref: string) { return this.service.cancel(ref); }
@Post()
@ApiOperation({ summary: 'Create booking from seat hold' })
create(@Body() dto: CreateBookingDto) {
return this.service.create(dto);
}
@Get(':bookingRef')
@ApiOperation({ summary: 'Get booking by reference' })
getByRef(@Param('bookingRef') ref: string) {
return this.service.getByRef(ref);
}
@Patch(':bookingRef/modify')
@ApiOperation({ summary: 'Modify booking seats or trip' })
modify(@Body() dto: ModifyBookingDto) {
return this.service.modify(dto);
}
@Delete(':bookingRef')
@ApiOperation({ summary: 'Cancel booking' })
cancel(@Param('bookingRef') ref: string, @Body() dto: CancelBookingDto) {
return this.service.cancel(ref, dto.reason);
}
}

View File

@@ -16,7 +16,25 @@ export class CreateBookingDto {
@ApiProperty() @IsString() tripId: string;
@ApiProperty() @IsString() holdId: string;
@ApiProperty({ type: [PassengerInputDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
@ApiPropertyOptional({ example: 'ECONOMY', enum: ['ECONOMY', 'BUSINESS', 'FIRST'] }) @IsOptional() @IsString() serviceClass?: string;
@ApiPropertyOptional({
example: 'ECONOMY_REGULAR',
enum: ['ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER']
})
@IsOptional() @IsString() serviceClass?: string;
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string;
@ApiPropertyOptional({ description: 'Auto-assign seats instead of manual selection' }) @IsOptional() autoAssign?: boolean;
}
export class ModifyBookingDto {
@ApiProperty() @IsString() bookingRef: string;
@ApiProperty() @IsString() newTripId: string;
@ApiProperty({ type: [String] }) @IsArray() newSeatIds: string[];
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
}
export class CancelBookingDto {
@ApiProperty() @IsString() bookingRef: string;
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
}

View File

@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateBookingDto } from './bookings.dto';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SearchService } from '../search/search.service';
@@ -20,13 +20,44 @@ export class BookingsService {
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId }, include: { originStation: true, destinationStation: true } });
if (!trip) throw new NotFoundException('Trip not found');
const fareQuote = await this.searchService.getFareQuote({ tripId: dto.tripId, serviceClass: dto.serviceClass ?? 'ECONOMY', passengerCount: dto.passengers.length, promoCode: dto.promoCode, loyaltyRedemptionPoints: dto.loyaltyRedemptionPoints });
let seatIds: string[];
if (dto.autoAssign) {
seatIds = await this.seatsService.autoAssignSeats(
dto.tripId,
dto.passengers.length,
dto.serviceClass ?? 'ECONOMY_REGULAR',
);
await this.seatsService.confirmSeats(seatIds);
} else {
seatIds = dto.passengers.map((p) => p.seatId);
}
const fareQuote = await this.searchService.getFareQuote({ tripId: dto.tripId, serviceClass: dto.serviceClass ?? 'ECONOMY_REGULAR', passengerCount: dto.passengers.length, promoCode: dto.promoCode, loyaltyRedemptionPoints: dto.loyaltyRedemptionPoints });
const booking = await this.prisma.booking.create({
data: { bookingRef: generateRef(), passengerId: dto.passengerId, tripId: dto.tripId, status: 'PENDING_PAYMENT', totalMinor: fareQuote.totalMinor, seats: { create: dto.passengers.map((p) => ({ seatId: p.seatId, passengerName: p.fullName, idDocumentType: p.idDocumentType, idDocumentNumber: p.idDocumentNumber })) } },
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
tripId: dto.tripId,
status: 'PENDING_PAYMENT',
totalMinor: fareQuote.totalMinor,
bookingType: dto.bookingType ?? 'ONE_WAY',
seats: { create: dto.passengers.map((p, i) => ({ seatId: seatIds[i], passengerName: p.fullName, idDocumentType: p.idDocumentType, idDocumentNumber: p.idDocumentNumber })) }
},
include: { seats: { include: { seat: true } }, trip: { include: { originStation: true, destinationStation: true, service: true } } },
});
this.eventEmitter.emit('booking.created', { booking });
return booking;
return {
...booking,
fareBreakdown: {
baseFare: fareQuote.baseFareMinor / 100,
discount: fareQuote.discountMinor / 100,
loyaltyRedemption: fareQuote.loyaltyRedemptionMinor / 100,
taxesFees: fareQuote.taxesFeesMinor / 100,
total: fareQuote.totalMinor / 100,
currency: fareQuote.currency
}
};
}
async getByRef(bookingRef: string) {
@@ -37,6 +68,7 @@ export class BookingsService {
bookingRef: booking.bookingRef,
status: booking.status,
totalFare: booking.totalMinor / 100,
bookingType: booking.bookingType,
createdAt: booking.createdAt,
trip: {
number: booking.trip.service.number,
@@ -53,12 +85,55 @@ export class BookingsService {
};
}
async cancel(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true } });
async modify(dto: ModifyBookingDto) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { seats: true, trip: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status === 'CONFIRMED') throw new BadRequestException('Use refund for confirmed bookings');
if (booking.status !== 'CONFIRMED') throw new BadRequestException('Only confirmed bookings can be modified');
if (booking.trip.departureAt < new Date()) throw new BadRequestException('Cannot modify past bookings');
const oldSeats = booking.seats.map(s => s.seatId);
const fareAdjustment = 0;
await this.prisma.bookingModification.create({
data: {
bookingId: booking.id,
modifiedBy: booking.passengerId,
modificationType: 'SEAT_CHANGE',
oldData: { tripId: booking.tripId, seatIds: oldSeats },
newData: { tripId: dto.newTripId, seatIds: dto.newSeatIds },
fareAdjustment,
reason: dto.reason
}
});
await this.seatsService.releaseSeats(oldSeats);
await this.seatsService.confirmSeats(dto.newSeatIds);
return { modified: true, bookingRef: dto.bookingRef };
}
async cancel(bookingRef: string, reason?: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0;
await this.prisma.bookingCancellation.create({
data: {
bookingId: booking.id,
cancelledBy: booking.passengerId,
reason,
refundAmount,
refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL',
refundStatus: 'PENDING'
}
});
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
return this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
}
@Cron(CronExpression.EVERY_MINUTE)

View File

@@ -0,0 +1,74 @@
import { Controller, Get, Post, Body, Query, UseGuards, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FraudService, FraudRuleConfig } from './fraud.service';
import { IamGuard, IamRoles } from '../../common/iam-adapter';
import { UserRole } from '@prisma/client';
@ApiTags('Fraud Detection')
@Controller('fraud')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
export class FraudController {
private readonly logger = new Logger(FraudController.name);
constructor(private fraudService: FraudService) {}
/**
* Get fraud alerts
*/
@Get('alerts')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Get fraud alerts' })
async getAlerts(
@Query('userId') userId?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
const alerts = await this.fraudService.getAlerts(userId, parseInt(limit || '100'), parseInt(offset || '0'));
return { data: alerts, total: alerts.length };
}
/**
* Get fraud rules
*/
@Get('rules')
@IamRoles('ADMIN')
@ApiOperation({ summary: 'Get fraud detection rules' })
async getRules() {
const rules = await this.fraudService.getRules();
return { data: rules };
}
/**
* Create or update fraud rule
*/
@Post('rules')
@IamRoles('ADMIN')
@ApiOperation({ summary: 'Create or update fraud rule' })
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
const rule = await this.fraudService.upsertRule(body.type, body.config);
return { data: rule, message: 'Rule updated successfully' };
}
/**
* Block user temporarily
*/
@Post('actions/block')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Block user temporarily' })
async blockUser(@Body() body: { userId: string; durationMinutes: number }) {
await this.fraudService.blockUserTemporarily(body.userId, body.durationMinutes);
return { message: `User blocked for ${body.durationMinutes} minutes` };
}
/**
* Unblock user
*/
@Post('actions/unblock')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Unblock user' })
async unblockUser(@Body() body: { userId: string }) {
await this.fraudService.unblockUser(body.userId);
return { message: 'User unblocked' };
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { FraudService } from './fraud.service';
import { FraudController } from './fraud.controller';
@Module({
imports: [HttpModule],
providers: [FraudService],
controllers: [FraudController],
exports: [FraudService],
})
export class FraudModule {}

View File

@@ -0,0 +1,252 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PrismaService } from '../../common/prisma.service';
export interface FraudRuleConfig {
type: 'VELOCITY' | 'HIGH_VALUE' | 'FAILED_PAYMENTS' | 'MULTIPLE_METHODS';
enabled: boolean;
threshold: number;
timeWindowMinutes?: number;
blockDurationMinutes?: number;
}
@Injectable()
export class FraudService {
private readonly logger = new Logger(FraudService.name);
constructor(private prisma: PrismaService) {}
/**
* Evaluate fraud rules and create alerts if triggered
*/
async evaluateRules(
userId: string,
eventType: 'booking.created' | 'payment.failed' | 'auth.login.failed',
context: Record<string, unknown>,
): Promise<{ triggered: boolean; rules: string[] }> {
const triggeredRules: string[] = [];
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) return { triggered: false, rules: [] };
// Check velocity rule (multiple bookings in short time)
if (eventType === 'booking.created') {
const velocityTriggered = await this.checkVelocityRule(userId);
if (velocityTriggered) {
triggeredRules.push('VELOCITY');
}
// Check high-value booking
const amount = (context.amountMinor as number) || 0;
const highValueTriggered = await this.checkHighValueRule(amount);
if (highValueTriggered) {
triggeredRules.push('HIGH_VALUE');
}
}
// Check repeated failed payments
if (eventType === 'payment.failed') {
const failedPaymentTriggered = await this.checkFailedPaymentRule(userId);
if (failedPaymentTriggered) {
triggeredRules.push('FAILED_PAYMENTS');
}
}
// Create alert if rules triggered
if (triggeredRules.length > 0) {
await this.createFraudAlert(userId, eventType, triggeredRules, context);
return { triggered: true, rules: triggeredRules };
}
return { triggered: false, rules: [] };
}
/**
* Check velocity rule: X bookings in Y minutes
*/
private async checkVelocityRule(userId: string): Promise<boolean> {
const rule = await this.prisma.fraudRule.findFirst({
where: { type: 'VELOCITY', enabled: true },
});
if (!rule) return false;
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 30;
const threshold = rule.threshold;
const bookingCount = await this.prisma.booking.count({
where: {
passengerId: userId,
createdAt: {
gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000),
},
},
});
return bookingCount > threshold;
}
/**
* Check high-value booking rule
*/
private async checkHighValueRule(amountMinor: number): Promise<boolean> {
const rule = await this.prisma.fraudRule.findFirst({
where: { type: 'HIGH_VALUE', enabled: true },
});
if (!rule) return false;
// threshold is in ETB (convert minor units to ETB)
const amountEtb = amountMinor / 100;
return amountEtb > rule.threshold;
}
/**
* Check failed payment rule: X failed attempts in Y minutes
*/
private async checkFailedPaymentRule(userId: string): Promise<boolean> {
const rule = await this.prisma.fraudRule.findFirst({
where: { type: 'FAILED_PAYMENTS', enabled: true },
});
if (!rule) return false;
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 60;
const threshold = rule.threshold;
const failedCount = await this.prisma.paymentIntent.count({
where: {
booking: { passengerId: userId },
status: 'FAILED',
updatedAt: {
gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000),
},
},
});
return failedCount > threshold;
}
/**
* Create a fraud alert
*/
private async createFraudAlert(
userId: string,
eventType: string,
triggeredRules: string[],
context: Record<string, unknown>,
): Promise<void> {
const alert = await this.prisma.fraudAlert.create({
data: {
userId,
eventType,
triggeredRules,
context: context as any,
severity: triggeredRules.length > 1 ? 'HIGH' : 'MEDIUM',
},
});
this.logger.warn(`Fraud alert created: ${alert.id} for user ${userId} - rules: ${triggeredRules.join(', ')}`);
// Trigger blocking if needed
if (triggeredRules.includes('HIGH_VALUE') || triggeredRules.length > 1) {
await this.blockUserTemporarily(userId, 30); // Block for 30 minutes
}
}
/**
* Block user temporarily
*/
async blockUserTemporarily(userId: string, durationMinutes: number): Promise<void> {
const blockedUntil = new Date(Date.now() + durationMinutes * 60 * 1000);
await this.prisma.user.update({
where: { id: userId },
data: { blockedUntil },
});
this.logger.warn(`User ${userId} blocked until ${blockedUntil.toISOString()}`);
}
/**
* Unblock user
*/
async unblockUser(userId: string): Promise<void> {
await this.prisma.user.update({
where: { id: userId },
data: { blockedUntil: null },
});
this.logger.log(`User ${userId} unblocked`);
}
/**
* Get all fraud alerts
*/
async getAlerts(userId?: string, limit = 100, offset = 0) {
return this.prisma.fraudAlert.findMany({
where: userId ? { userId } : {},
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
});
}
/**
* Create or update a fraud rule
*/
async upsertRule(
type: string,
config: FraudRuleConfig,
) {
return this.prisma.fraudRule.upsert({
where: { type: type as any },
update: {
enabled: config.enabled,
threshold: config.threshold,
config: config as any,
},
create: {
type: type as any,
enabled: config.enabled,
threshold: config.threshold,
config: config as any,
},
});
}
/**
* Get all fraud rules
*/
async getRules() {
return this.prisma.fraudRule.findMany();
}
/**
* Event listener for booking created
*/
@OnEvent('booking.created')
async onBookingCreated(payload: { booking: any }) {
await this.evaluateRules(payload.booking.passengerId, 'booking.created', {
bookingId: payload.booking.id,
amountMinor: payload.booking.totalMinor,
});
}
/**
* Event listener for payment failed
*/
@OnEvent('payment.failed')
async onPaymentFailed(payload: { intentId: string; userId: string }) {
await this.evaluateRules(payload.userId, 'payment.failed', {
intentId: payload.intentId,
});
}
/**
* Event listener for auth login failed
*/
@OnEvent('auth.login.failed')
async onLoginFailed(payload: { userId: string; email: string }) {
await this.evaluateRules(payload.userId, 'auth.login.failed', {
email: payload.email,
});
}
}

View File

@@ -0,0 +1,216 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as sgMail from '@sendgrid/mail';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
export interface NotificationChannel {
send(recipient: string, subject: string, body: string, context?: Record<string, unknown>): Promise<boolean>;
}
@Injectable()
export class EmailAdapter implements NotificationChannel {
private readonly logger = new Logger(EmailAdapter.name);
constructor(private readonly config: ConfigService) {
const apiKey = this.config.get<string>('SENDGRID_API_KEY');
if (apiKey) {
sgMail.setApiKey(apiKey);
this.logger.log('SendGrid Email adapter initialized');
} else {
this.logger.warn('SENDGRID_API_KEY not configured - emails will be logged only');
}
}
async send(
recipient: string,
subject: string,
body: string,
context?: Record<string, unknown>,
): Promise<boolean> {
const apiKey = this.config.get<string>('SENDGRID_API_KEY');
const fromEmail = this.config.get<string>('SENDGRID_FROM_EMAIL') || 'noreply@edr-platform.com';
if (!apiKey) {
this.logger.log(`[EMAIL MOCK] To: ${recipient} | Subject: ${subject} | Body: ${body.substring(0, 100)}`);
return true;
}
try {
const msg: sgMail.MailDataRequired = {
to: recipient,
from: fromEmail,
subject,
text: body,
html: this.formatHtml(body, context),
};
await sgMail.send(msg);
this.logger.log(`Email sent successfully to ${recipient}`);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Failed to send email to ${recipient}: ${message}`);
return false;
}
}
private formatHtml(body: string, context?: Record<string, unknown>): string {
const contextHtml = context
? `<div style="margin-top: 20px; padding: 10px; background: #f5f5f5; border-radius: 4px;">
<small>${JSON.stringify(context, null, 2)}</small>
</div>`
: '';
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #0066cc; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: white; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Ethio-Djibouti Railway</h2>
</div>
<div class="content">
${body.replace(/\n/g, '<br>')}
${contextHtml}
</div>
<div class="footer">
<p>© 2024 Ethio-Djibouti Railway. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
}
}
@Injectable()
export class SmsAdapter implements NotificationChannel {
private readonly logger = new Logger(SmsAdapter.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {
const provider = this.config.get<string>('SMS_PROVIDER');
this.logger.log(`SMS adapter initialized with provider: ${provider || 'MOCK'}`);
}
async send(
recipient: string,
subject: string,
body: string,
_context?: Record<string, unknown>,
): Promise<boolean> {
const provider = this.config.get<string>('SMS_PROVIDER');
const apiKey = this.config.get<string>('SMS_API_KEY');
if (!provider || !apiKey) {
this.logger.log(`[SMS MOCK] To: ${recipient} | Message: ${body.substring(0, 100)}`);
return true;
}
try {
switch (provider.toLowerCase()) {
case 'twilio':
return await this.sendViaTwilio(recipient, body);
case 'africastalking':
return await this.sendViaAfricasTalking(recipient, body);
default:
this.logger.warn(`Unknown SMS provider: ${provider}`);
return false;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Failed to send SMS to ${recipient}: ${message}`);
return false;
}
}
private async sendViaTwilio(to: string, body: string): Promise<boolean> {
const accountSid = this.config.get<string>('TWILIO_ACCOUNT_SID');
const authToken = this.config.get<string>('TWILIO_AUTH_TOKEN');
const fromNumber = this.config.get<string>('TWILIO_FROM_NUMBER');
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`;
const auth = Buffer.from(`${accountSid}:${authToken}`).toString('base64');
const response = await firstValueFrom(
this.http.post(
url,
new URLSearchParams({
To: to,
From: fromNumber || '',
Body: body,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${auth}`,
},
},
),
);
return response.status === 201;
}
private async sendViaAfricasTalking(to: string, body: string): Promise<boolean> {
const apiKey = this.config.get<string>('SMS_API_KEY');
const username = this.config.get<string>('AFRICASTALKING_USERNAME');
const from = this.config.get<string>('AFRICASTALKING_FROM');
const url = 'https://api.africastalking.com/version1/messaging';
const response = await firstValueFrom(
this.http.post(
url,
new URLSearchParams({
username: username || '',
to,
message: body,
from: from || '',
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'apiKey': apiKey || '',
},
},
),
);
return response.status === 201;
}
}
@Injectable()
export class PushAdapter implements NotificationChannel {
private readonly logger = new Logger(PushAdapter.name);
constructor(private readonly config: ConfigService) {
this.logger.log('Push notification adapter initialized');
}
async send(
recipient: string,
subject: string,
body: string,
context?: Record<string, unknown>,
): Promise<boolean> {
// Push notifications would typically use FCM/APNS
// For now, just log
this.logger.log(`[PUSH MOCK] To: ${recipient} | Title: ${subject} | Body: ${body.substring(0, 100)}`);
return true;
}
}

View File

@@ -1,7 +1,9 @@
import { Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { NotificationsService } from './notifications.service';
import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard, IamRoles } from '../../common/iam-adapter';
import { TestNotificationDto } from './notifications.dto';
@ApiTags('Notifications')
@Controller('notifications')
@@ -12,13 +14,32 @@ export class NotificationsController {
@Get(':passengerId')
@ApiOperation({ summary: 'Get notifications for passenger' })
getForPassenger(@Param('passengerId') id: string) { return this.service.getForPassenger(id); }
getForPassenger(@Param('passengerId') id: string) {
return this.service.getForPassenger(id);
}
@Patch(':id/read')
@ApiOperation({ summary: 'Mark notification as read' })
markRead(@Param('id') id: string) { return this.service.markRead(id); }
markRead(@Param('id') id: string) {
return this.service.markRead(id);
}
@Patch(':passengerId/read-all')
@ApiOperation({ summary: 'Mark all notifications as read' })
markAllRead(@Param('passengerId') id: string) { return this.service.markAllRead(id); }
markAllRead(@Param('passengerId') id: string) {
return this.service.markAllRead(id);
}
@Post('test')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Test notification delivery (Admin only)' })
async testNotification(@Body() dto: TestNotificationDto) {
return this.service.send(
dto.templateKey,
dto.recipient,
dto.context,
dto.channels as any,
);
}
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsEnum, IsOptional } from 'class-validator';
import { IsString, IsEnum, IsOptional, IsArray } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum NotificationCategoryEnum {
@@ -17,3 +17,21 @@ export class SendNotificationDto {
@ApiPropertyOptional({ example: 'edr://tickets/tkt_01' }) @IsOptional() @IsString() deepLink?: string;
@ApiPropertyOptional() @IsOptional() metadata?: Record<string, any>;
}
export class TestNotificationDto {
@ApiProperty({ example: 'booking.created' })
@IsString()
templateKey: string;
@ApiProperty({ example: 'user@example.com' })
@IsString()
recipient: string;
@ApiProperty({ example: { bookingRef: 'EDR123456', passengerName: 'John Doe' } })
context: Record<string, unknown>;
@ApiPropertyOptional({ example: ['EMAIL', 'SMS', 'IN_APP'] })
@IsOptional()
@IsArray()
channels?: string[];
}

View File

@@ -1,6 +1,13 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters';
@Module({ controllers: [NotificationsController], providers: [NotificationsService], exports: [NotificationsService] })
@Module({
imports: [HttpModule.register({ timeout: 10_000 })],
controllers: [NotificationsController],
providers: [NotificationsService, EmailAdapter, SmsAdapter, PushAdapter],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@@ -1,45 +1,263 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PrismaService } from '../../common/prisma.service';
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
import * as sgMail from '@sendgrid/mail';
import { EmailAdapter, SmsAdapter, PushAdapter, NotificationChannel } from './notification.adapters';
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
@Injectable()
export class NotificationsService {
constructor(private prisma: PrismaService) {
if (process.env.SENDGRID_API_KEY) sgMail.setApiKey(process.env.SENDGRID_API_KEY);
private readonly logger = new Logger(NotificationsService.name);
private readonly channels: Map<NotificationChannelType, NotificationChannel>;
constructor(
private prisma: PrismaService,
private emailAdapter: EmailAdapter,
private smsAdapter: SmsAdapter,
private pushAdapter: PushAdapter,
) {
this.channels = new Map<NotificationChannelType, NotificationChannel>([
['EMAIL', this.emailAdapter as NotificationChannel],
['SMS', this.smsAdapter as NotificationChannel],
['PUSH', this.pushAdapter as NotificationChannel],
]);
}
private sanitize(value: string): string {
return value.replace(/[\r\n]/g, ' ').replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#x27;' }[c] ?? c));
/**
* Send notification using template key and context
* @param templateKey - Template code from NotificationTemplate table
* @param recipient - User/Passenger ID or email/phone
* @param context - Variables to interpolate in template
* @param channels - Optional array of channels to use (defaults to user preferences)
*/
async send(
templateKey: string,
recipient: string,
context: Record<string, unknown>,
channels?: NotificationChannelType[],
): Promise<{ sent: boolean; channels: string[] }> {
const template = await this.prisma.notificationTemplate.findUnique({
where: { code: templateKey },
});
if (!template || !template.active) {
this.logger.warn(`Template ${templateKey} not found or inactive`);
return { sent: false, channels: [] };
}
const { subject, body } = this.interpolate(template, context);
const targetChannels = channels || await this.getUserPreferredChannels(recipient);
const sentChannels: string[] = [];
// Always create in-app notification
if (targetChannels.includes('IN_APP')) {
await this.createInAppNotification(recipient, subject, body, context);
sentChannels.push('IN_APP');
}
// Send via other channels
for (const channelType of targetChannels) {
if (channelType === 'IN_APP') continue;
const adapter = this.channels.get(channelType);
if (!adapter) {
this.logger.warn(`No adapter for channel: ${channelType}`);
continue;
}
const recipientAddress = await this.getRecipientAddress(recipient, channelType);
if (!recipientAddress) {
this.logger.warn(`No ${channelType} address for recipient: ${recipient}`);
continue;
}
const success = await adapter.send(recipientAddress, subject, body, context);
if (success) {
sentChannels.push(channelType);
}
}
return { sent: sentChannels.length > 0, channels: sentChannels };
}
async send(dto: SendNotificationDto) {
const notification = await this.prisma.notification.create({ data: { passengerId: dto.passengerId, title: dto.title, body: dto.body, category: dto.category as any, deepLink: dto.deepLink, metadata: dto.metadata } });
const passenger = await this.prisma.passenger.findUnique({ where: { id: dto.passengerId }, include: { user: true } });
if (passenger?.user) await this.sendEmail(passenger.user.email, this.sanitize(dto.title), this.sanitize(dto.body));
/**
* Legacy method for backward compatibility
*/
async sendDirect(dto: SendNotificationDto) {
const notification = await this.prisma.notification.create({
data: {
passengerId: dto.passengerId,
title: dto.title,
body: dto.body,
category: dto.category as any,
deepLink: dto.deepLink,
metadata: dto.metadata,
},
});
const passenger = await this.prisma.passenger.findUnique({
where: { id: dto.passengerId },
include: { user: true },
});
if (passenger?.user) {
await this.emailAdapter.send(
passenger.user.email,
this.sanitize(dto.title),
this.sanitize(dto.body),
);
}
return notification;
}
getForPassenger(passengerId: string) { return this.prisma.notification.findMany({ where: { passengerId }, orderBy: { createdAt: 'desc' }, take: 50 }); }
private async createInAppNotification(
recipient: string,
title: string,
body: string,
context: Record<string, unknown>,
): Promise<void> {
// Try to find passenger by ID or email
let passengerId = recipient;
markRead(id: string) { return this.prisma.notification.update({ where: { id }, data: { read: true } }); }
if (!recipient.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
const user = await this.prisma.user.findFirst({
where: {
OR: [{ email: recipient }, { phone: recipient }],
},
include: { passenger: true },
});
if (user?.passenger) {
passengerId = user.passenger.id;
} else {
this.logger.warn(`Could not find passenger for recipient: ${recipient}`);
return;
}
}
async markAllRead(passengerId: string) { await this.prisma.notification.updateMany({ where: { passengerId, read: false }, data: { read: true } }); return { updated: true }; }
await this.prisma.notification.create({
data: {
passengerId,
title,
body,
category: (context.category as any) || 'SYSTEM',
deepLink: context.deepLink as string,
metadata: context as any,
},
});
}
private interpolate(
template: { subject?: string | null; bodyTemplate: string },
context: Record<string, unknown>,
): { subject: string; body: string } {
const subject = template.subject || 'Notification';
let body = template.bodyTemplate;
// Simple template interpolation: {{variable}}
for (const [key, value] of Object.entries(context)) {
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
body = body.replace(regex, String(value));
}
return { subject, body };
}
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
const user = await this.prisma.user.findFirst({
where: {
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
},
include: { preferences: true },
});
if (!user?.preferences) {
return ['IN_APP', 'EMAIL'];
}
const channels: NotificationChannelType[] = ['IN_APP'];
if (user.preferences.emailEnabled) channels.push('EMAIL');
if (user.preferences.smsEnabled) channels.push('SMS');
if (user.preferences.pushEnabled) channels.push('PUSH');
return channels;
}
private async getRecipientAddress(
recipient: string,
channel: NotificationChannelType,
): Promise<string | null> {
const user = await this.prisma.user.findFirst({
where: {
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
},
});
if (!user) return null;
switch (channel) {
case 'EMAIL':
return user.email;
case 'SMS':
return user.phone;
case 'PUSH':
// Would need to fetch device push token
return user.id;
default:
return null;
}
}
private sanitize(value: string): string {
return value
.replace(/[\r\n]/g, ' ')
.replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#x27;' }[c] ?? c));
}
getForPassenger(passengerId: string) {
return this.prisma.notification.findMany({
where: { passengerId },
orderBy: { createdAt: 'desc' },
take: 50,
});
}
markRead(id: string) {
return this.prisma.notification.update({ where: { id }, data: { read: true } });
}
async markAllRead(passengerId: string) {
await this.prisma.notification.updateMany({
where: { passengerId, read: false },
data: { read: true },
});
return { updated: true };
}
@OnEvent('booking.created')
async onBookingCreated(payload: any) {
await this.send({ passengerId: payload.booking.passengerId, title: 'Booking Created', body: `Booking ${payload.booking.bookingRef} created. Complete payment within 15 minutes.`, category: NotificationCategoryEnum.BOOKING, deepLink: `edr://bookings/${payload.booking.bookingRef}`, metadata: { bookingRef: payload.booking.bookingRef } });
await this.send(
'booking.created',
payload.booking.passengerId,
{
bookingRef: payload.booking.bookingRef,
category: 'BOOKING',
deepLink: `edr://bookings/${payload.booking.bookingRef}`,
},
);
}
@OnEvent('payment.succeeded')
async onPaymentSucceeded(payload: any) {
await this.send({ passengerId: payload.booking.passengerId, title: 'Payment Successful', body: `Your ticket for ${payload.booking.bookingRef} is confirmed. Have a great journey!`, category: NotificationCategoryEnum.PAYMENT, deepLink: `edr://tickets/${payload.booking.bookingRef}`, metadata: { bookingRef: payload.booking.bookingRef } });
}
private async sendEmail(to: string, subject: string, text: string) {
if (!process.env.SENDGRID_API_KEY) { console.log(`[EMAIL] To: ${to} | Subject: ${subject}`); return; }
try { await sgMail.send({ to, from: process.env.SENDGRID_FROM_EMAIL || 'noreply@edr-platform.com', subject, text }); }
catch (e) { console.error('[EMAIL] Send error:', String(e instanceof Error ? e.message : e).replace(/[\r\n<>&"']/g, ' ')); }
await this.send(
'payment.succeeded',
payload.booking.passengerId,
{
bookingRef: payload.booking.bookingRef,
category: 'PAYMENT',
deepLink: `edr://tickets/${payload.booking.bookingRef}`,
},
);
}
}

View File

@@ -0,0 +1,270 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../../app.module';
import { PrismaService } from '../../common/prisma.service';
describe('Payments E2E', () => {
let app: INestApplication;
let prisma: PrismaService;
let authToken: string;
let bookingId: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
await app.init();
prisma = app.get<PrismaService>(PrismaService);
// Create test user and authenticate
const testUser = await prisma.user.create({
data: {
email: 'payment-test@example.com',
phone: '+251911111111',
fullName: 'Payment Test User',
passwordHash: '$2b$10$abcdefghijklmnopqrstuvwxyz', // Mock hash
role: 'PASSENGER',
},
});
const passenger = await prisma.passenger.create({
data: {
userId: testUser.id,
},
});
// Create wallet for test user
await prisma.walletAccount.create({
data: {
passengerId: passenger.id,
balanceMinor: 100000, // 1000 ETB
currency: 'ETB',
},
});
// Mock JWT token (in real test, call /auth/login)
authToken = 'mock-jwt-token';
// Create test booking
const station1 = await prisma.station.create({
data: {
code: 'TEST1',
name: 'Test Station 1',
city: 'Test City',
lat: 9.0,
lng: 38.0,
},
});
const station2 = await prisma.station.create({
data: {
code: 'TEST2',
name: 'Test Station 2',
city: 'Test City 2',
lat: 9.5,
lng: 38.5,
},
});
const service = await prisma.trainService.create({
data: {
number: 'TEST-001',
name: 'Test Service',
},
});
const trip = await prisma.trip.create({
data: {
serviceId: service.id,
originStationId: station1.id,
destinationStationId: station2.id,
departureAt: new Date(Date.now() + 86400000),
arrivalAt: new Date(Date.now() + 90000000),
durationMinutes: 60,
},
});
const coach = await prisma.coach.create({
data: {
tripId: trip.id,
label: 'A',
serviceClass: 'ECONOMY_REGULAR',
},
});
const seat = await prisma.seat.create({
data: {
coachId: coach.id,
row: 1,
col: 'A',
label: '1A',
status: 'AVAILABLE',
},
});
const booking = await prisma.booking.create({
data: {
bookingRef: 'TEST-BOOK-001',
passengerId: passenger.id,
tripId: trip.id,
status: 'PENDING_PAYMENT',
totalMinor: 50000, // 500 ETB
currency: 'ETB',
},
});
await prisma.bookingSeat.create({
data: {
bookingId: booking.id,
seatId: seat.id,
passengerName: 'Test Passenger',
},
});
bookingId = booking.id;
});
afterAll(async () => {
await prisma.$transaction([
prisma.bookingSeat.deleteMany(),
prisma.paymentIntent.deleteMany(),
prisma.booking.deleteMany(),
prisma.seat.deleteMany(),
prisma.coach.deleteMany(),
prisma.trip.deleteMany(),
prisma.trainService.deleteMany(),
prisma.station.deleteMany(),
prisma.walletLedgerEntry.deleteMany(),
prisma.walletAccount.deleteMany(),
prisma.passenger.deleteMany(),
prisma.user.deleteMany(),
]);
await app.close();
});
describe('POST /payments/initiate', () => {
it('should initiate wallet payment successfully', async () => {
const response = await request(app.getHttpServer())
.post('/payments/initiate')
.set('Authorization', `Bearer ${authToken}`)
.send({
bookingId,
method: 'WALLET',
})
.expect(201);
expect(response.body.intentId).toBeDefined();
expect(response.body.status).toBe('SUCCEEDED');
});
it('should return 400 for invalid payment method', async () => {
await request(app.getHttpServer())
.post('/payments/initiate')
.set('Authorization', `Bearer ${authToken}`)
.send({
bookingId,
method: 'INVALID_METHOD',
})
.expect(400);
});
it('should return 404 for non-existent booking', async () => {
await request(app.getHttpServer())
.post('/payments/initiate')
.set('Authorization', `Bearer ${authToken}`)
.send({
bookingId: 'non-existent-id',
method: 'WALLET',
})
.expect(404);
});
});
describe('GET /payments/intents/:bookingId', () => {
it('should get payment intent status', async () => {
const response = await request(app.getHttpServer())
.get(`/payments/intents/${bookingId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.intentId).toBeDefined();
expect(response.body.status).toBeDefined();
});
it('should return 404 for non-existent intent', async () => {
await request(app.getHttpServer())
.get('/payments/intents/non-existent-booking')
.set('Authorization', `Bearer ${authToken}`)
.expect(404);
});
});
describe('Webhook endpoints', () => {
it('should handle Telebirr webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/telebirr')
.send({
merch_order_id: 'TEST-ORDER-123',
payment_order_id: 'PAY-123',
trade_status: 'Completed',
sign: 'mock-signature',
})
.expect(200);
});
it('should handle CBE Birr webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/cbe-birr')
.send({
merchantId: 'TEST-MERCHANT',
merchantOrderId: 'TEST-ORDER-123',
orderId: 'CBE-ORDER-123',
status: 'SUCCESS',
signature: 'mock-signature',
})
.expect(200);
});
it('should handle eBirr webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/ebirr')
.send({
merchantCode: 'TEST-MERCHANT',
orderNo: 'TEST-ORDER-123',
tradeStatus: 'TRADE_SUCCESS',
timestamp: Date.now(),
sign: 'mock-signature',
})
.expect(200);
});
it('should handle Card webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/card')
.set('stripe-signature', 'mock-signature')
.send({
id: 'evt_123',
type: 'payment_intent.succeeded',
data: {
object: {
id: 'pi_123',
status: 'succeeded',
amount: 50000,
currency: 'ETB',
metadata: {
merchantOrderId: 'TEST-ORDER-123',
bookingRef: 'TEST-BOOK-001',
},
},
},
created: Math.floor(Date.now() / 1000),
})
.expect(200);
});
});
});

View File

@@ -5,12 +5,28 @@ import { PaymentsService } from './payments.service';
import { SeatsModule } from '../seats/seats.module';
import { TicketsModule } from '../tickets/tickets.module';
import { TelebirrProvider } from './providers/telebirr.provider';
import { CbeBirrProvider } from './providers/cbe-birr.provider';
import { EBirrProvider } from './providers/ebirr.provider';
import { CardProvider } from './providers/card.provider';
import { WebhooksController } from './webhooks/webhooks.controller';
import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service';
import { CbeBirrWebhookService } from './webhooks/cbe-birr-webhook.service';
import { EBirrWebhookService } from './webhooks/ebirr-webhook.service';
import { CardWebhookService } from './webhooks/card-webhook.service';
@Module({
imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })],
controllers: [PaymentsController, WebhooksController],
providers: [PaymentsService, TelebirrProvider, TelebirrWebhookService],
providers: [
PaymentsService,
TelebirrProvider,
CbeBirrProvider,
EBirrProvider,
CardProvider,
TelebirrWebhookService,
CbeBirrWebhookService,
EBirrWebhookService,
CardWebhookService,
],
})
export class PaymentsModule {}

View File

@@ -0,0 +1,328 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PaymentsService } from './payments.service';
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { TelebirrProvider } from './providers/telebirr.provider';
import { CbeBirrProvider } from './providers/cbe-birr.provider';
import { EBirrProvider } from './providers/ebirr.provider';
import { CardProvider } from './providers/card.provider';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { BadRequestException, NotFoundException } from '@nestjs/common';
describe('PaymentsService', () => {
let service: PaymentsService;
let prisma: PrismaService;
let seatsService: SeatsService;
let ticketsService: TicketsService;
let eventEmitter: EventEmitter2;
const mockPrisma = {
booking: {
findUnique: jest.fn(),
update: jest.fn(),
},
paymentIntent: {
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
upsert: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
walletAccount: {
findUnique: jest.fn(),
update: jest.fn(),
},
walletLedgerEntry: {
create: jest.fn(),
},
loyaltyAccount: {
findUnique: jest.fn(),
update: jest.fn(),
},
loyaltyLedgerEntry: {
create: jest.fn(),
},
$transaction: jest.fn((callback) => callback(mockPrisma)),
};
const mockSeatsService = {
confirmSeats: jest.fn(),
releaseSeats: jest.fn(),
};
const mockTicketsService = {
generate: jest.fn(),
};
const mockEventEmitter = {
emit: jest.fn(),
};
const mockTelebirrProvider = {
method: PaymentMethodType.TELEBIRR,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
const mockCbeBirrProvider = {
method: PaymentMethodType.CBE_BIRR,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
const mockEBirrProvider = {
method: PaymentMethodType.EBIRR,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
const mockCardProvider = {
method: PaymentMethodType.CARD,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PaymentsService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: SeatsService, useValue: mockSeatsService },
{ provide: TicketsService, useValue: mockTicketsService },
{ provide: EventEmitter2, useValue: mockEventEmitter },
{ provide: TelebirrProvider, useValue: mockTelebirrProvider },
{ provide: CbeBirrProvider, useValue: mockCbeBirrProvider },
{ provide: EBirrProvider, useValue: mockEBirrProvider },
{ provide: CardProvider, useValue: mockCardProvider },
],
}).compile();
service = module.get<PaymentsService>(PaymentsService);
prisma = module.get<PrismaService>(PrismaService);
seatsService = module.get<SeatsService>(SeatsService);
ticketsService = module.get<TicketsService>(TicketsService);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
jest.clearAllMocks();
});
describe('initiatePayment', () => {
const mockBooking = {
id: 'booking-1',
bookingRef: 'EDR123456',
passengerId: 'passenger-1',
totalMinor: 50000,
currency: 'ETB',
status: 'PENDING_PAYMENT',
seats: [{ id: 'seat-1', seatId: 'seat-id-1' }],
};
it('should throw NotFoundException if booking not found', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(null);
await expect(
service.initiatePayment({
bookingId: 'invalid',
method: 'TELEBIRR' as any,
}),
).rejects.toThrow(NotFoundException);
});
it('should throw BadRequestException if booking not payable', async () => {
mockPrisma.booking.findUnique.mockResolvedValue({
...mockBooking,
status: 'CONFIRMED',
});
await expect(
service.initiatePayment({
bookingId: 'booking-1',
method: 'TELEBIRR' as any,
}),
).rejects.toThrow(BadRequestException);
});
it('should initiate Telebirr payment successfully', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockTelebirrProvider.initiate.mockResolvedValue({
providerOrderId: 'TB-ORDER-123',
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
expiresAt: new Date(),
rawInitiation: {},
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: 'MERCH-123',
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'TELEBIRR' as any,
});
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
expect(mockTelebirrProvider.initiate).toHaveBeenCalled();
});
it('should initiate CBE Birr payment successfully', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockCbeBirrProvider.initiate.mockResolvedValue({
providerOrderId: 'CBE-ORDER-123',
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
expiresAt: new Date(),
rawInitiation: {},
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: 'MERCH-123',
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'CBE_BIRR' as any,
});
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
expect(mockCbeBirrProvider.initiate).toHaveBeenCalled();
});
it('should initiate wallet payment and debit successfully', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPrisma.walletAccount.findUnique.mockResolvedValue({
id: 'wallet-1',
passengerId: 'passenger-1',
balanceMinor: 100000,
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.PROCESSING,
});
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.SUCCEEDED,
bookingId: 'booking-1',
});
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
id: 'loyalty-1',
pointsBalance: 100,
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'WALLET' as any,
});
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
expect(mockSeatsService.confirmSeats).toHaveBeenCalled();
expect(mockTicketsService.generate).toHaveBeenCalled();
});
it('should fail wallet payment with insufficient balance', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPrisma.walletAccount.findUnique.mockResolvedValue({
id: 'wallet-1',
passengerId: 'passenger-1',
balanceMinor: 10000, // Less than booking total
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.FAILED,
failureCode: 'INSUFFICIENT_BALANCE',
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'WALLET' as any,
});
expect(result.status).toBe(PaymentIntentStatus.FAILED);
});
});
describe('finalizePaymentSuccess', () => {
it('should finalize payment and issue ticket', async () => {
const mockIntent = {
id: 'intent-1',
bookingId: 'booking-1',
status: PaymentIntentStatus.PROCESSING,
};
const mockBooking = {
id: 'booking-1',
passengerId: 'passenger-1',
totalMinor: 50000,
seats: [{ seatId: 'seat-1' }],
};
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
id: 'loyalty-1',
pointsBalance: 100,
});
const result = await service.finalizePaymentSuccess({
intentId: 'intent-1',
providerTxnId: 'TXN-123',
});
expect(result.alreadyFinalized).toBe(false);
expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(['seat-1']);
expect(mockTicketsService.generate).toHaveBeenCalledWith('booking-1');
expect(mockEventEmitter.emit).toHaveBeenCalledWith('payment.succeeded', {
booking: mockBooking,
});
});
it('should return alreadyFinalized if payment already succeeded', async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.SUCCEEDED,
});
const result = await service.finalizePaymentSuccess({
intentId: 'intent-1',
});
expect(result.alreadyFinalized).toBe(true);
});
});
describe('getIntentByBookingId', () => {
it('should return intent status', async () => {
const mockIntent = {
id: 'intent-1',
bookingId: 'booking-1',
status: PaymentIntentStatus.SUCCEEDED,
method: PaymentMethodType.TELEBIRR,
paidAt: new Date(),
merchantOrderId: 'MERCH-123',
updatedAt: new Date(),
};
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
const result = await service.getIntentByBookingId('booking-1');
expect(result.intentId).toBe('intent-1');
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
});
it('should throw NotFoundException if intent not found', async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
await expect(service.getIntentByBookingId('invalid')).rejects.toThrow(
NotFoundException,
);
});
});
});

View File

@@ -5,9 +5,11 @@ import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto } from './payments.dto';
import { cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters';
import { PaymentProvider, ProviderStatus } from './payments.types';
import { TelebirrProvider } from './providers/telebirr.provider';
import { CbeBirrProvider } from './providers/cbe-birr.provider';
import { EBirrProvider } from './providers/ebirr.provider';
import { CardProvider } from './providers/card.provider';
import { createMerchantOrderId } from './providers/telebirr.crypto';
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
@@ -27,9 +29,15 @@ export class PaymentsService {
private ticketsService: TicketsService,
private eventEmitter: EventEmitter2,
private telebirrProvider: TelebirrProvider,
private cbeBirrProvider: CbeBirrProvider,
private eBirrProvider: EBirrProvider,
private cardProvider: CardProvider,
) {
this.providers = new Map<PaymentMethodType, PaymentProvider>([
[PaymentMethodType.TELEBIRR, this.telebirrProvider],
[PaymentMethodType.CBE_BIRR, this.cbeBirrProvider],
[PaymentMethodType.EBIRR, this.eBirrProvider],
[PaymentMethodType.CARD, this.cardProvider],
]);
}
@@ -61,8 +69,7 @@ export class PaymentsService {
return this.initiateProviderPayment(booking, provider);
}
// TODO: convert CBE_BIRR, EBIRR, CARD into PaymentProvider implementations.
return this.initiateStubPayment(booking, method);
throw new BadRequestException(`Unsupported payment method: ${method}`);
}
private async initiateWalletPayment(
@@ -170,41 +177,7 @@ export class PaymentsService {
return this.formatIntentResponse(intent);
}
private async initiateStubPayment(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
method: PaymentMethodType,
): Promise<InitiateResponseDto> {
const adapters = {
[PaymentMethodType.CBE_BIRR]: cbeBirrAdapter,
[PaymentMethodType.EBIRR]: eBirrAdapter,
[PaymentMethodType.CARD]: cardAdapter,
} as Partial<Record<PaymentMethodType, (a: number, ref: string) => Promise<{ success: boolean; providerRef: string }>>>;
const adapter = adapters[method];
if (!adapter) {
throw new BadRequestException(`Unsupported payment method: ${method}`);
}
const result = await adapter(booking.totalMinor, booking.bookingRef);
const status = result.success ? PaymentIntentStatus.PROCESSING : PaymentIntentStatus.FAILED;
const intent = await this.prisma.paymentIntent.upsert({
where: { bookingId: booking.id },
update: { status, providerRef: result.providerRef },
create: {
bookingId: booking.id,
amountMinor: booking.totalMinor,
method,
status,
providerRef: result.providerRef,
},
});
if (result.success) {
await this.finalizePaymentSuccess({ intentId: intent.id });
const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
return this.formatIntentResponse(refreshed);
}
return this.formatIntentResponse(intent);
}
private formatIntentResponse(
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,

View File

@@ -0,0 +1,218 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'node:crypto';
import {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
} from '../payments.types';
interface CardInitiateRequest {
amount: number;
currency: string;
description: string;
metadata: {
merchantOrderId: string;
bookingRef: string;
};
return_url: string;
webhook_url: string;
}
interface CardInitiateResponse {
id: string;
status: string;
client_secret: string;
checkout_url: string;
expires_at: number;
}
interface CardQueryResponse {
id: string;
status: string;
amount: number;
currency: string;
transaction_id?: string;
paid_at?: number;
failure_code?: string;
failure_message?: string;
}
@Injectable()
export class CardProvider implements PaymentProvider {
readonly method = PaymentMethodType.CARD;
private readonly logger = new Logger(CardProvider.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const amount = input.amountMinor / 100;
const requestBody: CardInitiateRequest = {
amount,
currency: input.currency,
description: `EDR Train Booking ${input.bookingRef}`,
metadata: {
merchantOrderId: input.merchantOrderId,
bookingRef: input.bookingRef,
},
return_url: this.returnUrl,
webhook_url: this.webhookUrl,
};
const response = await this.postJson<CardInitiateResponse>(
`${this.baseUrl}/v1/payment_intents`,
requestBody,
);
if (!response.id) {
throw new Error(`Card gateway initiate failed: ${JSON.stringify(response)}`);
}
const expiresAt = new Date(response.expires_at * 1000);
return {
providerOrderId: response.id,
clientAction: { type: 'REDIRECT', url: response.checkout_url },
expiresAt,
rawInitiation: {
request: requestBody,
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
// For card payments, we need to find the payment intent by metadata
// In a real implementation, we'd store the provider order ID and use it directly
const response = await this.getJson<CardQueryResponse>(
`${this.baseUrl}/v1/payment_intents/search?metadata[merchantOrderId]=${merchantOrderId}`,
);
const mapped = this.mapStatus(response.status);
return {
status: mapped,
providerTxnId: response.transaction_id,
failureCode: response.failure_code,
failureMessage: response.failure_message,
rawResponse: response as unknown as Record<string, unknown>,
};
}
verifyWebhookSignature(payload: Record<string, unknown>, signature: string): boolean {
const payloadString = JSON.stringify(payload);
const expectedSignature = crypto
.createHmac('sha256', this.webhookSecret)
.update(payloadString)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
} catch {
return false;
}
}
mapWebhookStatus(status: string): PaymentIntentStatus {
return this.mapStatus(status);
}
private mapStatus(status: string): PaymentIntentStatus {
switch (status?.toLowerCase()) {
case 'succeeded':
case 'paid':
return PaymentIntentStatus.SUCCEEDED;
case 'failed':
case 'canceled':
case 'expired':
return PaymentIntentStatus.FAILED;
case 'requires_payment_method':
case 'requires_confirmation':
case 'requires_action':
return PaymentIntentStatus.REQUIRES_ACTION;
case 'processing':
return PaymentIntentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
}
}
private async postJson<T>(url: string, body: unknown): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
timeout: 10_000,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`Card Gateway POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Card Gateway POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(`Card Gateway POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private async getJson<T>(url: string): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
},
timeout: 10_000,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.get<T>(url, config));
this.logger.debug(`Card Gateway GET ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Card Gateway GET ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(`Card Gateway GET ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private get baseUrl(): string {
return this.config.get<string>('card.baseUrl') ?? '';
}
private get apiKey(): string {
return this.config.get<string>('card.apiKey') ?? '';
}
private get webhookSecret(): string {
return this.config.get<string>('card.webhookSecret') ?? '';
}
private get webhookUrl(): string {
return this.config.get<string>('card.webhookUrl') ?? '';
}
private get returnUrl(): string {
return this.config.get<string>('card.returnUrl') ?? '';
}
}

View File

@@ -0,0 +1,215 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'node:crypto';
import {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
} from '../payments.types';
interface CbeBirrInitiateRequest {
merchantId: string;
merchantOrderId: string;
amount: string;
currency: string;
description: string;
returnUrl: string;
notifyUrl: string;
timestamp: string;
signature: string;
}
interface CbeBirrInitiateResponse {
success: boolean;
orderId: string;
paymentUrl: string;
expiresIn: number;
}
interface CbeBirrQueryResponse {
success: boolean;
orderId: string;
status: string;
transactionId?: string;
amount?: string;
paidAt?: string;
}
@Injectable()
export class CbeBirrProvider implements PaymentProvider {
readonly method = PaymentMethodType.CBE_BIRR;
private readonly logger = new Logger(CbeBirrProvider.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const amount = (input.amountMinor / 100).toFixed(2);
const timestamp = new Date().toISOString();
const requestBody: CbeBirrInitiateRequest = {
merchantId: this.merchantId,
merchantOrderId: input.merchantOrderId,
amount,
currency: input.currency,
description: `EDR Booking ${input.bookingRef}`,
returnUrl: this.returnUrl,
notifyUrl: this.notifyUrl,
timestamp,
signature: this.signRequest({
merchantId: this.merchantId,
merchantOrderId: input.merchantOrderId,
amount,
timestamp,
}),
};
const response = await this.postJson<CbeBirrInitiateResponse>(
`${this.baseUrl}/api/v1/payment/initiate`,
requestBody,
);
if (!response.success || !response.orderId) {
throw new Error(`CBE Birr initiate failed: ${JSON.stringify(response)}`);
}
const expiresAt = new Date(Date.now() + response.expiresIn * 1000);
return {
providerOrderId: response.orderId,
clientAction: { type: 'REDIRECT', url: response.paymentUrl },
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const timestamp = new Date().toISOString();
const signature = this.signRequest({
merchantId: this.merchantId,
merchantOrderId,
timestamp,
});
const response = await this.postJson<CbeBirrQueryResponse>(
`${this.baseUrl}/api/v1/payment/query`,
{
merchantId: this.merchantId,
merchantOrderId,
timestamp,
signature,
},
);
const mapped = this.mapStatus(response.status);
return {
status: mapped,
providerTxnId: response.transactionId,
failureCode: mapped === PaymentIntentStatus.FAILED ? response.status : undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
const { signature, ...data } = payload;
if (!signature || typeof signature !== 'string') return false;
const expectedSignature = this.signRequest(data);
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
}
mapWebhookStatus(status: string): PaymentIntentStatus {
return this.mapStatus(status);
}
private mapStatus(status: string): PaymentIntentStatus {
switch (status?.toUpperCase()) {
case 'SUCCESS':
case 'COMPLETED':
return PaymentIntentStatus.SUCCEEDED;
case 'FAILED':
case 'REJECTED':
case 'EXPIRED':
return PaymentIntentStatus.FAILED;
case 'PENDING':
return PaymentIntentStatus.REQUIRES_ACTION;
case 'PROCESSING':
return PaymentIntentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
}
}
private signRequest(data: Record<string, unknown>): string {
const sortedKeys = Object.keys(data).sort();
const signString = sortedKeys
.map((key) => `${key}=${data[key]}`)
.join('&');
return crypto
.createHmac('sha256', this.secretKey)
.update(signString)
.digest('hex');
}
private async postJson<T>(url: string, body: unknown): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
'Content-Type': 'application/json',
'X-Merchant-Id': this.merchantId,
},
timeout: 10_000,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`CBE Birr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`CBE Birr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(`CBE Birr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private sanitize(body: CbeBirrInitiateRequest): Record<string, unknown> {
const { signature: _signature, ...rest } = body;
return rest;
}
private get baseUrl(): string {
return this.config.get<string>('cbe.baseUrl') ?? '';
}
private get merchantId(): string {
return this.config.get<string>('cbe.merchantId') ?? '';
}
private get secretKey(): string {
return this.config.get<string>('cbe.secretKey') ?? '';
}
private get notifyUrl(): string {
return this.config.get<string>('cbe.notifyUrl') ?? '';
}
private get returnUrl(): string {
return this.config.get<string>('cbe.returnUrl') ?? '';
}
}

View File

@@ -0,0 +1,228 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'node:crypto';
import {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
} from '../payments.types';
interface EBirrInitiateRequest {
merchantCode: string;
orderNo: string;
amount: number;
currency: string;
subject: string;
body: string;
notifyUrl: string;
returnUrl: string;
timestamp: number;
sign: string;
}
interface EBirrInitiateResponse {
code: string;
message: string;
data?: {
orderNo: string;
payUrl: string;
expireTime: number;
};
}
interface EBirrQueryResponse {
code: string;
message: string;
data?: {
orderNo: string;
tradeStatus: string;
tradeNo?: string;
totalAmount?: number;
payTime?: number;
};
}
@Injectable()
export class EBirrProvider implements PaymentProvider {
readonly method = PaymentMethodType.EBIRR;
private readonly logger = new Logger(EBirrProvider.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const amount = input.amountMinor / 100;
const timestamp = Date.now();
const requestBody: EBirrInitiateRequest = {
merchantCode: this.merchantCode,
orderNo: input.merchantOrderId,
amount,
currency: input.currency,
subject: `EDR Ticket`,
body: `Train booking ${input.bookingRef}`,
notifyUrl: this.notifyUrl,
returnUrl: this.returnUrl,
timestamp,
sign: this.signRequest({
merchantCode: this.merchantCode,
orderNo: input.merchantOrderId,
amount,
timestamp,
}),
};
const response = await this.postJson<EBirrInitiateResponse>(
`${this.baseUrl}/gateway/api/pay/create`,
requestBody,
);
if (response.code !== '0000' || !response.data?.orderNo) {
throw new Error(`eBirr initiate failed: ${response.message}`);
}
const expiresAt = new Date(response.data.expireTime);
return {
providerOrderId: response.data.orderNo,
clientAction: { type: 'REDIRECT', url: response.data.payUrl },
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const timestamp = Date.now();
const requestBody = {
merchantCode: this.merchantCode,
orderNo: merchantOrderId,
timestamp,
sign: this.signRequest({
merchantCode: this.merchantCode,
orderNo: merchantOrderId,
timestamp,
}),
};
const response = await this.postJson<EBirrQueryResponse>(
`${this.baseUrl}/gateway/api/pay/query`,
requestBody,
);
if (response.code !== '0000' || !response.data) {
throw new Error(`eBirr query failed: ${response.message}`);
}
const mapped = this.mapStatus(response.data.tradeStatus);
return {
status: mapped,
providerTxnId: response.data.tradeNo,
failureCode: mapped === PaymentIntentStatus.FAILED ? response.data.tradeStatus : undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
const { sign, ...data } = payload;
if (!sign || typeof sign !== 'string') return false;
const expectedSign = this.signRequest(data);
return crypto.timingSafeEqual(
Buffer.from(sign),
Buffer.from(expectedSign),
);
}
mapWebhookStatus(tradeStatus: string): PaymentIntentStatus {
return this.mapStatus(tradeStatus);
}
private mapStatus(tradeStatus: string): PaymentIntentStatus {
switch (tradeStatus?.toUpperCase()) {
case 'TRADE_SUCCESS':
case 'SUCCESS':
return PaymentIntentStatus.SUCCEEDED;
case 'TRADE_CLOSED':
case 'TRADE_FAILED':
case 'FAILED':
return PaymentIntentStatus.FAILED;
case 'WAIT_BUYER_PAY':
case 'PENDING':
return PaymentIntentStatus.REQUIRES_ACTION;
case 'PROCESSING':
return PaymentIntentStatus.PROCESSING;
default:
return PaymentIntentStatus.PROCESSING;
}
}
private signRequest(data: Record<string, unknown>): string {
const sortedKeys = Object.keys(data).sort();
const signString = sortedKeys
.map((key) => `${key}=${data[key]}`)
.join('&') + `&key=${this.secretKey}`;
return crypto
.createHash('md5')
.update(signString)
.digest('hex')
.toUpperCase();
}
private async postJson<T>(url: string, body: unknown): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
'Content-Type': 'application/json',
},
timeout: 10_000,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(`eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private sanitize(body: EBirrInitiateRequest): Record<string, unknown> {
const { sign: _sign, ...rest } = body;
return rest;
}
private get baseUrl(): string {
return this.config.get<string>('ebirr.baseUrl') ?? '';
}
private get merchantCode(): string {
return this.config.get<string>('ebirr.merchantCode') ?? '';
}
private get secretKey(): string {
return this.config.get<string>('ebirr.secretKey') ?? '';
}
private get notifyUrl(): string {
return this.config.get<string>('ebirr.notifyUrl') ?? '';
}
private get returnUrl(): string {
return this.config.get<string>('ebirr.returnUrl') ?? '';
}
}

View File

@@ -0,0 +1,147 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
import { CardProvider } from '../providers/card.provider';
export interface CardWebhookPayload {
id: string;
type: string;
data: {
object: {
id: string;
status: string;
amount: number;
currency: string;
metadata: {
merchantOrderId: string;
bookingRef: string;
};
transaction_id?: string;
paid_at?: number;
failure_code?: string;
failure_message?: string;
};
};
created: number;
}
@Injectable()
export class CardWebhookService {
private readonly logger = new Logger(CardWebhookService.name);
constructor(
private readonly prisma: PrismaService,
private readonly provider: CardProvider,
private readonly payments: PaymentsService,
) {}
async handle(payload: CardWebhookPayload, signature: string): Promise<void> {
const merchantOrderId = payload.data.object.metadata.merchantOrderId;
const externalEventId = `${payload.id}_${payload.type}`;
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
signature,
);
const eventRow = await this.persistEvent({
externalEventId,
merchantOrderId,
providerTxnId: payload.data.object.transaction_id,
signatureValid,
status: payload.data.object.status,
payload,
});
if (!eventRow) {
this.logger.log(`Card webhook duplicate: ${externalEventId} — short-circuit OK`);
return;
}
if (!signatureValid) {
this.logger.warn(`Card webhook signature invalid for merchantOrderId=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'signature-invalid');
return;
}
const intent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
});
if (!intent) {
this.logger.warn(`Card webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'intent-not-found');
return;
}
const mapped = this.provider.mapWebhookStatus(payload.data.object.status);
try {
if (mapped === PaymentIntentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.data.object.transaction_id,
paidAt: payload.data.object.paid_at ? new Date(payload.data.object.paid_at * 1000) : undefined,
});
} else if (mapped === PaymentIntentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.data.object.failure_code,
failureMessage: payload.data.object.failure_message,
});
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: {
status: mapped,
providerTxnId: payload.data.object.transaction_id ?? undefined,
},
});
}
await this.markProcessed(eventRow.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Card webhook processing failed for ${merchantOrderId}: ${message}`);
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
throw err;
}
}
private async persistEvent(input: {
externalEventId: string;
merchantOrderId: string;
providerTxnId?: string;
signatureValid: boolean;
status: string;
payload: CardWebhookPayload;
}): Promise<{ id: string } | null> {
try {
return await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.CARD,
externalEventId: input.externalEventId,
merchantOrderId: input.merchantOrderId,
providerTxnId: input.providerTxnId,
signatureValid: input.signatureValid,
status: input.status,
payload: input.payload as unknown as Prisma.InputJsonValue,
},
select: { id: true },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
return null;
}
throw err;
}
}
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
await this.prisma.paymentWebhookEvent.update({
where: { id: eventId },
data: { processedAt: new Date(), processingError },
});
}
}

View File

@@ -0,0 +1,133 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
import { CbeBirrProvider } from '../providers/cbe-birr.provider';
export interface CbeBirrWebhookPayload {
merchantId: string;
merchantOrderId: string;
orderId: string;
status: string;
transactionId?: string;
amount?: string;
currency?: string;
paidAt?: string;
signature: string;
[key: string]: unknown;
}
@Injectable()
export class CbeBirrWebhookService {
private readonly logger = new Logger(CbeBirrWebhookService.name);
constructor(
private readonly prisma: PrismaService,
private readonly provider: CbeBirrProvider,
private readonly payments: PaymentsService,
) {}
async handle(payload: CbeBirrWebhookPayload): Promise<void> {
const merchantOrderId = payload.merchantOrderId;
const externalEventId = `${payload.orderId}_${payload.status}`;
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const eventRow = await this.persistEvent({
externalEventId,
merchantOrderId,
providerTxnId: payload.transactionId ?? payload.orderId,
signatureValid,
status: payload.status,
payload,
});
if (!eventRow) {
this.logger.log(`CBE Birr webhook duplicate: ${externalEventId} — short-circuit OK`);
return;
}
if (!signatureValid) {
this.logger.warn(`CBE Birr webhook signature invalid for merchantOrderId=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'signature-invalid');
return;
}
const intent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
});
if (!intent) {
this.logger.warn(`CBE Birr webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'intent-not-found');
return;
}
const mapped = this.provider.mapWebhookStatus(payload.status);
try {
if (mapped === PaymentIntentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.transactionId ?? payload.orderId,
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
});
} else if (mapped === PaymentIntentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.status,
});
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: { status: mapped, providerTxnId: payload.transactionId ?? undefined },
});
}
await this.markProcessed(eventRow.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`CBE Birr webhook processing failed for ${merchantOrderId}: ${message}`);
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
throw err;
}
}
private async persistEvent(input: {
externalEventId: string;
merchantOrderId: string;
providerTxnId?: string;
signatureValid: boolean;
status: string;
payload: CbeBirrWebhookPayload;
}): Promise<{ id: string } | null> {
try {
return await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.CBE_BIRR,
externalEventId: input.externalEventId,
merchantOrderId: input.merchantOrderId,
providerTxnId: input.providerTxnId,
signatureValid: input.signatureValid,
status: input.status,
payload: input.payload as unknown as Prisma.InputJsonValue,
},
select: { id: true },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
return null;
}
throw err;
}
}
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
await this.prisma.paymentWebhookEvent.update({
where: { id: eventId },
data: { processedAt: new Date(), processingError },
});
}
}

View File

@@ -0,0 +1,133 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
import { EBirrProvider } from '../providers/ebirr.provider';
export interface EBirrWebhookPayload {
merchantCode: string;
orderNo: string;
tradeStatus: string;
tradeNo?: string;
totalAmount?: number;
currency?: string;
payTime?: number;
timestamp: number;
sign: string;
[key: string]: unknown;
}
@Injectable()
export class EBirrWebhookService {
private readonly logger = new Logger(EBirrWebhookService.name);
constructor(
private readonly prisma: PrismaService,
private readonly provider: EBirrProvider,
private readonly payments: PaymentsService,
) {}
async handle(payload: EBirrWebhookPayload): Promise<void> {
const merchantOrderId = payload.orderNo;
const externalEventId = `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`;
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const eventRow = await this.persistEvent({
externalEventId,
merchantOrderId,
providerTxnId: payload.tradeNo,
signatureValid,
status: payload.tradeStatus,
payload,
});
if (!eventRow) {
this.logger.log(`eBirr webhook duplicate: ${externalEventId} — short-circuit OK`);
return;
}
if (!signatureValid) {
this.logger.warn(`eBirr webhook signature invalid for orderNo=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'signature-invalid');
return;
}
const intent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
});
if (!intent) {
this.logger.warn(`eBirr webhook: no PaymentIntent for orderNo=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'intent-not-found');
return;
}
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
try {
if (mapped === PaymentIntentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.tradeNo,
paidAt: payload.payTime ? new Date(payload.payTime) : undefined,
});
} else if (mapped === PaymentIntentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.tradeStatus,
});
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: { status: mapped, providerTxnId: payload.tradeNo ?? undefined },
});
}
await this.markProcessed(eventRow.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`eBirr webhook processing failed for ${merchantOrderId}: ${message}`);
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
throw err;
}
}
private async persistEvent(input: {
externalEventId: string;
merchantOrderId: string;
providerTxnId?: string;
signatureValid: boolean;
status: string;
payload: EBirrWebhookPayload;
}): Promise<{ id: string } | null> {
try {
return await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.EBIRR,
externalEventId: input.externalEventId,
merchantOrderId: input.merchantOrderId,
providerTxnId: input.providerTxnId,
signatureValid: input.signatureValid,
status: input.status,
payload: input.payload as unknown as Prisma.InputJsonValue,
},
select: { id: true },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
return null;
}
throw err;
}
}
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
await this.prisma.paymentWebhookEvent.update({
where: { id: eventId },
data: { processedAt: new Date(), processingError },
});
}
}

View File

@@ -1,16 +1,33 @@
import { Body, Controller, HttpCode, HttpStatus, Logger, Post } from '@nestjs/common';
import { Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
TelebirrWebhookPayload,
TelebirrWebhookService,
} from './telebirr-webhook.service';
import {
CbeBirrWebhookPayload,
CbeBirrWebhookService,
} from './cbe-birr-webhook.service';
import {
EBirrWebhookPayload,
EBirrWebhookService,
} from './ebirr-webhook.service';
import {
CardWebhookPayload,
CardWebhookService,
} from './card-webhook.service';
@ApiTags('Payment Webhooks')
@Controller('payments/webhooks')
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
constructor(private readonly telebirr: TelebirrWebhookService) {}
constructor(
private readonly telebirr: TelebirrWebhookService,
private readonly cbeBirr: CbeBirrWebhookService,
private readonly eBirr: EBirrWebhookService,
private readonly card: CardWebhookService,
) {}
@Post('telebirr')
@HttpCode(HttpStatus.OK)
@@ -24,4 +41,46 @@ export class WebhooksController {
}
return { code: '0', message: 'OK' };
}
@Post('cbe-birr')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'CBE Birr payment notification callback' })
async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) {
try {
await this.cbeBirr.handle(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`CBE Birr webhook handler threw: ${message}`);
}
return { success: true };
}
@Post('ebirr')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'eBirr payment notification callback' })
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
try {
await this.eBirr.handle(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`eBirr webhook handler threw: ${message}`);
}
return { code: '0000', message: 'success' };
}
@Post('card')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Card payment notification callback' })
async receiveCard(
@Body() payload: CardWebhookPayload,
@Headers('stripe-signature') signature: string,
) {
try {
await this.card.handle(payload, signature);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Card webhook handler threw: ${message}`);
}
return { received: true };
}
}

View File

@@ -0,0 +1,35 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ReportsService } from './reports.service';
import { GenerateReportDto } from './reports.dto';
import { IamGuard, IamRoles } from '../../common/iam-adapter';
import { UserRole } from '@prisma/client';
@ApiTags('Reports')
@Controller('reports')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
export class ReportsController {
constructor(private service: ReportsService) {}
@Post('generate')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Generate operational report' })
generateReport(@Body() dto: GenerateReportDto) {
return this.service.generateReport(dto);
}
@Get(':reportId')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Get report by ID' })
getReport(@Param('reportId') reportId: string) {
return this.service.getReport(reportId);
}
@Get()
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'List reports' })
listReports(@Query('type') type?: string) {
return this.service.listReports(type);
}
}

View File

@@ -0,0 +1,29 @@
import { IsString, IsDateString, IsOptional, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum ReportType {
REVENUE = 'REVENUE',
OCCUPANCY = 'OCCUPANCY',
AGENT_SALES = 'AGENT_SALES',
CANCELLATIONS = 'CANCELLATIONS',
PAYMENT_METHODS = 'PAYMENT_METHODS'
}
export enum ExportFormat {
JSON = 'JSON',
CSV = 'CSV',
PDF = 'PDF'
}
export class GenerateReportDto {
@ApiProperty({ enum: ReportType }) @IsEnum(ReportType) reportType: ReportType;
@ApiProperty({ example: '2026-01-01' }) @IsDateString() dateFrom: string;
@ApiProperty({ example: '2026-01-31' }) @IsDateString() dateTo: string;
@ApiPropertyOptional() @IsOptional() @IsString() routeId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() agentId?: string;
}
export class ExportReportDto {
@ApiProperty() @IsString() reportId: string;
@ApiProperty({ enum: ExportFormat }) @IsEnum(ExportFormat) format: ExportFormat;
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
@Module({
imports: [HttpModule],
controllers: [ReportsController],
providers: [ReportsService],
exports: [ReportsService]
})
export class ReportsModule {}

View File

@@ -0,0 +1,185 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { GenerateReportDto, ReportType } from './reports.dto';
@Injectable()
export class ReportsService {
constructor(private prisma: PrismaService) {}
async generateReport(dto: GenerateReportDto) {
const dateFrom = new Date(dto.dateFrom);
const dateTo = new Date(dto.dateTo);
let data: any;
switch (dto.reportType) {
case ReportType.REVENUE:
data = await this.generateRevenueReport(dateFrom, dateTo);
break;
case ReportType.OCCUPANCY:
data = await this.generateOccupancyReport(dateFrom, dateTo);
break;
case ReportType.AGENT_SALES:
data = await this.generateAgentSalesReport(dateFrom, dateTo, dto.agentId);
break;
case ReportType.CANCELLATIONS:
data = await this.generateCancellationsReport(dateFrom, dateTo);
break;
case ReportType.PAYMENT_METHODS:
data = await this.generatePaymentMethodsReport(dateFrom, dateTo);
break;
default:
data = {};
}
const report = await this.prisma.operationalReport.create({
data: {
reportType: dto.reportType,
dateFrom,
dateTo,
data
}
});
return { reportId: report.id, reportType: dto.reportType, data };
}
private async generateRevenueReport(dateFrom: Date, dateTo: Date) {
const bookings = await this.prisma.booking.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo },
status: { in: ['CONFIRMED', 'COMPLETED'] }
},
include: { paymentIntent: true }
});
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
const byPaymentMethod = bookings.reduce((acc, b) => {
const method = b.paymentIntent?.method ?? 'UNKNOWN';
acc[method] = (acc[method] || 0) + b.totalMinor;
return acc;
}, {} as Record<string, number>);
return {
totalBookings: bookings.length,
totalRevenueMinor: totalRevenue,
totalRevenue: totalRevenue / 100,
currency: 'ETB',
byPaymentMethod
};
}
private async generateOccupancyReport(dateFrom: Date, dateTo: Date) {
const trips = await this.prisma.trip.findMany({
where: { departureAt: { gte: dateFrom, lte: dateTo } },
include: {
coaches: { include: { seats: true } },
bookings: { where: { status: { in: ['CONFIRMED', 'COMPLETED'] } }, include: { seats: true } }
}
});
const tripData = trips.map(trip => {
const totalSeats = trip.coaches.reduce((sum, c) => sum + c.seats.length, 0);
const bookedSeats = trip.bookings.reduce((sum, b) => sum + b.seats.length, 0);
const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
return {
tripId: trip.id,
departureAt: trip.departureAt,
totalSeats,
bookedSeats,
occupancyRate: +occupancyRate.toFixed(2)
};
});
const avgOccupancy = tripData.length > 0
? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) / tripData.length
: 0;
return {
totalTrips: trips.length,
averageOccupancyRate: +avgOccupancy.toFixed(2),
trips: tripData
};
}
private async generateAgentSalesReport(dateFrom: Date, dateTo: Date, agentId?: string) {
const agentBookings = await this.prisma.agentBooking.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo },
...(agentId ? { agentId } : {})
},
include: {
agent: { include: { user: true } },
booking: true
}
});
const byAgent = agentBookings.reduce((acc, ab) => {
const agentName = ab.agent.user.fullName;
if (!acc[agentName]) {
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
}
acc[agentName].bookings += 1;
acc[agentName].revenueMinor += ab.booking.totalMinor;
acc[agentName].cashCollected += ab.cashReceived ?? 0;
return acc;
}, {} as Record<string, any>);
return {
totalAgentBookings: agentBookings.length,
byAgent
};
}
private async generateCancellationsReport(dateFrom: Date, dateTo: Date) {
const cancellations = await this.prisma.bookingCancellation.findMany({
where: { createdAt: { gte: dateFrom, lte: dateTo } },
include: { booking: true }
});
const totalRefunded = cancellations.reduce((sum, c) => sum + c.refundAmount, 0);
return {
totalCancellations: cancellations.length,
totalRefundedMinor: totalRefunded,
totalRefunded: totalRefunded / 100,
currency: 'ETB'
};
}
private async generatePaymentMethodsReport(dateFrom: Date, dateTo: Date) {
const payments = await this.prisma.paymentIntent.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo },
status: 'SUCCEEDED'
}
});
const byMethod = payments.reduce((acc, p) => {
const method = p.method;
if (!acc[method]) {
acc[method] = { count: 0, totalMinor: 0 };
}
acc[method].count += 1;
acc[method].totalMinor += p.amountMinor;
return acc;
}, {} as Record<string, any>);
return {
totalPayments: payments.length,
byMethod
};
}
async getReport(reportId: string) {
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
}
async listReports(reportType?: string) {
return this.prisma.operationalReport.findMany({
where: reportType ? { reportType } : {},
orderBy: { createdAt: 'desc' },
take: 50
});
}
}

View File

@@ -11,7 +11,11 @@ export class SearchTripsDto {
export class FareQuoteDto {
@ApiProperty() @IsString() tripId: string;
@ApiProperty({ example: 'ECONOMY' }) @IsString() serviceClass: string;
@ApiProperty({
example: 'ECONOMY_REGULAR',
enum: ['ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER']
})
@IsString() serviceClass: string;
@ApiPropertyOptional({ example: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) passengerCount?: number;
@ApiPropertyOptional({ example: 'WEEKEND15' }) @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional({ example: 450 }) @IsOptional() @Type(() => Number) @IsInt() loyaltyRedemptionPoints?: number;

View File

@@ -23,8 +23,22 @@ export class SearchService {
origin: { id: trip.originStation.id, code: trip.originStation.code, name: trip.originStation.name, city: trip.originStation.city },
destination: { id: trip.destinationStation.id, code: trip.destinationStation.code, name: trip.destinationStation.name, city: trip.destinationStation.city },
departureAt: trip.departureAt, arrivalAt: trip.arrivalAt, status: trip.status,
availability: { ECONOMY: avail('ECONOMY'), BUSINESS: avail('BUSINESS'), FIRST: avail('FIRST') },
fares: { ECONOMY: this.defaultFare('ECONOMY') / 100, BUSINESS: this.defaultFare('BUSINESS') / 100, FIRST: this.defaultFare('FIRST') / 100 },
availability: {
ECONOMY_REGULAR: avail('ECONOMY_REGULAR'),
ECONOMY_BED_LOWER: avail('ECONOMY_BED_LOWER'),
ECONOMY_BED_MIDDLE: avail('ECONOMY_BED_MIDDLE'),
ECONOMY_BED_UPPER: avail('ECONOMY_BED_UPPER'),
VIP_BED_LOWER: avail('VIP_BED_LOWER'),
VIP_BED_UPPER: avail('VIP_BED_UPPER')
},
fares: {
ECONOMY_REGULAR: this.defaultFare('ECONOMY_REGULAR') / 100,
ECONOMY_BED_LOWER: this.defaultFare('ECONOMY_BED_LOWER') / 100,
ECONOMY_BED_MIDDLE: this.defaultFare('ECONOMY_BED_MIDDLE') / 100,
ECONOMY_BED_UPPER: this.defaultFare('ECONOMY_BED_UPPER') / 100,
VIP_BED_LOWER: this.defaultFare('VIP_BED_LOWER') / 100,
VIP_BED_UPPER: this.defaultFare('VIP_BED_UPPER') / 100
},
};
});
}
@@ -45,6 +59,14 @@ export class SearchService {
}
private defaultFare(serviceClass: string): number {
return ({ ECONOMY: 45000, BUSINESS: 90000, FIRST: 135000 } as any)[serviceClass] ?? 45000;
const fares: Record<string, number> = {
ECONOMY_REGULAR: 35000, // 350 ETB
ECONOMY_BED_LOWER: 55000, // 550 ETB
ECONOMY_BED_MIDDLE: 50000, // 500 ETB
ECONOMY_BED_UPPER: 45000, // 450 ETB
VIP_BED_LOWER: 85000, // 850 ETB
VIP_BED_UPPER: 80000 // 800 ETB
};
return fares[serviceClass] ?? 35000;
}
}

View File

@@ -14,4 +14,20 @@ export class SeatsController {
holdSeats(@Body() dto: HoldSeatsDto) { return this.service.holdSeats(dto); }
@Delete('hold/:holdId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Release a seat hold' })
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
@Get('export/csv/:tripId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' })
async exportCSV(@Param('tripId') tripId: string) {
const csv = await this.service.exportSeatsCSV(tripId);
return { csv, filename: `seats-${tripId}.csv` };
}
@Post('import/preview') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Preview CSV import' })
previewCSV(@Body() body: { csv: string }) {
return this.service.previewSeatsCSV(body.csv);
}
@Post('import/commit') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Commit CSV import' })
importCSV(@Body() body: { tripId: string; csv: string; commit: boolean }) {
return this.service.importSeatsCSV(body.tripId, body.csv, body.commit);
}
}

View File

@@ -0,0 +1,82 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SeatsService } from './seats.service';
import { PrismaService } from '../../common/prisma.service';
import { ConflictException } from '@nestjs/common';
describe('SeatsService - Auto Assign', () => {
let service: SeatsService;
let prisma: PrismaService;
const mockPrisma = {
seat: {
findMany: jest.fn(),
updateMany: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SeatsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<SeatsService>(SeatsService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('autoAssignSeats', () => {
it('should assign contiguous seats in same row', async () => {
const mockSeats = [
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
{ id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B' },
{ id: 'seat-3', coachId: 'coach-1', row: 1, col: 'C' },
{ id: 'seat-4', coachId: 'coach-1', row: 2, col: 'A' },
];
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
expect(result).toHaveLength(2);
expect(result).toEqual(['seat-1', 'seat-2']);
});
it('should throw error if not enough seats available', async () => {
mockPrisma.seat.findMany.mockResolvedValue([
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
]);
await expect(
service.autoAssignSeats('trip-1', 3, 'ECONOMY_REGULAR'),
).rejects.toThrow(ConflictException);
});
it('should respect eligibility filter', async () => {
const mockSeats = [
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A', eligibility: 'ACCESSIBLE' },
{ id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B', eligibility: 'ACCESSIBLE' },
];
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'ACCESSIBLE');
expect(result).toHaveLength(2);
});
it('should assign single seat', async () => {
const mockSeats = [
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
];
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 1, 'ECONOMY_REGULAR');
expect(result).toEqual(['seat-1']);
});
});
});

View File

@@ -42,6 +42,128 @@ export class SeatsService {
async confirmSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); }
async releaseSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); }
async autoAssignSeats(tripId: string, count: number, serviceClass: string, eligibility?: string): Promise<string[]> {
const seats = await this.prisma.seat.findMany({
where: {
coach: { tripId, serviceClass: serviceClass as any },
status: 'AVAILABLE',
...(eligibility ? { eligibility } : {}),
},
orderBy: [{ coach: { label: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
});
if (seats.length < count) {
throw new ConflictException(`Only ${seats.length} seats available, requested ${count}`);
}
const assigned = this.findContiguousSeats(seats, count);
return assigned.map((s) => s.id);
}
private findContiguousSeats(seats: any[], count: number): any[] {
if (count === 1) return [seats[0]];
const grouped = new Map<string, any[]>();
for (const seat of seats) {
const key = `${seat.coachId}-${seat.row}`;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(seat);
}
for (const rowSeats of grouped.values()) {
if (rowSeats.length >= count) {
return rowSeats.slice(0, count);
}
}
return seats.slice(0, count);
}
async exportSeatsCSV(tripId: string): Promise<string> {
const coaches = await this.prisma.coach.findMany({
where: { tripId },
include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } },
});
const rows = ['coachId,coachLabel,row,col,label,kind,status,premiumFeeMinor,eligibility'];
for (const coach of coaches) {
for (const seat of coach.seats) {
rows.push(
`${coach.id},${coach.label},${seat.row},${seat.col},${seat.label},${seat.kind},${seat.status},${seat.premiumFeeMinor},${seat.eligibility || ''}`,
);
}
}
return rows.join('\n');
}
async previewSeatsCSV(csvContent: string): Promise<{ valid: number; invalid: number; errors: string[] }> {
const lines = csvContent.trim().split('\n').slice(1);
const errors: string[] = [];
let valid = 0;
let invalid = 0;
for (let i = 0; i < lines.length; i++) {
const parts = lines[i].split(',');
if (parts.length < 8) {
errors.push(`Line ${i + 2}: Invalid format`);
invalid++;
continue;
}
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor] = parts;
if (!coachId || !row || !col || !label) {
errors.push(`Line ${i + 2}: Missing required fields`);
invalid++;
continue;
}
valid++;
}
return { valid, invalid, errors: errors.slice(0, 10) };
}
async importSeatsCSV(tripId: string, csvContent: string, commit: boolean): Promise<{ imported: number; errors: string[] }> {
const lines = csvContent.trim().split('\n').slice(1);
const errors: string[] = [];
let imported = 0;
if (!commit) {
return { imported: 0, errors: ['Preview mode - use commit=true to apply changes'] };
}
for (let i = 0; i < lines.length; i++) {
try {
const parts = lines[i].split(',');
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor, eligibility] = parts;
await this.prisma.seat.upsert({
where: { coachId_row_col: { coachId, row: parseInt(row), col } },
update: {
label,
kind: kind as any,
status: status as any,
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
eligibility: eligibility || null,
},
create: {
coachId,
row: parseInt(row),
col,
label,
kind: kind as any,
status: status as any,
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
eligibility: eligibility || null,
},
});
imported++;
} catch (err) {
errors.push(`Line ${i + 2}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return { imported, errors: errors.slice(0, 10) };
}
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@@ -9,6 +9,38 @@ import { JwtGuard } from '../../common/jwt.guard';
@ApiBearerAuth('JWT-auth')
export class TicketsController {
constructor(private service: TicketsService) {}
@Get(':bookingRef') @ApiOperation({ summary: 'Get ticket by booking reference' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); }
@Post(':bookingRef/validate') @ApiOperation({ summary: 'Validate ticket at gate (staff)' }) validate(@Param('bookingRef') ref: string, @Body('validatorId') validatorId: string) { return this.service.validate(ref, validatorId); }
@Get(':bookingRef')
@ApiOperation({ summary: 'Get ticket by booking reference' })
getByRef(@Param('bookingRef') ref: string) {
return this.service.getByRef(ref);
}
@Post(':bookingRef/validate')
@ApiOperation({ summary: 'Validate ticket at gate (staff)' })
validate(
@Param('bookingRef') ref: string,
@Body('validatorId') validatorId: string,
@Body('gateId') gateId?: string
) {
return this.service.validate(ref, validatorId, gateId);
}
@Get(':ticketId/validation-logs')
@ApiOperation({ summary: 'Get validation logs for ticket' })
getValidationLogs(@Param('ticketId') ticketId: string) {
return this.service.getValidationLogs(ticketId);
}
@Get('offline/export')
@ApiOperation({ summary: 'Export tickets for offline validation' })
exportOfflineData(@Query('tripId') tripId: string) {
return this.service.exportOfflineData(tripId);
}
@Post('validate/offline')
@ApiOperation({ summary: 'Batch import offline validations' })
validateOfflineBatch(@Body() body: { validations: any[] }) {
return this.service.validateOfflineBatch(body.validations);
}
}

View File

@@ -0,0 +1,126 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TicketsService } from './tickets.service';
import { PrismaService } from '../../common/prisma.service';
describe('TicketsService - Offline Validation', () => {
let service: TicketsService;
let prisma: PrismaService;
const mockPrisma = {
booking: {
findMany: jest.fn(),
findUnique: jest.fn(),
},
ticket: {
findUnique: jest.fn(),
update: jest.fn(),
},
gateValidationLog: {
create: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TicketsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<TicketsService>(TicketsService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('exportOfflineData', () => {
it('should export tickets for offline validation', async () => {
const mockBookings = [
{
bookingRef: 'ABC123',
ticket: { id: 'ticket-1', qrPayload: 'qr-data', validatedAt: null },
seats: [{ passengerName: 'John Doe', seat: { label: '1A', coach: { label: 'A' } } }],
status: 'CONFIRMED',
},
];
mockPrisma.booking.findMany.mockResolvedValue(mockBookings);
const result = await service.exportOfflineData('trip-1');
expect(result).toHaveLength(1);
expect(result[0].bookingRef).toBe('ABC123');
expect(result[0].passengerName).toBe('John Doe');
});
});
describe('validateOfflineBatch', () => {
it('should process batch validations successfully', async () => {
const validations = [
{
bookingRef: 'ABC123',
validatorId: 'validator-1',
gateId: 'gate-1',
validatedAt: new Date().toISOString(),
},
];
mockPrisma.booking.findUnique.mockResolvedValue({ id: 'booking-1' });
mockPrisma.ticket.findUnique.mockResolvedValue({ id: 'ticket-1', validatedAt: null });
mockPrisma.ticket.update.mockResolvedValue({});
mockPrisma.gateValidationLog.create.mockResolvedValue({});
const result = await service.validateOfflineBatch(validations);
expect(result.success).toBe(1);
expect(result.failed).toBe(0);
expect(result.duplicate).toBe(0);
});
it('should detect duplicate validations', async () => {
const validations = [
{
bookingRef: 'ABC123',
validatorId: 'validator-1',
validatedAt: new Date().toISOString(),
},
{
bookingRef: 'ABC123',
validatorId: 'validator-1',
validatedAt: new Date().toISOString(),
},
];
mockPrisma.booking.findUnique.mockResolvedValue({ id: 'booking-1' });
mockPrisma.ticket.findUnique.mockResolvedValue({ id: 'ticket-1', validatedAt: null });
mockPrisma.ticket.update.mockResolvedValue({});
mockPrisma.gateValidationLog.create.mockResolvedValue({});
const result = await service.validateOfflineBatch(validations);
expect(result.success).toBe(1);
expect(result.duplicate).toBe(1);
});
it('should handle already validated tickets', async () => {
const validations = [
{
bookingRef: 'ABC123',
validatorId: 'validator-1',
validatedAt: new Date().toISOString(),
},
];
mockPrisma.booking.findUnique.mockResolvedValue({ id: 'booking-1' });
mockPrisma.ticket.findUnique.mockResolvedValue({
id: 'ticket-1',
validatedAt: new Date(),
});
const result = await service.validateOfflineBatch(validations);
expect(result.duplicate).toBe(1);
expect(result.success).toBe(0);
});
});
});

View File

@@ -2,6 +2,13 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
import { PrismaService } from '../../common/prisma.service';
import * as QRCode from 'qrcode';
interface OfflineValidation {
bookingRef: string;
validatorId: string;
gateId?: string;
validatedAt: string;
}
@Injectable()
export class TicketsService {
constructor(private prisma: PrismaService) {}
@@ -13,7 +20,12 @@ export class TicketsService {
});
if (!booking) throw new NotFoundException('Booking not found');
const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
return this.prisma.ticket.upsert({ where: { bookingId }, update: { qrPayload }, create: { bookingId, bookingRef: booking.bookingRef, qrPayload } });
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
return this.prisma.ticket.upsert({
where: { bookingId },
update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload }
});
}
async getByRef(bookingRef: string) {
@@ -29,15 +41,110 @@ export class TicketsService {
departureAt: booking.trip.departureAt, trainName: booking.trip.service.name,
coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label, passengerName: seat?.passengerName,
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
barcodePayload: booking.ticket.barcodePayload
};
}
async validate(bookingRef: string, validatorId: string) {
async validate(bookingRef: string, validatorId: string, gateId?: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) throw new NotFoundException('Ticket not found');
if (ticket.validatedAt) throw new BadRequestException('Ticket already validated');
return this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
if (ticket.validatedAt) {
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' }
});
throw new BadRequestException('Ticket already validated');
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' }
});
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
}
async getValidationLogs(ticketId: string) {
return this.prisma.gateValidationLog.findMany({
where: { ticketId },
orderBy: { validatedAt: 'desc' }
});
}
async exportOfflineData(tripId: string) {
const bookings = await this.prisma.booking.findMany({
where: { tripId, status: 'CONFIRMED' },
include: {
ticket: true,
seats: { include: { seat: { include: { coach: true } } } },
passenger: { include: { user: true } },
},
});
return bookings.map((b) => ({
bookingRef: b.bookingRef,
ticketId: b.ticket?.id,
passengerName: b.seats[0]?.passengerName,
seatLabel: b.seats[0]?.seat.label,
coachLabel: b.seats[0]?.seat.coach.label,
qrPayload: b.ticket?.qrPayload,
status: b.status,
validatedAt: b.ticket?.validatedAt,
}));
}
async validateOfflineBatch(validations: OfflineValidation[]) {
const results = { success: 0, failed: 0, duplicate: 0, errors: [] as string[] };
const processedRefs = new Set<string>();
for (const v of validations) {
if (processedRefs.has(v.bookingRef)) {
results.duplicate++;
continue;
}
processedRefs.add(v.bookingRef);
try {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
if (!booking) {
results.failed++;
results.errors.push(`Booking ${v.bookingRef} not found`);
continue;
}
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) {
results.failed++;
results.errors.push(`Ticket for ${v.bookingRef} not found`);
continue;
}
if (ticket.validatedAt) {
results.duplicate++;
continue;
}
await this.prisma.ticket.update({
where: { id: ticket.id },
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
});
await this.prisma.gateValidationLog.create({
data: {
ticketId: ticket.id,
validatorId: v.validatorId,
gateId: v.gateId,
status: 'APPROVED',
validatedAt: new Date(v.validatedAt),
},
});
results.success++;
} catch (err) {
results.failed++;
results.errors.push(`Error processing ${v.bookingRef}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return results;
}
}