Refactored the whole app based on the requirements shared

This commit is contained in:
Stephanos A
2026-05-21 08:48:28 +03:00
parent 2dc3da9e74
commit 51bc906792
84 changed files with 6880 additions and 12659 deletions

View File

@@ -1,11 +1,17 @@
import { Module } from '@nestjs/common';
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PrismaModule } from './common/prisma.module';
import { I18nModule } from './common/i18n/i18n.module';
import { IamModule } from './common/iam.module';
import { LocaleMiddleware } from './common/i18n/locale.middleware';
import appConfig from './config/app.config';
import dbConfig from './config/database.config';
import telebirrConfig from './config/telebirr.config';
import cbeConfig from './config/cbe.config';
import ebirrConfig from './config/ebirr.config';
import cardConfig from './config/card.config';
import { AuthModule } from './modules/auth/auth.module';
import { StationsModule } from './modules/stations/stations.module';
import { FleetModule } from './modules/fleet/fleet.module';
@@ -24,13 +30,21 @@ import { LiveModule } from './modules/live/live.module';
import { SupportModule } from './modules/support/support.module';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { SegmentsModule } from './modules/segments/segments.module';
import { AgentsModule } from './modules/agents/agents.module';
import { ReportsModule } from './modules/reports/reports.module';
import { FraudModule } from './modules/fraud/fraud.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [appConfig, dbConfig, telebirrConfig] }),
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, dbConfig, telebirrConfig, cbeConfig, ebirrConfig, cardConfig],
}),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
PrismaModule,
I18nModule,
IamModule,
AuthModule,
StationsModule,
FleetModule,
@@ -49,6 +63,13 @@ import { SegmentsModule } from './modules/segments/segments.module';
SupportModule,
DashboardModule,
SegmentsModule,
AgentsModule,
ReportsModule,
FraudModule,
],
})
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LocaleMiddleware).forRoutes('*');
}
}

View File

@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { I18nService } from './i18n.service';
@Global()
@Module({
providers: [I18nService],
exports: [I18nService],
})
export class I18nModule {}

View File

@@ -0,0 +1,49 @@
import { Test, TestingModule } from '@nestjs/testing';
import { I18nService } from './i18n.service';
describe('I18nService', () => {
let service: I18nService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [I18nService],
}).compile();
service = module.get<I18nService>(I18nService);
});
it('should translate English keys', () => {
expect(service.translate('common.welcome', 'en')).toBe('Welcome');
expect(service.translate('booking.created', 'en')).toBe('Booking created successfully');
});
it('should translate Amharic keys', () => {
expect(service.translate('common.welcome', 'am')).toBe('እንኳን ደህና መጡ');
});
it('should translate French keys', () => {
expect(service.translate('common.welcome', 'fr')).toBe('Bienvenue');
});
it('should translate Oromo keys', () => {
expect(service.translate('common.welcome', 'om')).toBe('Baga nagaan dhuftan');
});
it('should fallback to English for unsupported locale', () => {
expect(service.translate('common.welcome', 'de')).toBe('Welcome');
});
it('should return key if translation not found', () => {
expect(service.translate('nonexistent.key', 'en')).toBe('nonexistent.key');
});
it('should interpolate parameters', () => {
const result = service.translate('common.welcome', 'en', { name: 'John' });
expect(result).toBeDefined();
});
it('should return supported locales', () => {
const locales = service.getSupportedLocales();
expect(locales).toEqual(['en', 'am', 'fr', 'om']);
});
});

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
type TranslationMap = Record<string, any>;
@Injectable()
export class I18nService {
private translations: Map<string, TranslationMap> = new Map();
private readonly supportedLocales = ['en', 'am', 'fr', 'om'];
private readonly defaultLocale = 'en';
constructor() {
this.loadTranslations();
}
private loadTranslations() {
for (const locale of this.supportedLocales) {
const filePath = path.join(__dirname, 'translations', `${locale}.json`);
try {
const content = fs.readFileSync(filePath, 'utf-8');
this.translations.set(locale, JSON.parse(content));
} catch (err) {
console.warn(`Failed to load translation file for locale: ${locale}`);
}
}
}
translate(key: string, locale: string = this.defaultLocale, params?: Record<string, string>): string {
const normalizedLocale = this.normalizeLocale(locale);
const translations = this.translations.get(normalizedLocale) || this.translations.get(this.defaultLocale);
if (!translations) return key;
const keys = key.split('.');
let value: any = translations;
for (const k of keys) {
value = value?.[k];
if (value === undefined) return key;
}
if (typeof value !== 'string') return key;
if (params) {
return Object.entries(params).reduce(
(text, [param, val]) => text.replace(new RegExp(`{{${param}}}`, 'g'), val),
value
);
}
return value;
}
private normalizeLocale(locale: string): string {
const normalized = locale.toLowerCase().split('-')[0];
return this.supportedLocales.includes(normalized) ? normalized : this.defaultLocale;
}
getSupportedLocales(): string[] {
return this.supportedLocales;
}
}

