Resolve merge conflicts from Train-Scheduling

This commit is contained in:
hagiye
2026-06-08 16:51:29 +03:00
563 changed files with 55089 additions and 9929 deletions

View File

@@ -25,10 +25,10 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
key: 'approved_contract',
statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
},
{ key: 'payment', statuses: ['FULLY_EXECUTED', 'PAID'] },
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
{
key: 'operations',
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED'],
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'],
},
{ key: 'completed', statuses: ['COMPLETED'] },
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },

View File

@@ -34,8 +34,8 @@ export function computeNextStep(
};
case 'APPROVED':
return {
action: 'GENERATE_CONTRACT',
description: 'Generate the contract document',
action: 'CUSTOMER_SIGN',
description: 'Contract generated; customer must sign',
};
case 'CONTRACT_READY':
return {
@@ -49,8 +49,8 @@ export function computeNextStep(
};
case 'FULLY_EXECUTED':
return {
action: 'PAY',
description: 'Complete in-app payment',
action: 'AWAIT_PAYMENT',
description: 'Awaiting customer payment',
};
case 'PAID':
return {

View File

@@ -4,56 +4,44 @@ import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { PaymentService } from '../payment/payment.service';
import { PaymentStatus } from '../payment/entities/payment.entity';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
"action-required",
"processing",
"success",
];
@Injectable()
export class BookingPaymentService {
constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { }
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly paymentService: PaymentService,
) { }
async pay(
bookingId: string,
): Promise<{ redirectUrl: string }> {
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED']);
assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
// const receipt = this.buildMockReceipt(booking);
// const updated = await this.bookingsRepository.update(bookingId, {
// status: 'PAID',
// paymentStatus: 'PAID',
// } as never);
const resp = await this.paymentService.pay(booking.totalAmount, "ETB", "telebirr", "payment for booking", 'booking', (_) => {
return new Promise((resp, _) => {
resp({
id: booking.id,
type: "booking"
})
});
})
return {
redirectUrl: resp.clientAction.type == "REDIRECT" ? `http://localhost:3001/api/payments/telebirr/${booking.id}` : ""
const existing = await this.paymentService.findBookingById(bookingId);
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
if (existing.clientAction) {
const action = existing.clientAction as { type?: string; url?: string };
if (action.type === "REDIRECT" && action.url) {
return { redirectUrl: action.url };
}
}
}
const resp = await this.paymentService.initBookingTelebirr(bookingId, "web");
return {
redirectUrl:
resp.redirectUrl ?? "",
};
}
// private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
// const timestamp = Date.now();
// const isEtb = booking.paymentCurrency === 'ETB';
// const prefix = isEtb ? 'TB' : 'CARD';
// const provider = isEtb ? 'TELEBIRR' : 'CARD';
// return {
// success: true,
// provider,
// providerRef: `${prefix}-${booking.reference}-${timestamp}`,
// amount: booking.totalAmount,
// currency: booking.paymentCurrency,
// paidAt: new Date().toISOString(),
// };
// }
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);

View File

@@ -185,6 +185,11 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, updates as never);
}
if (allDone) {
const generated = await this.contractService.generateContract(bookingId);
return this.bookingsService.findById(generated.id);
}
return this.bookingsService.findById(bookingId);
}

View File

@@ -0,0 +1,59 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
export class CreateLocomotiveDto {
@ApiProperty({ example: 'LOCO-001' })
@IsString()
@MaxLength(32)
code!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@ApiProperty({ enum: LOCOMOTIVE_TYPES })
@IsIn([...LOCOMOTIVE_TYPES])
locomotiveType!: string;
@ApiProperty({ enum: LOCOMOTIVE_STATUSES })
@IsIn([...LOCOMOTIVE_STATUSES])
status!: string;
@ApiProperty({ example: 3500 })
@Transform(({ value }) => Number(value))
@IsNumber()
@Min(0)
maxPullWeightTons!: number;
@ApiProperty({ example: 760 })
@Transform(({ value }) => Number(value))
@IsNumber()
@Min(0)
maxTrainLengthMeters!: number;
@ApiPropertyOptional({ example: 4200 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsNumber()
@Min(0)
powerKw?: number;
@ApiPropertyOptional({ example: 300 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsNumber()
@Min(0)
tractionForceKn?: number;
@ApiPropertyOptional({ example: 120 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsNumber()
@Min(0)
maxSpeedKmh?: number;
}

View File

@@ -1,11 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
import { LOCOMOTIVE_STATUSES } from '../entities/locomotive.entity';
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
export class FilterLocomotivesDto {
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
@IsOptional()
@IsIn([...LOCOMOTIVE_STATUSES])
status?: string;
@ApiPropertyOptional({ enum: LOCOMOTIVE_TYPES })
@IsOptional()
@IsIn([...LOCOMOTIVE_TYPES])
locomotiveType?: string;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/swagger';
import { CreateLocomotiveDto } from './create-locomotive.dto';
export class UpdateLocomotiveDto extends PartialType(CreateLocomotiveDto) {}

View File

@@ -7,10 +7,13 @@ export const LOCOMOTIVE_STATUSES = [
'AVAILABLE',
'ASSIGNED',
'MAINTENANCE',
'INACTIVE',
'OUT_OF_SERVICE',
] as const;
export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const;
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
@Entity({ schema: 'freight', name: 'locomotives' })
@Index(['code'])
@@ -22,14 +25,26 @@ export class Locomotive extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
name?: string | null;
@Column({ name: 'locomotive_type', type: 'varchar', length: 20, default: 'DIESEL' })
locomotiveType!: LocomotiveType;
@Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 })
maxPullWeightTons!: number;
@Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 })
maxTrainLengthMeters!: number;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
status!: LocomotiveStatus;
@Column({ name: 'available_from', type: 'timestamptz', nullable: true })
availableFrom?: Date | null;
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
powerKw?: number | null;
@Column({ name: 'traction_force_kn', type: 'numeric', precision: 10, scale: 3, nullable: true })
tractionForceKn?: number | null;
@Column({ name: 'max_speed_kmh', type: 'numeric', precision: 10, scale: 3, nullable: true })
maxSpeedKmh?: number | null;
@OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
trainSets?: TrainSet[];

View File

@@ -1,7 +1,9 @@
import { Controller, Get, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
import { LocomotivesService } from './locomotives.service';
@ApiTags('locomotives')
@@ -15,4 +17,28 @@ export class LocomotivesController {
findAll(@Query() filter: FilterLocomotivesDto) {
return this.locomotivesService.findAll(filter);
}
@Get(':id')
@ApiOperation({ summary: 'Get a locomotive by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.locomotivesService.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a locomotive' })
create(@Body() dto: CreateLocomotiveDto) {
return this.locomotivesService.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a locomotive' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
return this.locomotivesService.update(id, dto);
}
@Post(':id/decommission')
@ApiOperation({ summary: 'Decommission a locomotive' })
decommission(@Param('id', ParseUUIDPipe) id: string) {
return this.locomotivesService.decommission(id);
}
}

View File

@@ -1,7 +1,9 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { Locomotive, type LocomotiveStatus } from './entities/locomotive.entity';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
import { LocomotivesRepository } from './locomotives.repository';
@Injectable()
@@ -10,13 +12,36 @@ export class LocomotivesService {
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
return this.locomotivesRepository.findAll({
where: filter.status
? { status: filter.status as LocomotiveStatus }
: undefined,
where: {
...(filter.status ? { status: filter.status as LocomotiveStatus } : {}),
...(filter.locomotiveType
? { locomotiveType: filter.locomotiveType as LocomotiveType }
: {}),
},
order: { code: 'ASC' },
});
}
async create(dto: CreateLocomotiveDto): Promise<Locomotive> {
const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } });
if (existing) {
throw new ConflictException(`Locomotive code ${dto.code} already exists`);
}
return this.locomotivesRepository.create({
code: dto.code,
name: dto.name?.trim() || null,
locomotiveType: dto.locomotiveType as LocomotiveType,
status: dto.status as LocomotiveStatus,
maxPullWeightTons: dto.maxPullWeightTons,
maxTrainLengthMeters: dto.maxTrainLengthMeters,
powerKw: dto.powerKw ?? null,
tractionForceKn: dto.tractionForceKn ?? null,
maxSpeedKmh: dto.maxSpeedKmh ?? null,
});
}
async findById(id: string): Promise<Locomotive> {
const locomotive = await this.locomotivesRepository.findById(id);
@@ -26,4 +51,48 @@ export class LocomotivesService {
return locomotive;
}
async update(id: string, dto: UpdateLocomotiveDto): Promise<Locomotive> {
const locomotive = await this.findById(id);
if (dto.code && dto.code !== locomotive.code) {
const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } });
if (existing && existing.id !== id) {
throw new ConflictException(`Locomotive code ${dto.code} already exists`);
}
}
const updated = await this.locomotivesRepository.update(id, {
...dto,
locomotiveType:
dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType,
status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus,
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
tractionForceKn:
dto.tractionForceKn === undefined ? locomotive.tractionForceKn : dto.tractionForceKn ?? null,
maxSpeedKmh:
dto.maxSpeedKmh === undefined ? locomotive.maxSpeedKmh : dto.maxSpeedKmh ?? null,
});
if (!updated) {
throw new NotFoundException(`Locomotive ${id} not found`);
}
return updated;
}
async decommission(id: string): Promise<Locomotive> {
await this.findById(id);
const updated = await this.locomotivesRepository.update(id, {
status: 'OUT_OF_SERVICE',
});
if (!updated) {
throw new NotFoundException(`Locomotive ${id} not found`);
}
return updated;
}
}

