mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
Refactored the whole app based on the requirements shared
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
33
apps/edr-passenger-api/src/modules/agents/agents.dto.ts
Normal file
33
apps/edr-passenger-api/src/modules/agents/agents.dto.ts
Normal 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;
|
||||
}
|
||||
12
apps/edr-passenger-api/src/modules/agents/agents.module.ts
Normal file
12
apps/edr-passenger-api/src/modules/agents/agents.module.ts
Normal 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 {}
|
||||
131
apps/edr-passenger-api/src/modules/agents/agents.service.ts
Normal file
131
apps/edr-passenger-api/src/modules/agents/agents.service.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
74
apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts
Normal file
74
apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
12
apps/edr-passenger-api/src/modules/fraud/fraud.module.ts
Normal file
12
apps/edr-passenger-api/src/modules/fraud/fraud.module.ts
Normal 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 {}
|
||||
252
apps/edr-passenger-api/src/modules/fraud/fraud.service.ts
Normal file
252
apps/edr-passenger-api/src/modules/fraud/fraud.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
270
apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts
Normal file
270
apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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') ?? '';
|
||||
}
|
||||
}
|
||||
@@ -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') ?? '';
|
||||
}
|
||||
}
|
||||
@@ -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') ?? '';
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
29
apps/edr-passenger-api/src/modules/reports/reports.dto.ts
Normal file
29
apps/edr-passenger-api/src/modules/reports/reports.dto.ts
Normal 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;
|
||||
}
|
||||
12
apps/edr-passenger-api/src/modules/reports/reports.module.ts
Normal file
12
apps/edr-passenger-api/src/modules/reports/reports.module.ts
Normal 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 {}
|
||||
185
apps/edr-passenger-api/src/modules/reports/reports.service.ts
Normal file
185
apps/edr-passenger-api/src/modules/reports/reports.service.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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() } } });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user