View File

@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { LOCALE_KEY } from './locale.middleware';
export const Locale = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
return request[LOCALE_KEY] || 'en';
},
);

View File

@@ -0,0 +1,16 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
export const LOCALE_KEY = 'locale';
@Injectable()
export class LocaleMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) {
const locale =
req.query.lang as string ||
req.headers['accept-language']?.split(',')[0]?.split('-')[0] ||
'en';
req[LOCALE_KEY] = locale;
next();
}
}

View File

@@ -0,0 +1,23 @@
{
"common": {
"welcome": "እንኳን ደህና መጡ",
"error": "ስህተት ተከስቷል",
"success": "ተሳክቷል"
},
"booking": {
"created": "ቦታ ማስያዝ በተሳካ ሁኔታ ተፈጥሯል",
"notFound": "ቦታ ማስያዝ አልተገኘም",
"cancelled": "ቦታ ማስያዝ ተሰርዟል",
"confirmed": "ቦታ ማስያዝ ተረጋግጧል"
},
"payment": {
"succeeded": "ክፍያ ተሳክቷል",
"failed": "ክፍያ አልተሳካም",
"pending": "ክፍያ በመጠባበቅ ላይ"
},
"ticket": {
"issued": "ትኬት ተሰጥቷል",
"validated": "ትኬት ተረጋግጧል",
"alreadyValidated": "ትኬት ቀድሞውኑ ተረጋግጧል"
}
}

View File

@@ -0,0 +1,23 @@
{
"common": {
"welcome": "Welcome",
"error": "An error occurred",
"success": "Success"
},
"booking": {
"created": "Booking created successfully",
"notFound": "Booking not found",
"cancelled": "Booking cancelled",
"confirmed": "Booking confirmed"
},
"payment": {
"succeeded": "Payment successful",
"failed": "Payment failed",
"pending": "Payment pending"
},
"ticket": {
"issued": "Ticket issued",
"validated": "Ticket validated",
"alreadyValidated": "Ticket already validated"
}
}

View File

@@ -0,0 +1,23 @@
{
"common": {
"welcome": "Bienvenue",
"error": "Une erreur s'est produite",
"success": "Succès"
},
"booking": {
"created": "Réservation créée avec succès",
"notFound": "Réservation introuvable",
"cancelled": "Réservation annulée",
"confirmed": "Réservation confirmée"
},
"payment": {
"succeeded": "Paiement réussi",
"failed": "Échec du paiement",
"pending": "Paiement en attente"
},
"ticket": {
"issued": "Billet émis",
"validated": "Billet validé",
"alreadyValidated": "Billet déjà validé"
}
}

View File

@@ -0,0 +1,23 @@
{
"common": {
"welcome": "Baga nagaan dhuftan",
"error": "Dogongora uumame",
"success": "Milkaa'ina"
},
"booking": {
"created": "Bakka qabachuu milkaa'inaan uumame",
"notFound": "Bakka qabachuu hin argamne",
"cancelled": "Bakka qabachuu haqame",
"confirmed": "Bakka qabachuu mirkaneeffame"
},
"payment": {
"succeeded": "Kaffaltiin milkaa'e",
"failed": "Kaffaltiin hin milkoofne",
"pending": "Kaffaltiin eegaa jira"
},
"ticket": {
"issued": "Tiikeetiin kenname",
"validated": "Tiikeetiin mirkaneeffame",
"alreadyValidated": "Tiikeetiin duraan mirkaneeffame"
}
}

View File