View File

@@ -1,6 +0,0 @@
import { IsString } from "class-validator";
export class InitiateBookingPayment {
@IsString()
bookingId!: string;
}

View File

@@ -4,7 +4,7 @@ import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn }
type PaymentType = "booking"
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
type Currency = "ETB" | "USD"
type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@Entity({ schema: 'freight', name: 'payments' })
export class PaymentEntity extends BaseEntity {

View File

@@ -1,53 +1,31 @@
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
import { Controller, Get, NotFoundException, Param, Post, Res } from "@nestjs/common";
import { PaymentService } from "./payment.service";
import { Public } from "@edr/api-common";
// import { randomUUID } from "crypto";
import { Response } from "express"
@Public()
@Controller("payments")
export class PaymentController {
constructor(private readonly paymentService: PaymentService,) { }
constructor(private readonly paymentService: PaymentService,) { }
@Post("/initiate")
initiate() {
return this.paymentService.initBookingTelebirr("123", "web")
}
// @Get("/receipts/:orderId/html")
// async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) {
// const filled = await this.paymentService.genReceiptHtml(orderId);
// return res.send(filled)
// }
@Post("/bookings/check-payment/:orderId")
checkPayment(@Param("orderId") orderId: string) {
return this.paymentService.checkStatusAndUpdate(orderId)
}
// @Post("/initiate/booking")
// async initiatePayment() {
// //Only for testing..
// const description = "Booking for contact"
// const price = 2000
// const data = await this.paymentService.pay(price, "ETB", "telebirr", description, "booking", (_) => {
// return new Promise((resp, _) => {
// resp({
// id: randomUUID(),
// type: "booking"
// })
// });
// })
// return data
// }
@Post("/bookings/check-payment/:orderId")
checkPayment(@Param("orderId", ParseUUIDPipe) orderId: string) {
return this.paymentService.checkStatusAndUpdate(orderId)
@Get("/bookings/telebirr/redirect/:orderId")
async pay(@Param("orderId") orderId: string, @Res() res: Response) {
const payment = await this.paymentService.getActivePaymentByOrderIdAndMethod(orderId, "telebirr")
if (!payment) {
throw new NotFoundException('payment not found')
}
@Get("/telebirr/:refId")
async pay(@Param("refId", ParseUUIDPipe) refId: string, @Res() res: Response) {
const payment = await this.paymentService.getActivePaymentByRefIdAndMethod(refId, "telebirr")
if (!payment) {
throw new NotFoundException('payment not found')
}
return res.send(`
return res.send(`
<!DOCTYPE html>
<html>
<head>
@@ -62,6 +40,5 @@ export class PaymentController {
</body>
</html>
`);
}
}
}

View File

@@ -1,5 +1,4 @@
import { Module } from "@nestjs/common";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
import { PaymentService } from "./payment.service";
import { HttpModule } from "@nestjs/axios";
import { PaymentController } from "./payment.controller";
@@ -7,10 +6,11 @@ import { ConfigModule } from "@nestjs/config";
import { PaymentRepository } from "./payment.repository";
import { WebhookController } from "./webhooks/webhook.controller";
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
import { TelebirrProvider } from "@edr/payment-providers";
@Module({
imports: [HttpModule, ConfigModule],
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider],
controllers: [PaymentController, WebhookController],
exports: [PaymentService]
})

View File

@@ -14,6 +14,13 @@ export class PaymentRepository {
return qr.manager.save(payment)
}
async create(data: Pick<PaymentEntity, "amount" | "method" | "currency" | "type" | "refId" | "merchantOrderId" | "rawInitiation" | "clientAction" | "expiresAt" | "reason">): Promise<PaymentEntity> {
const payment = this.paymentRepo.create(data)
return this.paymentRepo.save(payment)
}
findOneBy(options: FindOptionsWhere<PaymentEntity> | FindOptionsWhere<PaymentEntity>[]): Promise<PaymentEntity | null> {
return this.paymentRepo.findOneBy(options);
}
@@ -36,4 +43,20 @@ export class PaymentRepository {
getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]) {
return this.paymentRepo
.createQueryBuilder('payment')
.where('payment.method = :method', { method })
.andWhere('payment.merchantOrderId = :orderId', { orderId })
.andWhere('payment.status IN (:...statuses)', {
statuses: ['action-required'],
})
.andWhere('payment.expiresAt > :now', { now: new Date() })
.getOne();
}
}

View File

@@ -1,111 +1,90 @@
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common";
import { DataSource, QueryRunner } from "typeorm";
import {
BadRequestException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentStrategy } from "./strategies/payment.strategy";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
import { PaymentRepository } from "./payment.repository";
import { ClientAction, PaymentPlatform } from "./strategies/payments.types";
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as Handlebars from 'handlebars';
import * as fs from "fs";
import * as path from "path";
import * as Handlebars from "handlebars";
import { ConfigService } from "@nestjs/config";
import { Booking } from "../bookings/entities/booking.entity";
import {
ClientAction,
createMerchantOrderId,
ProviderPaymentStatus,
TelebirrProvider,
} from "@edr/payment-providers";
import { ProviderInitiationInput } from "@edr/types"
import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto";
type PaymentMethod = PaymentEntity["method"]
type CurrencyType = PaymentEntity["currency"]
const DEFAULT_CURRENCY = "ETB";
@Injectable()
export class PaymentService {
private strategies: Map<PaymentMethod, PaymentStrategy>;
constructor(
private readonly configService: ConfigService,
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrPaymentStategy: PaymentTelebirrStrategy) {
this.strategies = new Map([
["telebirr", this.telebirrPaymentStategy as PaymentStrategy]
])
}
private readonly telebirrProvider: TelebirrProvider,
) { }
async pay(amount: number, currency: CurrencyType, method: PaymentMethod, reason: string, type: PaymentEntity["type"], cb: (qr: QueryRunner) => Promise<{ id: string, type: PaymentEntity["type"] }>, payform: PaymentPlatform = "web"): Promise<{
refId: string,
clientAction: ClientAction,
status: PaymentEntity["status"],
paidAt?: string,
failureCode?: string,
failureMessage?: string,
}> {
async initBookingTelebirr(
bookingId: string,
platform: PaymentPlatformDto,
): Promise<{ redirectUrl: string }> {
// const booking = await this.datasource.getRepository(Booking).findOneBy({ id: bookingId });
// if (!booking) throw new NotFoundException("Booking not found");
// const booking = new Booking()
// booking.totalAmount = 20
// booking.id = randomUUID
const amount = 20
const merchantOrderId = createMerchantOrderId();
const redirectBase = this.configService.get<string>("TELEBIRR_SUCCESS_BOOKING_REDIRECT_BASE_URL");
const redirectUrl = `${redirectBase}/${merchantOrderId}`;
const amountMinor = Math.round(Number(amount) * 100);
const strategy = this.strategies.get(method)
if (!strategy) {
throw new NotFoundException("strategy not found")
}
const orderId = `${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic
let redirectUrl: string;
switch (type) {
case "booking":
const url = this.configService.get<string>("TELEBIRR_SUCCESS_REDIRECT_BASE_URL")
redirectUrl = `${url}/check-status/${orderId}`
break;
}
const paymentResp = await strategy.pay({
const input: ProviderInitiationInput = {
merchantOrderId,
orderRef: bookingId,
amountMinor,
currency: DEFAULT_CURRENCY,
platform: platform || "web",
redirectUrl,
amountMinor: amount,
currency: currency,
merchantOrderId: orderId,
platform: payform,
};
const result = await this.telebirrProvider.initiate(input);
const payment = await this.paymentRepo.create({
amount: amount,
currency: DEFAULT_CURRENCY,
method: "telebirr",
refId: bookingId,
type: "booking",
merchantOrderId,
rawInitiation: result.rawInitiation,
clientAction: result.clientAction as Record<string, unknown>,
expiresAt: result.expiresAt,
reason: `Payment for booking`,
});
const queryRunner = this.datasource.createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction()
console.log(paymentResp.expiresAt)
try {
const resp = await cb(queryRunner)
const payment = await this.paymentRepo.createTr(queryRunner, {
amount,
currency,
method,
refId: resp.id,
type: resp.type,
merchantOrderId: orderId,
rawInitiation: paymentResp.rawInitiation,
clientAction: paymentResp.clientAction,
expiresAt: paymentResp.expiresAt,
reason
})
await queryRunner.commitTransaction()
return {
refId: payment.refId,
clientAction: paymentResp.clientAction,
status: payment.status,
paidAt: payment.paidAt?.toISOString(),
failureCode: payment.failerCode ?? undefined,
failureMessage: payment.failureMessage ?? undefined,
}
} catch (err) {
await queryRunner.rollbackTransaction()
throw new Error("payment failed")
} finally {
await queryRunner.release()
return {
redirectUrl: `${this.configService.get<string>("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}`
}
}
async getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method)
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method)
}
async genReceiptHtml(orderId: string) {
const payment = await this.paymentRepo.findOneBy({
merchantOrderId: orderId,
@@ -141,13 +120,9 @@ export class PaymentService {
if (!resp) {
throw new NotFoundException("order id not found")
}
const result = await this.telebirrPaymentStategy.queryStatus(resp.merchantOrderId)
const bizContent = result.rawResponse.biz_content as {
order_status: string;
};
const result = await this.telebirrProvider.queryStatus(resp.merchantOrderId)
const ordersStatus = bizContent.order_status
if (ordersStatus == "PAY_SUCCESS") {
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
await this.datasource.transaction(async (mg) => {
await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" })
@@ -158,5 +133,28 @@ export class PaymentService {
}
}
}
findBookingById(id: string) {
return this.paymentRepo.findOneBy({ refId: id, type: "booking" })
}
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
const clientAction =
intent.clientAction && typeof intent.clientAction === "object"
? (intent.clientAction as unknown as ClientAction)
: undefined;
const statusMap: Record<string, ProviderPaymentStatus> = {
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
"processing": ProviderPaymentStatus.PROCESSING,
"success": ProviderPaymentStatus.SUCCEEDED,
"failed": ProviderPaymentStatus.FAILED,
"canceled": ProviderPaymentStatus.CANCELLED,
"refunded": ProviderPaymentStatus.CANCELLED,
};
return {
intentId: intent.id,
status: statusMap[intent.status] ?? ProviderPaymentStatus.PROCESSING,
clientAction,
merchantOrderId: intent.merchantOrderId ?? undefined,
};
}
}

View File

@@ -0,0 +1,62 @@
import { ProviderPaymentStatus } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
export type PaymentPlatformDto = "web" | "mobile";
export class InitiatePaymentDto {
@ApiProperty({ example: "booking-uuid" })
@IsString()
bookingId!: string;
@ApiProperty({ enum: ["TELEBIRR"], example: "TELEBIRR" })
@IsIn(["TELEBIRR"])
method!: "TELEBIRR";
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
@IsOptional()
@IsIn(["web", "mobile"])
platform?: PaymentPlatformDto;
}
export class ClientActionDto {
@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)" })
appId?: 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 {
@ApiProperty()
intentId!: string;
@ApiProperty({ enum: ProviderPaymentStatus })
status!: ProviderPaymentStatus;
@ApiPropertyOptional({ type: ClientActionDto })
clientAction?: ClientActionDto;
@ApiPropertyOptional()
merchantOrderId?: string;
}
export class IntentStatusDto extends InitiateResponseDto {
@ApiPropertyOptional()
paidAt?: string;
@ApiPropertyOptional()
failureCode?: string;
@ApiPropertyOptional()
failureMessage?: string;
}

View File

@@ -1,8 +0,0 @@
import { Injectable } from "@nestjs/common";
import { ProviderInitiationInput, ProviderInitiationResult } from "./payments.types";
@Injectable()
export abstract class PaymentStrategy {
abstract pay(data: ProviderInitiationInput): Promise<ProviderInitiationResult>
}

View File

@@ -1,304 +0,0 @@
import { Injectable, Logger } from "@nestjs/common";
import { PaymentStrategy } from "./payment.strategy";
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as https from 'node:https';
import { PaymentEntity } from "../entities/payment.entity";
import { ProviderInitiationInput, ProviderInitiationResult, ProviderStatus } from "./payments.types";
import { CreateOrderRequest, CreateOrderResponse, FabricTokenResponse, QueryOrderResponse } from "./telebirr/telebirr.types";
import { createNonceStr, createTimestamp, signRequestObject, verifyRequestObject } from "./telebirr/telebirr.crypto";
// type PaymentCurrency = PaymentEntity["currency"]
type PaymentIntentStatus = PaymentEntity["status"]
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
@Injectable()
export class PaymentTelebirrStrategy implements PaymentStrategy {
async pay(data: ProviderInitiationInput): Promise<any> {
// const refId = randomUUID()
// const orderId = createMerchantOrderId()
const resp = await this.initiate(data)
return resp;
}
// readonly method = PaymentMethodType.TELEBIRR;
private readonly logger = new Logger(PaymentTelebirrStrategy.name);
private readonly httpsAgent: https.Agent;
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {
const insecure = this.config.get<boolean>('telebirr.insecureTls');
if (insecure) {
this.logger.warn('TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.');
}
this.httpsAgent = new https.Agent({
rejectUnauthorized: !insecure,
secureProtocol: 'TLSv1_2_method',
});
}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildCreateOrderRequest(input);
const response = await this.requestCreateOrder(fabricToken, requestBody);
const prepayId = response.biz_content?.prepay_id;
if (!prepayId) {
throw new Error(
`Telebirr createOrder returned no prepay_id: ${JSON.stringify(response)}`,
);
}
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,
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
const response = await this.postJson<QueryOrderResponse>(
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
requestBody,
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
Authorization: fabricToken,
},
);
const tradeStatus = response.biz_content?.trade_status;
const providerTxnId =
response.biz_content?.trans_id ?? response.biz_content?.payment_order_id;
const mapped = this.mapTradeStatus(tradeStatus);
return {
status: mapped,
providerTxnId,
failureCode:
mapped === "failed" && tradeStatus ? tradeStatus : undefined,
rawResponse: response as Record<string, unknown>,
};
}
mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
switch (tradeStatus) {
case 'PAY_SUCCESS':
return "success";
case 'PAY_FAILED':
case 'ORDER_CLOSED':
return "failed";
case 'WAIT_PAY':
return "action-required";
case 'PAYING':
return "processing";
default:
return "processing";
}
}
mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
switch (tradeStatus) {
case 'Completed':
return "success";
case 'Failure':
case 'Expired':
return "failed";
case 'Paying':
case 'Pending':
return "processing";
default:
return "processing";
}
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
if (!this.publicKey) {
this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks');
return false;
}
return verifyRequestObject(payload, this.publicKey);
}
private async applyFabricToken(): Promise<string> {
console.log(this.baseUrl, "base url")
const response = await this.postJson<FabricTokenResponse>(
`${this.baseUrl}/payment/v1/token`,
{ appSecret: this.appSecret },
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
},
);
if (!response?.token) {
throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`);
}
return response.token;
}
private async requestCreateOrder(
fabricToken: string,
body: CreateOrderRequest,
): Promise<CreateOrderResponse> {
return this.postJson<CreateOrderResponse>(
`${this.baseUrl}/payment/v1/inapp/createOrder`,
body,
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
Authorization: fabricToken,
},
);
}
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
// const totalAmount = String(input.amountMinor / 100);
const totalAmount = String(input.amountMinor)
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
method: 'payment.preorder' as const,
version: '1.0' as const,
biz_content: {
notify_url: this.notifyUrl,
appid: this.merchantAppId,
redirect_url: input.redirectUrl,
merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId,
trade_type: 'Checkout' as const,
title: `EDR Booking`,
total_amount: totalAmount,
trans_currency: input.currency,
timeout_express: this.timeoutExpress,
},
};
const sign = signRequestObject(req as unknown as Record<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
}
private buildQueryOrderRequest(merchantOrderId: string): Record<string, unknown> {
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
method: 'payment.queryorder',
version: '1.0',
biz_content: {
appid: this.merchantAppId,
merch_code: this.merchantCode,
merch_order_id: merchantOrderId,
},
};
const sign = signRequestObject(req as Record<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
}
private buildCheckoutUrl(prepayId: string): string {
const map: Record<string, string> = {
appid: this.merchantAppId,
merch_code: this.merchantCode,
nonce_str: createNonceStr(),
prepay_id: prepayId,
timestamp: createTimestamp(),
};
const sign = signRequestObject(map, this.privateKey);
const rawRequest = [
`appid=${map.appid}`,
`merch_code=${map.merch_code}`,
`nonce_str=${map.nonce_str}`,
`prepay_id=${map.prepay_id}`,
`timestamp=${map.timestamp}`,
'sign_type=SHA256WithRSA',
`sign=${sign}`,
'version=1.0',
'trade_type=Checkout',
].join('&');
return `${this.webBaseUrl}${rawRequest}`;
}
private computeExpiresAt(timeoutExpress: string): Date {
const match = /^(\d+)([smhd])$/.exec(timeoutExpress);
const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15;
return new Date(Date.now() + minutes * 60_000);
}
private toMinutes(n: number, unit: string): number {
switch (unit) {
case 's': return Math.max(1, Math.round(n / 60));
case 'm': return n;
case 'h': return n * 60;
case 'd': return n * 60 * 24;
default: return 15;
}
}
private async postJson<T>(
url: string,
body: unknown,
headers: Record<string, string>,
): Promise<T> {
const config: AxiosRequestConfig = {
headers,
timeout: TELEBIRR_HTTP_TIMEOUT_MS,
httpsAgent: this.httpsAgent,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
);
} else {
this.logger.error(`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private sanitize(body: CreateOrderRequest): Record<string, unknown> {
const { sign: _sign, ...rest } = body;
return rest;
}
private get baseUrl(): string { return this.config.get<string>('telebirr.baseUrl') ?? ''; }
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
private get publicKey(): string {
return this.config.get<string>('telebirr.publicKey') ?? '';
}
}

View File

@@ -1,40 +0,0 @@
import { PaymentEntity } from "../entities/payment.entity";
type PaymentIntentStatus = PaymentEntity["status"]
type PaymentMethodType = PaymentEntity["method"]
export type PaymentPlatform = 'web' | 'mobile';
export type ClientAction =
| { type: 'REDIRECT'; url: string }
| { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string };
export interface ProviderInitiationInput {
redirectUrl: string;
merchantOrderId: string;
// bookingRef: string;
amountMinor: number;
currency: string;
platform?: PaymentPlatform;
}
export interface ProviderInitiationResult {
providerOrderId: string;
clientAction: ClientAction;
expiresAt: Date;
rawInitiation: Record<string, unknown>;
}
export interface ProviderStatus {
status: PaymentIntentStatus;
providerTxnId?: string;
failureCode?: string;
failureMessage?: string;
rawResponse: Record<string, unknown>;
}
export interface PaymentProvider {
readonly method: PaymentMethodType;
initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult>;
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
}

View File

@@ -1,98 +0,0 @@
import * as crypto from 'crypto';
const EXCLUDE_FIELDS = new Set([
'sign',
'sign_type',
'header',
'refund_info',
'openType',
'raw_request',
'biz_content',
]);
const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export function buildCanonicalString(requestObject: Record<string, unknown>): string {
const fieldMap: Record<string, unknown> = {};
for (const key of Object.keys(requestObject)) {
if (EXCLUDE_FIELDS.has(key)) continue;
fieldMap[key] = requestObject[key];
}
const biz = requestObject['biz_content'];
if (biz && typeof biz === 'object') {
for (const key of Object.keys(biz as Record<string, unknown>)) {
if (EXCLUDE_FIELDS.has(key)) continue;
fieldMap[key] = (biz as Record<string, unknown>)[key];
}
}
return Object.keys(fieldMap)
.sort()
.map((k) => `${k}=${fieldMap[k]}`)
.join('&');
}
export function signRequestObject(
requestObject: Record<string, unknown>,
privateKey: string,
): string {
return signString(buildCanonicalString(requestObject), privateKey);
}
export function verifyRequestObject(
requestObject: Record<string, unknown>,
publicKey: string,
): boolean {
const signature = requestObject['sign'];
if (typeof signature !== 'string' || signature.length === 0) return false;
return verifySignature(buildCanonicalString(requestObject), signature, publicKey);
}
export function signString(text: string, privateKey: string): string {
const signature = crypto.sign('sha256', Buffer.from(text), {
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
});
return signature.toString('base64');
}
export function verifySignature(
text: string,
signatureBase64: string,
publicKey: string,
): boolean {
try {
return crypto.verify(
'sha256',
Buffer.from(text),
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
},
Buffer.from(signatureBase64, 'base64'),
);
} catch {
return false;
}
}
export function createTimestamp(): string {
return Math.round(Date.now() / 1000).toString();
}
export function createNonceStr(length = 32): string {
const bytes = crypto.randomBytes(length);
let out = '';
for (let i = 0; i < length; i++) {
out += NONCE_CHARS[bytes[i] % NONCE_CHARS.length];
}
return out;
}
export function createMerchantOrderId(): string {
return `${Date.now()}${crypto.randomBytes(4).toString('hex')}`;
}

View File

@@ -1,69 +0,0 @@
export interface FabricTokenResponse {
token: string;
expires_in?: number | string;
}
export interface CreateOrderBizContent {
notify_url: string;
appid: string;
merch_code: string;
merch_order_id: string;
trade_type: 'Checkout' | 'InApp' | 'MiniApp';
title: string;
total_amount: string;
trans_currency: string;
timeout_express: string;
}
export interface CreateOrderRequest {
timestamp: string;
nonce_str: string;
method: 'payment.preorder';
version: '1.0';
biz_content: CreateOrderBizContent;
sign: string;
sign_type: 'SHA256WithRSA';
}
export interface CreateOrderResponse {
code?: string;
msg?: string;
biz_content?: {
prepay_id?: string;
receiveCode?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export type TelebirrTradeStatus =
| 'PAY_SUCCESS'
| 'PAY_FAILED'
| 'WAIT_PAY'
| 'ORDER_CLOSED'
| 'PAYING'
| 'ACCEPTED'
| 'REFUNDING'
| 'REFUND_SUCCESS'
| 'REFUND_FAILED';
export interface QueryOrderResponse {
result?: 'SUCCESS' | 'FAIL';
code?: string;
msg?: string;
nonce_str?: string;
sign?: string;
sign_type?: string;
biz_content?: {
merch_order_id?: string;
order_status?: string;
trade_status?: TelebirrTradeStatus | string;
payment_order_id?: string;
trans_id?: string;
trans_time?: string;
trans_currency?: string;
total_amount?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}

View File

@@ -1,82 +1,53 @@
import { Injectable, } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from "crypto"
import { Injectable, Logger } from '@nestjs/common';
import { TelebirrDto } from '../dto/telebirr.dto';
import { PaymentRepository } from '../../payment.repository';
import { DataSource } from 'typeorm';
import { Booking } from 'src/modules/bookings/entities/booking.entity';
import { Booking } from '../../../bookings/entities/booking.entity';
import { TelebirrProvider, ProviderPaymentStatus } from '@edr/payment-providers';
@Injectable()
export class TelebirrWebhookService {
// private readonly logger = new Logger(TelebirrWebhookService.name);
private readonly logger = new Logger(TelebirrWebhookService.name);
constructor(
private readonly datasource: DataSource,
private readonly config: ConfigService,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrProvider: TelebirrProvider,
) { }
verifyTelebirrNotification(payload: TelebirrDto) {
// 1. Extract the signature provided by Telebirr
const { sign, ...bizContent } = payload;
if (!sign) {
throw new Error("Missing 'sign' field from Telebirr payload");
}
// 2. Sort the remaining keys alphabetically to rebuild the raw string
const sortedKeys = Object.keys(bizContent).sort();
const signString = sortedKeys
.map(key => `${key}=${typeof bizContent[key] === 'object' ? JSON.stringify(bizContent[key]) : bizContent[key]}`)
.join('&');
// 3. Convert Telebirr's public key into an object specifying RSA-PSS padding
const publicKey = {
key: this.config.get<string>("telebirr.publicKey") ?? "",
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: 32 // Telebirr standard salt length
};
// 4. Verify the signature against the sorted string
const isVerified = crypto.verify(
"sha256",
Buffer.from(signString),
publicKey,
Buffer.from(sign, 'base64')
);
return isVerified;
return this.telebirrProvider.verifyWebhookSignature(payload as unknown as Record<string, unknown>);
}
async handle(payload: TelebirrDto): Promise<void> {
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id })
if (!payment) {
throw new Error("payment not found")
this.logger.warn(`Webhook received for unknown merchantOrderId: ${payload.merch_order_id}`);
return;
}
switch (payload.trade_status) {
case "SUCCEEDED":
await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() })
switch (payment.type) {
case "booking":
await this.datasource.manager.update(Booking, { id: payment.refId }, { paymentStatus: "PAID", })
// await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", })
break;
const mapped = this.telebirrProvider.mapWebhookTradeStatus(payload.trade_status);
switch (mapped) {
case ProviderPaymentStatus.SUCCEEDED:
await this.paymentRepo.update(
{ id: payment.id },
{ status: "success", paidAt: new Date() },
);
if (payment.type === "booking") {
await this.datasource.manager.update(
Booking,
{ id: payment.refId },
{ paymentStatus: "PAID" },
);
}
break;
case "FAILED":
await this.paymentRepo.update({ id: payment.id }, { status: "failed" })
case ProviderPaymentStatus.FAILED:
await this.paymentRepo.update({ id: payment.id }, { status: "failed" });
break;
case "CANCELLED":
await this.paymentRepo.update({ id: payment.id }, { status: "canceled" })
case ProviderPaymentStatus.PROCESSING:
await this.paymentRepo.update({ id: payment.id }, { status: "processing" });
break;
case "PROCESSING":
await this.paymentRepo.update({ id: payment.id }, { status: "processing" })
break;
case "REFUNDED":
await this.paymentRepo.update({ id: payment.id }, { status: "refunded" })
break;
}
}
}

View File

@@ -22,14 +22,12 @@ export class WebhookController {
);
try {
// const verified = this.telebirr.verifyTelebirrNotification(payload)
// if (!verified) {
// throw new Error("not valid")
// }
// const merchantOrderId = payload.merch_order_id;
const verified = this.telebirr.verifyTelebirrNotification(payload)
if (!verified) {
throw new Error("Telebirr webhook signature verification failed")
}
await this.telebirr.handle(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Telebirr webhook handler threw: ${message}`);

View File

@@ -0,0 +1,28 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator';
export class CreateRouteMilestoneDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
}
export class CreateRouteDto {
@ApiProperty()
@IsString()
@MaxLength(120)
name!: string;
@ApiProperty({ type: [CreateRouteMilestoneDto] })
@IsArray()
@ArrayMinSize(2)
@ValidateNested({ each: true })
@Type(() => CreateRouteMilestoneDto)
milestones!: CreateRouteMilestoneDto[];
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class FilterRoutesDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/swagger';
import { CreateRouteDto } from './create-route.dto';
export class UpdateRouteDto extends PartialType(CreateRouteDto) {}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Route } from './route.entity';
@Entity({ schema: 'freight', name: 'route_milestones' })
@Index(['routeId', 'sequenceNo'], { unique: true })
export class RouteMilestone extends BaseEntity {
@Column({ name: 'route_id', type: 'uuid' })
routeId!: string;
@ManyToOne(() => Route, (route) => route.milestones, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'route_id' })
route?: Route;
@Column({ name: 'yard_id', type: 'uuid' })
yardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'yard_id' })
yard?: Yard;
@Column({ name: 'sequence_no', type: 'int' })
sequenceNo!: number;
}

View File

@@ -0,0 +1,33 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { RouteMilestone } from './route-milestone.entity';
@Entity({ schema: 'freight', name: 'routes' })
@Index(['name'])
@Index(['isActive'])
export class Route extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 120, unique: true })
name!: string;
@Column({ name: 'origin_yard_id', type: 'uuid' })
originYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;
@Column({ name: 'destination_yard_id', type: 'uuid' })
destinationYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'destination_yard_id' })
destinationYard?: Yard;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
milestones?: RouteMilestone[];
}

View File

@@ -0,0 +1,13 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RouteMilestone } from './entities/route-milestone.entity';
@Injectable()
export class RouteMilestonesRepository extends BaseRepository<RouteMilestone> {
constructor(@InjectRepository(RouteMilestone) repository: Repository<RouteMilestone>) {
super(repository);
}
}

View File

@@ -0,0 +1,44 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto';
import { RoutesService } from './routes.service';
@ApiTags('routes')
@ApiBearerAuth()
@Controller('routes')
export class RoutesController {
constructor(private readonly routesService: RoutesService) {}
@Get()
@ApiOperation({ summary: 'List routes' })
findAll(@Query() filter: FilterRoutesDto) {
return this.routesService.findAll(filter);
}
@Get(':id')
@ApiOperation({ summary: 'Get route by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.routesService.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create route' })
create(@Body() dto: CreateRouteDto) {
return this.routesService.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update route' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
return this.routesService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Deactivate route' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.routesService.deactivate(id);
}
}

View File

@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Yard } from '../rule-engine/entities/yard.entity';
import { RouteMilestone } from './entities/route-milestone.entity';
import { Route } from './entities/route.entity';
import { RouteMilestonesRepository } from './route-milestones.repository';
import { RoutesController } from './routes.controller';
import { RoutesRepository } from './routes.repository';
import { RoutesService } from './routes.service';
@Module({
imports: [TypeOrmModule.forFeature([Route, RouteMilestone, Yard])],
controllers: [RoutesController],
providers: [RoutesRepository, RouteMilestonesRepository, RoutesService],
exports: [RoutesRepository, RouteMilestonesRepository, RoutesService],
})
export class RoutesModule {}

View File

@@ -0,0 +1,13 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Route } from './entities/route.entity';
@Injectable()
export class RoutesRepository extends BaseRepository<Route> {
constructor(@InjectRepository(Route) repository: Repository<Route>) {
super(repository);
}
}

View File

@@ -0,0 +1,171 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, ILike } from 'typeorm';
import { Yard } from '../rule-engine/entities/yard.entity';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto';
import { RouteMilestone } from './entities/route-milestone.entity';
import { Route } from './entities/route.entity';
import { RoutesRepository } from './routes.repository';
@Injectable()
export class RoutesService {
constructor(
private readonly dataSource: DataSource,
private readonly routesRepository: RoutesRepository,
) {}
findAll(filter: FilterRoutesDto): Promise<Route[]> {
return this.routesRepository.findAll({
where: {
...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}),
...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}),
},
relations: {
originYard: true,
destinationYard: true,
milestones: { yard: true },
},
order: {
name: 'ASC',
milestones: { sequenceNo: 'ASC' },
},
});
}
async findById(id: string): Promise<Route> {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id },
relations: {
originYard: true,
destinationYard: true,
milestones: { yard: true },
},
order: { milestones: { sequenceNo: 'ASC' } },
});
if (!route) {
throw new NotFoundException(`Route ${id} not found`);
}
return route;
}
async create(dto: CreateRouteDto): Promise<Route> {
await this.validateRouteName(dto.name);
const validated = await this.validateMilestones(dto.milestones);
const route = await this.dataSource.transaction(async (manager) => {
const savedRoute = await manager.getRepository(Route).save(
manager.getRepository(Route).create({
name: dto.name.trim(),
originYardId: validated.originYardId,
destinationYardId: validated.destinationYardId,
isActive: dto.isActive ?? true,
}),
);
await manager.getRepository(RouteMilestone).save(
validated.milestones.map((milestone) =>
manager.getRepository(RouteMilestone).create({
routeId: savedRoute.id,
yardId: milestone.yardId,
sequenceNo: milestone.sequenceNo,
}),
),
);
return savedRoute;
});
return this.findById(route.id);
}
async update(id: string, dto: UpdateRouteDto): Promise<Route> {
const existing = await this.findById(id);
if (dto.name && dto.name.trim() !== existing.name) {
await this.validateRouteName(dto.name, id);
}
const milestoneInput = dto.milestones
? await this.validateMilestones(dto.milestones)
: null;
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(Route).update(id, {
name: dto.name?.trim() ?? existing.name,
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId,
isActive: dto.isActive ?? existing.isActive,
});
if (milestoneInput) {
await manager.getRepository(RouteMilestone).delete({ routeId: id });
await manager.getRepository(RouteMilestone).save(
milestoneInput.milestones.map((milestone) =>
manager.getRepository(RouteMilestone).create({
routeId: id,
yardId: milestone.yardId,
sequenceNo: milestone.sequenceNo,
}),
),
);
}
});
return this.findById(id);
}
async deactivate(id: string): Promise<Route> {
await this.findById(id);
const updated = await this.routesRepository.update(id, { isActive: false });
if (!updated) {
throw new NotFoundException(`Route ${id} not found`);
}
return this.findById(id);
}
private async validateRouteName(name: string, routeId?: string) {
const trimmedName = name.trim();
const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } });
if (existing && existing.id !== routeId) {
throw new ConflictException(`Route name ${trimmedName} already exists`);
}
}
private async validateMilestones(milestones: Array<{ yardId: string }>) {
if (milestones.length < 2) {
throw new BadRequestException('A route requires at least two yards');
}
const normalized = milestones.map((milestone, index) => ({
yardId: milestone.yardId,
sequenceNo: index + 1,
}));
const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))];
const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) });
const yardIds = new Set(yards.map((yard) => yard.id));
for (const milestone of normalized) {
if (!yardIds.has(milestone.yardId)) {
throw new BadRequestException(`Yard ${milestone.yardId} does not exist`);
}
}
if (normalized[0].yardId === normalized[normalized.length - 1].yardId) {
throw new BadRequestException('Origin and destination yards must be different');
}
return {
originYardId: normalized[0].yardId,
destinationYardId: normalized[normalized.length - 1].yardId,
milestones: normalized,
};
}
}

View File

@@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Route } from '../../routes/entities/route.entity';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from './train-schedule-booking.entity';
@@ -26,6 +27,13 @@ export class TrainSchedule extends BaseEntity {
@JoinColumn({ name: 'train_set_id' })
trainSet?: TrainSet;
@Column({ name: 'route_id', type: 'uuid', nullable: true })
routeId?: string | null;
@ManyToOne(() => Route)
@JoinColumn({ name: 'route_id' })
route?: Route | null;
@Column({ name: 'origin_station_id', type: 'uuid' })
originStationId!: string;

View File

@@ -1,9 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
import { IsDateString, IsUUID } from 'class-validator';
import { PreviewContainerTrainScheduleDto } from './preview-container-train-schedule.dto';
export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
routeId!: string;
@ApiProperty({ example: '2026-06-20T08:00:00.000Z' })
@IsDateString()
scheduleDate!: string;
export class CreateContainerTrainScheduleDto extends PreviewContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
locomotiveId!: string;

View File

@@ -17,6 +17,7 @@ const locomotive = {
id: 'loc-1',
code: 'LOC-001',
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
status: 'AVAILABLE',
};
@@ -202,47 +203,12 @@ describe('TrainSchedulingService', () => {
});
it('creates a schedule transactionally when validation passes', async () => {
const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')];
const validation = {
valid: true,
violations: [],
bookings,
wagonType: nw5,
summary: {
totalBookings: 1,
totalWeightTons: 140,
wagonType: 'NW5',
wagonsNeeded: 2,
totalLengthMeters: 28,
},
wagonPlan: [
{
sequenceNo: 1,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [
{
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
},
],
},
{
sequenceNo: 2,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [
{
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
},
],
},
],
const route = {
id: 'route-1',
name: 'Djibouti to Addis',
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
isActive: true,
};
const lockedLocomotiveRepo = {
@@ -253,23 +219,6 @@ describe('TrainSchedulingService', () => {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
};
const trainScheduleBookingRepo = {
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const trainSetWagonRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
find: jest.fn().mockResolvedValue([
{ id: 'wagon-1', sequenceNo: 1 },
{ id: 'wagon-2', sequenceNo: 2 },
]),
};
const wagonAllocRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const trainSetRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
@@ -281,12 +230,6 @@ describe('TrainSchedulingService', () => {
return lockedLocomotiveRepo;
case 'TrainSchedule':
return trainScheduleRepo;
case 'TrainScheduleBooking':
return trainScheduleBookingRepo;
case 'TrainSetWagon':
return trainSetWagonRepo;
case 'WagonBookingAllocation':
return wagonAllocRepo;
case 'TrainSet':
return trainSetRepo;
default:
@@ -295,70 +238,60 @@ describe('TrainSchedulingService', () => {
}),
};
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
return { findOne: jest.fn().mockResolvedValue(route) };
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
const result = await service.createContainerTrainSchedule({
bookingIds: ['b1'],
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
locomotiveId: 'loc-1',
});
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(trainSetWagonRepo.save).toHaveBeenCalled();
expect(wagonAllocRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
expect(result).toEqual({ id: 'schedule-1' });
});
it('rejects create when the locked locomotive is no longer available', async () => {
const validation = {
valid: true,
violations: [],
bookings: [makeBooking('b1', 'BKG-CONT-001', 70, 1, '40FT')],
wagonType: nw5,
summary: {
totalBookings: 1,
totalWeightTons: 70,
wagonType: 'NW5',
wagonsNeeded: 1,
totalLengthMeters: 14,
},
wagonPlan: [
{
sequenceNo: 1,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [],
},
],
};
const manager = {
getRepository: jest.fn(() => ({
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
})),
};
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
return {
findOne: jest.fn().mockResolvedValue({
id: 'route-1',
name: 'Djibouti to Addis',
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
isActive: true,
}),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
await expect(
service.createContainerTrainSchedule({
bookingIds: ['b1'],
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
locomotiveId: 'loc-1',
}),
).rejects.toBeInstanceOf(ConflictException);

View File

@@ -15,9 +15,9 @@ import {
import { LocomotivesRepository } from "../locomotives/locomotives.repository";
import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity";
import { TrainSet } from "../train-sets/entities/train-set.entity";
import { Route } from "../routes/entities/route.entity";
import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
import { WagonTypesRepository } from "../wagon-types/wagon-types.repository";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
@@ -179,18 +179,12 @@ export class TrainSchedulingService {
}
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const validation = await this.validateContainerBookingsForScheduling(dto);
if (!validation.valid) {
throw new BadRequestException({
message: "train_schedule_invalid",
violations: validation.violations,
});
}
const route = await this.getActiveRoute(dto.routeId);
const locomotive = await this.selectOrValidateLocomotive(
dto.locomotiveId,
validation.summary.totalWeightTons,
0,
0,
);
const createdSchedule = await this.dataSource.transaction(
@@ -211,90 +205,24 @@ export class TrainSchedulingService {
);
}
if (
Number(lockedLocomotive.maxPullWeightTons) <
validation.summary.totalWeightTons
) {
throw new BadRequestException(
`Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`,
);
}
const existingScheduleCount = await manager
.getRepository(TrainScheduleBooking)
.count({
where: {
bookingId: In(validation.bookings.map((booking) => booking.id)),
},
});
if (existingScheduleCount > 0) {
throw new BadRequestException(
"One or more bookings are already scheduled",
);
}
const trainSet = await this.buildTrainSet(
const trainSet = await this.buildEmptyTrainSet(
manager,
lockedLocomotive,
validation.wagonType,
validation.summary.totalWeightTons,
validation.summary.totalLengthMeters,
validation.wagonPlan,
);
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
routeId: route.id,
originStationId: route.originYardId,
destinationStationId: route.destinationYardId,
scheduledDepartureDate: new Date(dto.scheduleDate),
status: "SCHEDULED",
status: "DRAFT",
});
const savedSchedule = await manager
.getRepository(TrainSchedule)
.save(schedule);
const scheduleBookings = validation.bookings.map((booking) =>
manager.getRepository(TrainScheduleBooking).create({
trainScheduleId: savedSchedule.id,
bookingId: booking.id,
}),
);
await manager
.getRepository(TrainScheduleBooking)
.save(scheduleBookings);
const savedWagons = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: trainSet.id },
order: { sequenceNo: "ASC" },
});
const wagonBySequence = new Map(
savedWagons.map((wagon) => [wagon.sequenceNo, wagon]),
);
const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => {
const wagon = wagonBySequence.get(wagonPlan.sequenceNo);
if (!wagon) {
throw new BadRequestException(
`Missing wagon sequence ${wagonPlan.sequenceNo}`,
);
}
return wagonPlan.allocations.map((allocation) =>
manager.getRepository(WagonBookingAllocation).create({
trainSetWagonId: wagon.id,
bookingId: allocation.bookingId,
allocatedWeightTons: allocation.allocatedWeightTons,
}),
);
});
await manager
.getRepository(WagonBookingAllocation)
.save(allocationRows);
await locomotiveRepository.update(lockedLocomotive.id, {
status: "ASSIGNED",
});
@@ -461,10 +389,14 @@ export class TrainSchedulingService {
where: { status: "AVAILABLE" },
});
const canPull = capableLocomotives.some(
(locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons,
(locomotive) =>
Number(locomotive.maxPullWeightTons) >= totalWeightTons &&
Number(locomotive.maxTrainLengthMeters) >= totalLengthMeters,
);
if (!canPull) {
violations.push("No available locomotive can pull the total weight");
violations.push(
'No available locomotive can support the total train weight and length',
);
}
}
@@ -513,6 +445,7 @@ export class TrainSchedulingService {
async selectOrValidateLocomotive(
locomotiveId: string,
totalWeightTons: number,
totalLengthMeters: number,
) {
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
@@ -532,6 +465,12 @@ export class TrainSchedulingService {
);
}
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
);
}
return locomotive;
}
@@ -568,6 +507,21 @@ export class TrainSchedulingService {
return savedTrainSet;
}
async buildEmptyTrainSet(
manager: EntityManager,
locomotive: Locomotive,
) {
const trainSet = manager.getRepository(TrainSet).create({
locomotiveId: locomotive.id,
totalWeightTons: 0,
totalLengthMeters: 0,
wagonCount: 0,
status: 'DRAFT',
});
return manager.getRepository(TrainSet).save(trainSet);
}
allocateBookingsToWagons(
bookings: Booking[],
baseWagonPlan: WagonPlanRecord[],
@@ -627,6 +581,7 @@ export class TrainSchedulingService {
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
relations: {
trainSet: { locomotive: true },
route: true,
originStation: true,
destinationStation: true,
scheduleBookings: true,
@@ -637,6 +592,7 @@ export class TrainSchedulingService {
return schedules.map((schedule) => ({
id: schedule.id,
scheduleDate: schedule.scheduledDepartureDate,
routeName: schedule.route?.name ?? null,
origin:
schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination:
@@ -668,6 +624,7 @@ export class TrainSchedulingService {
.findOne({
where: { id },
relations: {
route: true,
trainSet: {
locomotive: true,
wagons: { wagonType: true, allocations: { booking: true } },
@@ -687,6 +644,12 @@ export class TrainSchedulingService {
return {
id: schedule.id,
status: schedule.status,
route: schedule.route
? {
id: schedule.route.id,
name: schedule.route.name,
}
: null,
scheduledDepartureDate: schedule.scheduledDepartureDate,
scheduledArrivalDate: schedule.scheduledArrivalDate,
originStation: schedule.originStation,
@@ -711,6 +674,9 @@ export class TrainSchedulingService {
maxPullWeightTons: this.roundTons(
Number(schedule.trainSet.locomotive.maxPullWeightTons),
),
maxTrainLengthMeters: this.roundTons(
Number(schedule.trainSet.locomotive.maxTrainLengthMeters),
),
}
: null,
wagons: [...(schedule.trainSet.wagons ?? [])]
@@ -806,6 +772,22 @@ export class TrainSchedulingService {
});
}
private async getActiveRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId },
});
if (!route) {
throw new NotFoundException(`Route ${routeId} not found`);
}
if (!route.isActive) {
throw new BadRequestException(`Route ${route.name} is inactive`);
}
return route;
}
private toUtcDateKey(value: Date | string) {
const date = value instanceof Date ? value : new Date(value);
return date.toISOString().slice(0, 10);

View File

@@ -14,6 +14,9 @@ import {
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value);
const toOptionalNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? undefined : Number(value);
const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
@@ -22,8 +25,12 @@ const toBoolean = ({ value }: { value: unknown }) => {
};
const toStringArray = ({ value }: { value: unknown }) => {
if (Array.isArray(value)) return value;
if (Array.isArray(value)) {
return value.map((entry) => String(entry).trim()).filter(Boolean);
}
if (typeof value !== 'string') return [];
return value
.split(',')
.map((entry) => entry.trim())
@@ -31,36 +38,40 @@ const toStringArray = ({ value }: { value: unknown }) => {
};
export class CreateWagonTypeDto {
@ApiProperty({ maxLength: 32, example: 'FLAT' })
@ApiProperty({ maxLength: 32, example: 'NW5' })
@IsString()
@MaxLength(32)
code!: string;
@ApiProperty({ maxLength: 100, example: 'Flat wagon' })
@ApiProperty({ maxLength: 100, example: 'Flat wagon container' })
@IsString()
@MaxLength(100)
name!: string;
@ApiProperty({ example: 60 })
@ApiProperty({ description: 'Maximum payload capacity in metric tons', example: 70 })
@Transform(toNumber)
@IsNumber()
@Min(0)
@Min(0.001)
capacityTons!: number;
@ApiProperty({ example: 14.2 })
@ApiProperty({ description: 'Wagon length in meters', example: 14 })
@Transform(toNumber)
@IsNumber()
@Min(0)
@Min(0.001)
lengthMeters!: number;
@ApiPropertyOptional({ example: 45 })
@ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 })
@IsOptional()
@Transform(toNumber)
@Transform(toOptionalNumber)
@IsInt()
@Min(1)
maxWagonsPerTrain?: number;
@ApiPropertyOptional({ type: [String], example: ['container', 'break-bulk'] })
@ApiPropertyOptional({
description: 'Supported load types, e.g. CONTAINER,BULK',
type: [String],
default: [],
})
@IsOptional()
@Transform(toStringArray)
@IsArray()

View File

@@ -11,48 +11,64 @@ import {
Post,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards';
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
import { WagonTypesService } from './wagon-types.service';
import { WagonType } from './entities/wagon-type.entity';
@ApiTags('Wagon Types')
@ApiTags('wagon-types')
@Controller('wagon-types')
@ApiBearerAuth()
export class WagonTypesController {
constructor(private readonly wagonTypesService: WagonTypesService) {}
@Post()
@ApiOperation({ summary: 'Create a wagon type' })
async create(@Body() dto: CreateWagonTypeDto): Promise<WagonType> {
return this.wagonTypesService.create(dto);
}
@Get()
@ApiOperation({ summary: 'Get wagon types' })
async findAll(@Query() query: Record<string, string | undefined>): Promise<WagonType[]> {
return this.wagonTypesService.findAll(query);
@RuleEngineView('wagon-types')
@ApiOperation({ summary: 'List wagon types' })
findAll(@Query() query: Record<string, string | undefined>) {
return this.wagonTypesService.findAll({
isActive:
query.isActive === 'all'
? undefined
: query.isActive !== undefined
? query.isActive === 'true'
: true,
page: query.page ? parseInt(query.page, 10) : undefined,
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
});
}
@Get(':id')
@RuleEngineView('wagon-types')
@ApiOperation({ summary: 'Get a wagon type by ID' })
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<WagonType> {
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonTypesService.findById(id);
}
@Post()
@RuleEngineManage('wagon-types')
@ApiOperation({ summary: 'Create a wagon type' })
create(@Body() dto: CreateWagonTypeDto) {
return this.wagonTypesService.create(dto);
}
@Patch(':id')
@RuleEngineManage('wagon-types')
@ApiOperation({ summary: 'Update a wagon type' })
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateWagonTypeDto,
): Promise<WagonType> {
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) {
return this.wagonTypesService.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('wagon-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Deactivate a wagon type' })
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
@ApiOperation({ summary: 'Soft-delete a wagon type' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonTypesService.remove(id);
}
}

View File

@@ -13,4 +13,8 @@ export class WagonTypesRepository extends BaseRepository<WagonType> {
) {
super(repository);
}
findByCode(code: string): Promise<WagonType | null> {
return this.repository.findOne({ where: { code } });
}
}

View File

@@ -6,44 +6,47 @@ import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
import { WagonType } from './entities/wagon-type.entity';
import { WagonTypesRepository } from './wagon-types.repository';
type WagonTypeListFilter = {
isActive?: boolean;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
};
@Injectable()
export class WagonTypesService {
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
async create(dto: CreateWagonTypeDto): Promise<WagonType> {
const code = dto.code.trim().toUpperCase();
const existing = await this.wagonTypesRepository.findAll({ where: { code } });
if (existing.length > 0) {
throw new ConflictException(`Wagon type code "${code}" already exists`);
}
return this.wagonTypesRepository.create({
...dto,
code,
name: dto.name.trim(),
supportedLoadTypes: dto.supportedLoadTypes ?? [],
isActive: dto.isActive ?? true,
});
}
async findAll(query: Record<string, string | undefined> = {}): Promise<WagonType[]> {
const isActive =
query.isActive === 'all'
? undefined
: query.isActive === undefined
? true
: query.isActive === 'true';
async findAll(filter: WagonTypeListFilter = {}): Promise<{
data: WagonType[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 500;
const sortBy = ['code', 'name', 'capacityTons', 'lengthMeters', 'isActive'].includes(
query.sortBy ?? '',
filter.sortBy ?? '',
)
? (query.sortBy as keyof WagonType)
? (filter.sortBy as keyof WagonType)
: 'code';
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
return this.wagonTypesRepository.findAll({
where: isActive === undefined ? {} : { isActive },
const [data, total] = await this.wagonTypesRepository.findAndCount({
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
order: { [sortBy]: sortOrder } as FindOptionsOrder<WagonType>,
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data,
meta: {
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
};
}
async findById(id: string): Promise<WagonType> {
@@ -57,22 +60,39 @@ export class WagonTypesService {
}
async findByCode(code: string): Promise<WagonType> {
const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } });
const wagonType = await this.wagonTypesRepository.findByCode(code);
if (!wagonType) {
throw new NotFoundException(`Wagon type ${code} not found`);
}
return wagonType;
}
async create(dto: CreateWagonTypeDto): Promise<WagonType> {
const code = dto.code.trim().toUpperCase();
const existing = await this.wagonTypesRepository.findByCode(code);
if (existing) {
throw new ConflictException(`Wagon type code "${code}" already exists`);
}
return this.wagonTypesRepository.create({
code,
name: dto.name.trim(),
capacityTons: dto.capacityTons,
lengthMeters: dto.lengthMeters,
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null,
supportedLoadTypes: dto.supportedLoadTypes ?? [],
isActive: dto.isActive ?? true,
});
}
async update(id: string, dto: UpdateWagonTypeDto): Promise<WagonType> {
const wagonType = await this.findById(id);
const nextCode = dto.code?.trim().toUpperCase();
if (nextCode && nextCode !== wagonType.code) {
const existing = await this.wagonTypesRepository.findAll({ where: { code: nextCode } });
if (existing.length > 0) {
const existing = await this.wagonTypesRepository.findByCode(nextCode);
if (existing) {
throw new ConflictException(`Wagon type code "${nextCode}" already exists`);
}
}
@@ -81,6 +101,9 @@ export class WagonTypesService {
...dto,
...(nextCode ? { code: nextCode } : {}),
...(dto.name ? { name: dto.name.trim() } : {}),
maxWagonsPerTrain:
dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null,
supportedLoadTypes: dto.supportedLoadTypes ?? undefined,
});
if (!updated) {
@@ -92,6 +115,6 @@ export class WagonTypesService {
async remove(id: string): Promise<void> {
await this.findById(id);
await this.wagonTypesRepository.update(id, { isActive: false });
await this.wagonTypesRepository.softDelete(id);
}
}