mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Resolve merge conflicts from Train-Scheduling
This commit is contained in:
@@ -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'] },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateLocomotiveDto } from './create-locomotive.dto';
|
||||
|
||||
export class UpdateLocomotiveDto extends PartialType(CreateLocomotiveDto) {}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class InitiateBookingPayment {
|
||||
@IsString()
|
||||
bookingId!: string;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
`);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
62
apps/edr-freight-api/src/modules/payment/payments.dto.ts
Normal file
62
apps/edr-freight-api/src/modules/payment/payments.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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') ?? '';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateRouteDto } from './create-route.dto';
|
||||
|
||||
export class UpdateRouteDto extends PartialType(CreateRouteDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
44
apps/edr-freight-api/src/modules/routes/routes.controller.ts
Normal file
44
apps/edr-freight-api/src/modules/routes/routes.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
18
apps/edr-freight-api/src/modules/routes/routes.module.ts
Normal file
18
apps/edr-freight-api/src/modules/routes/routes.module.ts
Normal 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 {}
|
||||
13
apps/edr-freight-api/src/modules/routes/routes.repository.ts
Normal file
13
apps/edr-freight-api/src/modules/routes/routes.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
171
apps/edr-freight-api/src/modules/routes/routes.service.ts
Normal file
171
apps/edr-freight-api/src/modules/routes/routes.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,8 @@ export class WagonTypesRepository extends BaseRepository<WagonType> {
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByCode(code: string): Promise<WagonType | null> {
|
||||
return this.repository.findOne({ where: { code } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user