@@ -0,0 +1,264 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { IamGuard } from './iam-adapter';
import { of, throwError } from 'rxjs';
describe('IamGuard', () => {
let guard: IamGuard;
let httpService: HttpService;
let configService: ConfigService;
let reflector: Reflector;
const mockConfigService = {
get: jest.fn((key: string) => {
const config: Record<string, string> = {
IAM_API_URL: 'https://iam.test.com/api',
IAM_ENABLED: 'true',
IAM_API_KEY: 'test-api-key',
};
return config[key];
}),
};
const mockHttpService = {
post: jest.fn(),
};
const mockReflector = {
get: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
IamGuard,
{ provide: ConfigService, useValue: mockConfigService },
{ provide: HttpService, useValue: mockHttpService },
{ provide: Reflector, useValue: mockReflector },
],
}).compile();
guard = module.get<IamGuard>(IamGuard);
httpService = module.get<HttpService>(HttpService);
configService = module.get<ConfigService>(ConfigService);
reflector = module.get<Reflector>(Reflector);
jest.clearAllMocks();
});
const createMockContext = (token?: string, roles?: string[]): ExecutionContext => {
const request = {
headers: token ? { authorization: `Bearer ${token}` } : {},
user: undefined,
};
return {
switchToHttp: () => ({
getRequest: () => request,
}),
getHandler: () => ({}),
} as ExecutionContext;
};
describe('canActivate', () => {
it('should allow access when IAM is disabled', async () => {
mockConfigService.get.mockReturnValueOnce('false'); // IAM_ENABLED
const context = createMockContext();
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should throw UnauthorizedException when no token provided', async () => {
const context = createMockContext();
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('should validate token and allow access', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: ['read', 'write'],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(null);
const context = createMockContext('valid-token');
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(mockHttpService.post).toHaveBeenCalledWith(
'https://iam.test.com/api/v1/auth/validate',
{ token: 'valid-token' },
expect.objectContaining({
headers: expect.objectContaining({
'X-API-Key': 'test-api-key',
}),
}),
);
});
it('should throw UnauthorizedException for invalid token', async () => {
const mockValidationResponse = {
data: {
valid: false,
error: 'Token expired',
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
const context = createMockContext('invalid-token');
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('should check required roles', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'agent@test.com',
roles: ['AGENT'],
permissions: [],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']);
const context = createMockContext('valid-token');
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
});
it('should allow access when user has required role', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: [],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']);
const context = createMockContext('valid-token');
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should handle HTTP errors gracefully', async () => {
mockHttpService.post.mockReturnValue(
throwError(() => new Error('Network error')),
);
const context = createMockContext('valid-token');
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('should attach user to request', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: ['read', 'write'],
organizationId: 'org-456',
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(null);
const context = createMockContext('valid-token');
await guard.canActivate(context);
const request = context.switchToHttp().getRequest();
expect(request.user).toEqual({
userId: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: ['read', 'write'],
organizationId: 'org-456',
});
});
});
describe('token extraction', () => {
it('should extract token from Bearer header', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'test@test.com',
roles: [],
permissions: [],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(null);
const context = createMockContext('my-token-123');
await guard.canActivate(context);
expect(mockHttpService.post).toHaveBeenCalledWith(
expect.any(String),
{ token: 'my-token-123' },
expect.any(Object),
);
});
it('should reject malformed authorization header', async () => {
const request = {
headers: { authorization: 'InvalidFormat token' },
};
const context = {
switchToHttp: () => ({
getRequest: () => request,
}),
getHandler: () => ({}),
} as ExecutionContext;
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
});
});

View File

@@ -0,0 +1,144 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
/**
* IAM Adapter for @tria-plc corporate identity integration
*
* This adapter wraps the corporate IAM guards and provides a bridge
* between the corporate identity system and the EDR passenger API.
*
* For back-office roles (agent, supervisor, admin, staff), this guard
* validates tokens against the corporate IAM service.
*
* For passenger-facing routes, the existing JWT guard is used.
*/
export interface IamTokenPayload {
sub: string;
email: string;
roles: string[];
permissions: string[];
organizationId?: string;
exp: number;
iat: number;
}
export interface IamValidationResponse {
valid: boolean;
payload?: IamTokenPayload;
error?: string;
}
@Injectable()
export class IamGuard implements CanActivate {
private readonly iamApiUrl: string;
private readonly iamEnabled: boolean;
constructor(
private readonly reflector: Reflector,
private readonly config: ConfigService,
private readonly http: HttpService,
) {
this.iamApiUrl = this.config.get<string>('IAM_API_URL') || 'https://iam.tria-plc.com/api';
this.iamEnabled = this.config.get<string>('IAM_ENABLED') === 'true';
}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (!this.iamEnabled) {
// IAM disabled - allow access (for development)
return true;
}
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('No authentication token provided');
}
const validation = await this.validateToken(token);
if (!validation.valid || !validation.payload) {
throw new UnauthorizedException(validation.error || 'Invalid token');
}
// Check required roles
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
if (requiredRoles && requiredRoles.length > 0) {
const hasRole = requiredRoles.some((role) => validation.payload!.roles.includes(role));
if (!hasRole) {
throw new ForbiddenException('Insufficient permissions');
}
}
// Attach user to request
request.user = {
userId: validation.payload.sub,
email: validation.payload.email,
roles: validation.payload.roles,
permissions: validation.payload.permissions,
organizationId: validation.payload.organizationId,
};
return true;
}
private extractToken(request: any): string | null {
const authHeader = request.headers.authorization;
if (!authHeader) return null;
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') return null;
return parts[1];
}
private async validateToken(token: string): Promise<IamValidationResponse> {
try {
const response = await firstValueFrom(
this.http.post<IamValidationResponse>(
`${this.iamApiUrl}/v1/auth/validate`,
{ token },
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.config.get<string>('IAM_API_KEY') || '',
},
timeout: 5000,
},
),
);
return response.data;
} catch (err) {
return {
valid: false,
error: err instanceof Error ? err.message : 'Token validation failed',
};
}
}
}
/**
* Decorator to mark routes as requiring IAM authentication
*/
export const UseIamAuth = () => {
// This is a marker decorator that can be used with @UseGuards(IamGuard)
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
// Marker only - actual guard is applied via @UseGuards
};
};
/**
* Decorator to specify required roles for IAM-protected routes
*/
export const IamRoles = (...roles: string[]) => {
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
if (descriptor) {
Reflect.defineMetadata('roles', roles, descriptor.value);
}
};
};

