This commit is contained in:
Stephanos A
2026-05-24 16:31:36 +03:00
23 changed files with 12996 additions and 859 deletions

View File

@@ -1,19 +1,19 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
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';
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
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);
app.enableCors({
origin: [
process.env.FRONTEND_URL ?? 'http://localhost:3000',
process.env.PORTAL_URL ?? 'http://localhost:3001',
process.env.FRONTEND_URL ?? "http://localhost:3000",
process.env.PORTAL_URL ?? "http://localhost:3001",
],
});
@@ -25,7 +25,7 @@ async function bootstrap() {
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
const config = new DocumentBuilder()
.setTitle('EDR Passenger API')
.setTitle("EDR Passenger API")
.setDescription(
`# Ethio-Djibouti Railway Passenger Booking API
@@ -205,78 +205,39 @@ Payment providers send notifications to:
- **Status Page:** https://status.edr-platform.com
`,
)
.setVersion('1.0.0')
.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'
{ type: "http", scheme: "bearer", bearerFormat: "JWT", in: "header" },
"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', '🚂 Trains, physical coaches, seat auto-generation, coach-to-schedule assignments')
.addTag('Seat Classes', '🎨 Seat class management and configuration')
.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('Routes', '🗺️ Reusable route templates with ordered stops — referenced by schedules')
.addTag('Schedule', '🗓️ Train schedules (created from routes), stop time management, fare rules')
.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')
.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')
// .addServer("https://api.edr-platform.com", "Production")
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document, {
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',
SwaggerModule.setup("api-docs", app, document, {
customSiteTitle: "EDR Passenger API",
swaggerOptions: {
persistAuthorization: true,
docExpansion: "none",
filter: true,
tagsSorter: 'alpha',
operationsSorter: 'alpha',
displayRequestDuration: true,
tryItOutEnabled: true,
syntaxHighlight: {
activate: true,
theme: 'monokai'
}
},
});

View File

@@ -1,4 +1,4 @@
import { IsString, IsEnum, IsOptional } from 'class-validator';
import { IsString, IsEnum, IsOptional, IsIn } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PaymentIntentStatus } from '@prisma/client';
@@ -11,14 +11,20 @@ export enum PaymentMethodTypeEnum {
WALLET = 'WALLET' // Internal
}
export type PaymentPlatformDto = 'web' | 'mobile';
export class InitiatePaymentDto {
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
@ApiProperty({
@ApiProperty({
enum: PaymentMethodTypeEnum,
description: 'Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)',
example: 'TELEBIRR'
}) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
@ApiPropertyOptional({ description: 'Saved payment method ID (optional)' }) @IsOptional() @IsString() paymentMethodId?: string;
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web', description: 'Payment platform (web or mobile)' })
@IsOptional()
@IsIn(['web', 'mobile'])
platform?: PaymentPlatformDto;
}
export class RefundDto {
@@ -34,8 +40,11 @@ export class AddPaymentMethodDto {
}
export class ClientActionDto {
@ApiProperty({ enum: ['REDIRECT'] }) type: 'REDIRECT';
@ApiProperty() url: string;
@ApiProperty({ enum: ['REDIRECT', 'LAUNCH_APP'] }) type: 'REDIRECT' | 'LAUNCH_APP';
@ApiPropertyOptional({ description: 'Set when type=REDIRECT (web flow)' }) url?: string;
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) prepayId?: string;
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) receiveCode?: string;
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) shortCode?: string;
}
export class InitiateResponseDto {

View File

@@ -5,7 +5,7 @@ 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 { PaymentProvider, ProviderStatus } from './payments.types';
import { ClientAction, PaymentProvider, ProviderStatus } from './payments.types';
import { TelebirrProvider } from './providers/telebirr.provider';
import { CbeBirrProvider } from './providers/cbe-birr.provider';
import { EBirrProvider } from './providers/ebirr.provider';
@@ -69,7 +69,7 @@ export class PaymentsService {
const provider = this.providers.get(method);
if (provider) {
return this.initiateProviderPayment(booking, provider);
return this.initiateProviderPayment(booking, provider, dto.platform);
}
throw new BadRequestException(`Unsupported payment method: ${method}`);
@@ -142,6 +142,7 @@ export class PaymentsService {
private async initiateProviderPayment(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
provider: PaymentProvider,
platform: 'web' | 'mobile' | undefined,
): Promise<InitiateResponseDto> {
const merchantOrderId = createMerchantOrderId();
const result = await provider.initiate({
@@ -149,6 +150,7 @@ export class PaymentsService {
bookingRef: booking.bookingRef,
amountMinor: booking.totalMinor,
currency: booking.currency,
platform,
});
const intent = await this.prisma.paymentIntent.upsert({
@@ -187,7 +189,7 @@ export class PaymentsService {
): InitiateResponseDto {
const clientAction =
intent.clientAction && typeof intent.clientAction === 'object'
? (intent.clientAction as unknown as { type: 'REDIRECT'; url: string })
? (intent.clientAction as unknown as ClientAction)
: undefined;
return {
intentId: intent.id,

View File

@@ -1,15 +1,17 @@
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
export interface ClientAction {
type: 'REDIRECT' | 'NONE';
url?: string;
}
export type PaymentPlatform = 'web' | 'mobile';
export type ClientAction =
| { type: 'REDIRECT'; url: string }
| { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string };
export interface ProviderInitiationInput {
merchantOrderId: string;
bookingRef: string;
amountMinor: number;
currency: string;
platform?: PaymentPlatform;
}
export interface ProviderInitiationResult {

View File

@@ -58,12 +58,21 @@ export class TelebirrProvider implements PaymentProvider {
);
}
const checkoutUrl = this.buildCheckoutUrl(prepayId);
const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express);
const platform = input.platform ?? 'web';
const clientAction =
platform === 'mobile'
? {
type: 'LAUNCH_APP' as const,
prepayId,
receiveCode: response.biz_content?.receiveCode,
shortCode: this.merchantCode,
}
: { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) };
return {
providerOrderId: prepayId,
clientAction: { type: 'REDIRECT', url: checkoutUrl },
clientAction,
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),