View File

@@ -0,0 +1,11 @@
import { Module, Global } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { IamGuard } from './iam-adapter';
@Global()
@Module({
imports: [HttpModule.register({ timeout: 5000 })],
providers: [IamGuard],
exports: [IamGuard],
})
export class IamModule {}

View File

@@ -0,0 +1,49 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, UnauthorizedException } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { PrismaService } from '../prisma.service';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SessionActivityInterceptor implements NestInterceptor {
private readonly inactivityMinutes: number;
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {
this.inactivityMinutes = parseInt(this.config.get<string>('SESSION_INACTIVITY_MINUTES') || '30', 10);
}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const user = request.user;
if (user?.userId) {
const session = await this.prisma.session.findFirst({
where: { userId: user.userId },
orderBy: { lastActivityAt: 'desc' },
});
if (session) {
const inactiveMinutes = (Date.now() - session.lastActivityAt.getTime()) / 60000;
if (inactiveMinutes > this.inactivityMinutes) {
await this.prisma.session.delete({ where: { id: session.id } });
throw new UnauthorizedException('Session expired due to inactivity');
}
const expiryWarningMinutes = Math.max(0, this.inactivityMinutes - inactiveMinutes);
response.setHeader('X-Session-Expiry-Warning', Math.floor(expiryWarningMinutes).toString());
await this.prisma.session.update({
where: { id: session.id },
data: { lastActivityAt: new Date() },
});
}
}
return next.handle().pipe(tap(() => {}));
}
}

View File

@@ -1,6 +1,7 @@
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { SessionActivityInterceptor } from './interceptors/session-activity.interceptor';
@Global()
@Module({ providers: [PrismaService], exports: [PrismaService] })
@Module({ providers: [PrismaService, SessionActivityInterceptor], exports: [PrismaService, SessionActivityInterceptor] })
export class PrismaModule {}

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,19 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '@prisma/client';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.role === role);
}
}

View File

@@ -0,0 +1,9 @@
import { registerAs } from '@nestjs/config';
export default registerAs('card', () => ({
baseUrl: process.env.CARD_BASE_URL || '',
apiKey: process.env.CARD_API_KEY || '',
webhookSecret: process.env.CARD_WEBHOOK_SECRET || '',
webhookUrl: process.env.CARD_WEBHOOK_URL || '',
returnUrl: process.env.CARD_RETURN_URL || '',
}));

View File

@@ -0,0 +1,9 @@
import { registerAs } from '@nestjs/config';
export default registerAs('cbe', () => ({
baseUrl: process.env.CBE_BASE_URL || '',
merchantId: process.env.CBE_MERCHANT_ID || '',
secretKey: process.env.CBE_SECRET_KEY || '',
notifyUrl: process.env.CBE_NOTIFY_URL || '',
returnUrl: process.env.CBE_RETURN_URL || '',
}));

View File

@@ -0,0 +1,9 @@
import { registerAs } from '@nestjs/config';
export default registerAs('ebirr', () => ({
baseUrl: process.env.EBIRR_BASE_URL || '',
merchantCode: process.env.EBIRR_MERCHANT_CODE || '',
secretKey: process.env.EBIRR_SECRET_KEY || '',
notifyUrl: process.env.EBIRR_NOTIFY_URL || '',
returnUrl: process.env.EBIRR_RETURN_URL || '',
}));

View File

@@ -5,6 +5,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseTransformInterceptor } from './common/interceptors/response-transform.interceptor';
import { SessionActivityInterceptor } from './common/interceptors/session-activity.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@@ -17,41 +18,202 @@ async function bootstrap() {
});
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseTransformInterceptor());
app.useGlobalInterceptors(
new ResponseTransformInterceptor(),
app.get(SessionActivityInterceptor),
);
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
const config = new DocumentBuilder()
.setTitle('EDR Passenger API')
.setDescription(
'Ethio-Djibouti Railway Passenger API — booking lifecycle, seat inventory, payment (Telebirr, CBE Birr, eBirr, Card, Wallet), loyalty, live tracking, and support.',
`# Ethio-Djibouti Railway Passenger Booking API
## Overview
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## Authentication
### Passenger Authentication (JWT-auth)
Used for passenger-facing endpoints. Obtain token via \`POST /auth/login\`.
**Usage:** Add header \`Authorization: Bearer <token>\`
### Back-office Authentication (IAM-auth)
Used for agent, fraud, and reporting endpoints. Requires corporate IAM token.
**Usage:** Add header \`Authorization: Bearer <iam-token>\`
## Key Features
### 🎫 Booking Lifecycle
- Search trips with real-time availability
- Create bookings with seat selection
- Modify bookings (seat changes, passenger updates)
- Cancel bookings with automatic refunds
- Multi-segment journey support
### 💳 Payment Integration
- **Telebirr** - Ethiopia's leading mobile money
- **CBE Birr** - Commercial Bank of Ethiopia
- **eBirr** - Electronic payment gateway
- **Card** - International card payments
- **Wallet** - Internal wallet system
### 🪑 Seat Management
- Real-time seat availability
- Seat holds (15-minute expiry)
- Auto-assign seats with contiguous algorithm
- Seat blocking for maintenance
- Coach-level seat maps
### 🎟️ Ticketing
- QR code and barcode generation
- PDF ticket generation
- Gate validation with audit logs
- Offline validation support
### 🏆 Loyalty Program
- 4 tiers: Bronze, Silver, Gold, Platinum
- Points accumulation on trips
- Reward redemption
- Tier-based benefits
### 💰 Wallet System
- Top-up via payment methods
- Pay with wallet balance
- Transaction ledger
- Refund to wallet
### 📍 Live Tracking
- Real-time trip status
- Location updates
- Delay notifications
- Station crowd signals
### 🔒 Fraud Detection
- Velocity checks (multiple bookings)
- High-value transaction monitoring
- Failed payment pattern detection
- Automatic user blocking
### 🌍 Internationalization
- Multi-language support (English, Amharic, French, Oromo)
- Locale-based responses
- Currency formatting
### 👨‍💼 Agent Operations
- Counter booking
- Shift management
- Commission tracking
- Cash reconciliation
## Rate Limiting
- Auth endpoints: 5 requests/minute
- General endpoints: 100 requests/minute
- Webhook endpoints: No limit
## Error Handling
All errors follow standard format:
\`\`\`json
{
"statusCode": 400,
"message": "Validation failed",
"error": "Bad Request",
"timestamp": "2026-05-20T14:30:00.000Z",
"path": "/bookings"
}
\`\`\`
## Pagination
List endpoints support pagination:
- \`limit\`: Number of items (default: 20, max: 100)
- \`offset\`: Skip items (default: 0)
## Webhooks
Payment providers send notifications to:
- \`POST /payments/webhooks/telebirr\`
- \`POST /payments/webhooks/cbe-birr\`
- \`POST /payments/webhooks/ebirr\`
- \`POST /payments/webhooks/card\`
## Support
- **Email:** support@edr-platform.com
- **Documentation:** https://docs.edr-platform.com
- **Status Page:** https://status.edr-platform.com
`,
)
.setVersion('1.0.0')
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' }, 'JWT-auth')
.addTag('Auth', 'Registration and login')
.addTag('Stations', 'Station directory')
.addTag('Fleet', 'Train services and coaches')
.addTag('Schedule', 'Trips and fare rules')
.addTag('Search', 'Trip search and fare quotes')
.addTag('Seats', 'Seat maps and holds')
.addTag('Booking', 'Booking lifecycle')
.addTag('Payment', 'Payment intents and refunds')
.addTag('Tickets', 'QR ticket generation and validation')
.addTag('Passenger', 'Profiles, traveler profiles, saved routes')
.addTag('Notifications', 'Push and email notifications')
.addTag('Loyalty', 'Points, tiers, and rewards')
.addTag('Wallet', 'Wallet balance and ledger')
.addTag('Promotions', 'Promo codes and campaigns')
.addTag('Live Tracking', 'Real-time trip status and crowd signals')
.addTag('Support', 'FAQ and chat support')
.addTag('Dashboard', 'Home dashboard aggregate')
.addServer('http://localhost:4000', 'Development')
.setVersion('1.0.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
in: 'header',
description: 'JWT token for passenger authentication. Obtain via POST /auth/login'
},
'JWT-auth'
)
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
in: 'header',
description: 'Corporate IAM token for back-office operations (agents, fraud, reports)'
},
'IAM-auth'
)
.addTag('Auth', '🔐 Registration, login, OTP verification, password reset')
.addTag('Agents', '👨‍💼 Agent booking, shifts, commissions, reconciliation')
.addTag('Booking', '🎫 Booking lifecycle, modification, cancellation, refunds')
.addTag('Dashboard', '📊 Home dashboard aggregated data')
.addTag('Fleet', '🚂 Train services, coaches, seat configurations')
.addTag('Fraud Detection', '🔒 Fraud detection, risk scoring, user blocking')
.addTag('Live Tracking', '📍 Real-time trip status, location updates, crowd signals')
.addTag('Loyalty', '🏆 Points accumulation, tier management, rewards redemption')
.addTag('Notifications', '🔔 Push, email, SMS notifications, preferences')
.addTag('Passenger', '👤 Profiles, traveler profiles, saved routes, preferences')
.addTag('Payment', '💳 Payment intents, status queries, refunds')
.addTag('Payment Webhooks', '🔗 Payment provider callback endpoints')
.addTag('Promotions', '🎁 Promo codes, campaigns, discount validation')
.addTag('Reports', '📈 Revenue reports, occupancy analytics, agent sales')
.addTag('Schedule', '🗓️ Trip schedules, fare rules, status updates')
.addTag('Search', '🔍 Trip search, availability, fare quotes')
.addTag('Seats', '🪑 Seat maps, holds, releases, blocking, auto-assign')
.addTag('Segment-based Seats', '🎯 Segment-based seat availability and booking')
.addTag('Stations', '🚉 Station directory, information, crowd signals')
.addTag('Support', '💬 FAQ management, live chat conversations')
.addTag('Tickets', '🎟️ QR/barcode generation, PDF tickets, gate validation')
.addTag('Wallet', '💰 Wallet balance, top-up, transaction ledger')
.addServer('http://localhost:4000', 'Local Development')
.addServer('https://api-staging.edr-platform.com', 'Staging Environment')
.addServer('https://api.edr-platform.com', 'Production')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document, {
customSiteTitle: 'EDR Passenger API',
swaggerOptions: { persistAuthorization: true, docExpansion: 'none', filter: true },
customSiteTitle: 'EDR Passenger API Documentation',
customfavIcon: 'https://edr-platform.com/favicon.ico',
customCss: `
.swagger-ui .topbar { display: none }
.swagger-ui .info { margin: 20px 0 }
.swagger-ui .info .title { font-size: 36px; font-weight: bold }
.swagger-ui .scheme-container { background: #fafafa; padding: 15px; border-radius: 4px }
`,
swaggerOptions: {
persistAuthorization: true,
docExpansion: 'none',
filter: true,
tagsSorter: 'alpha',
operationsSorter: 'alpha',
displayRequestDuration: true,
tryItOutEnabled: true,
syntaxHighlight: {
activate: true,
theme: 'monokai'
}
},
});
const port = process.env.PORT ?? 4000;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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