Initial commit of edr-passenger-api alpha version

This commit is contained in:
Stephanos A
2026-05-13 16:58:49 +03:00
parent 199a3eba11
commit 39ba561d8f
113 changed files with 3602 additions and 1035 deletions

View File

@@ -1,37 +0,0 @@
import { BaseEntity } from "@edr/api-common";
import { Passenger } from "@edr/types";
import { Column, Entity } from "typeorm";
@Entity({ name: "payments" })
export class Payment extends BaseEntity {
@Column({ name: "ticket_id", type: "uuid" })
ticketId!: string;
@Column({ name: "amount", type: "numeric", precision: 10, scale: 2 })
amount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@Column({
name: "status",
type: "enum",
enum: Passenger.PaymentStatus,
default: Passenger.PaymentStatus.Pending,
})
status!: Passenger.PaymentStatus;
@Column({ name: "provider", type: "varchar", length: 64 })
provider!: string;
@Column({
name: "provider_transaction_id",
type: "varchar",
length: 256,
nullable: true,
})
providerTransactionId?: string | null;
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
paidAt?: Date | null;
}

View File

@@ -0,0 +1,10 @@
export interface GatewayResult { success: boolean; providerRef: string; clientAction?: { type: string; url?: string }; }
export async function telebirrAdapter(_a: number, ref: string): Promise<GatewayResult> {
await new Promise((r) => setTimeout(r, 200));
return { success: true, providerRef: `TB-${ref}-${Date.now()}`, clientAction: { type: 'REDIRECT', url: `https://telebirr.sandbox.com/pay/${ref}` } };
}
export async function cbeBirrAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; }
export async function eBirrAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; }
export async function cardAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: !ref.startsWith('FAIL'), providerRef: `CARD-${ref}-${Date.now()}` }; }
export async function walletAdapter(amount: number, balance: number): Promise<GatewayResult> { return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; }

View File

@@ -1,17 +1,17 @@
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PaymentsService } from './payments.service';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { PaymentsService } from "./payments.service";
@ApiTags("payments")
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller("payments")
@ApiTags('Payment')
@Controller('payments')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@Get("ticket/:ticketId")
@ApiOperation({ summary: "List payments for a ticket" })
findByTicket(@Param("ticketId", ParseUUIDPipe) ticketId: string) {
return this.paymentsService.findByTicket(ticketId);
}
constructor(private service: PaymentsService) {}
@Post('initiate') @ApiOperation({ summary: 'Initiate payment for a booking' }) initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); }
@Post('refund') @ApiOperation({ summary: 'Refund a confirmed booking' }) refund(@Body() dto: RefundDto) { return this.service.refund(dto); }
@Post('methods') @ApiOperation({ summary: 'Add a payment method' }) addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); }
@Get('methods/:userId') @ApiOperation({ summary: 'Get payment methods for user' }) getMethods(@Param('userId') userId: string) { return this.service.getPaymentMethods(userId); }
}

View File

@@ -0,0 +1,22 @@
import { IsString, IsEnum, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum PaymentMethodTypeEnum { TELEBIRR = 'TELEBIRR', CBE_BIRR = 'CBE_BIRR', EBIRR = 'EBIRR', CARD = 'CARD', WALLET = 'WALLET' }
export class InitiatePaymentDto {
@ApiProperty() @IsString() bookingId: string;
@ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
@ApiPropertyOptional() @IsOptional() @IsString() paymentMethodId?: string;
}
export class RefundDto {
@ApiProperty() @IsString() bookingId: string;
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
}
export class AddPaymentMethodDto {
@ApiProperty() @IsString() userId: string;
@ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) type: PaymentMethodTypeEnum;
@ApiProperty() @IsString() displayName: string;
@ApiPropertyOptional() @IsOptional() @IsString() maskedHint?: string;
}

View File

@@ -1,14 +1,8 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { SeatsModule } from '../seats/seats.module';
import { TicketsModule } from '../tickets/tickets.module';
import { Payment } from "./entities/payment.entity";
import { PaymentsController } from "./payments.controller";
import { PaymentsService } from "./payments.service";
@Module({
imports: [TypeOrmModule.forFeature([Payment])],
controllers: [PaymentsController],
providers: [PaymentsService],
exports: [PaymentsService],
})
@Module({ imports: [SeatsModule, TicketsModule], controllers: [PaymentsController], providers: [PaymentsService] })
export class PaymentsModule {}

View File

@@ -1,21 +1,81 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Payment } from "./entities/payment.entity";
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto';
import { telebirrAdapter, cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters';
@Injectable()
export class PaymentsService {
constructor(
@InjectRepository(Payment)
private readonly paymentsRepository: Repository<Payment>,
private prisma: PrismaService,
private seatsService: SeatsService,
private ticketsService: TicketsService,
private eventEmitter: EventEmitter2,
) {}
/** List payments associated with a ticket. */
findByTicket(ticketId: string): Promise<Payment[]> {
return this.paymentsRepository.find({
where: { ticketId },
order: { createdAt: "DESC" },
async initiatePayment(dto: InitiatePaymentDto) {
const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status !== 'PENDING_PAYMENT') throw new BadRequestException('Booking not payable');
let result;
if (dto.method === 'WALLET') {
result = await this.prisma.$transaction(async (tx) => {
const wallet = await tx.walletAccount.findUnique({ where: { passengerId: booking.passengerId } });
if (!wallet || wallet.balanceMinor < booking.totalMinor) return { success: false, providerRef: '' };
const newBalance = wallet.balanceMinor - booking.totalMinor;
await tx.walletAccount.update({ where: { passengerId: booking.passengerId }, data: { balanceMinor: newBalance } });
await tx.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'DEBIT', amountMinor: booking.totalMinor, balanceAfterMinor: newBalance, description: `Train Ticket - ${booking.bookingRef}`, relatedBookingId: booking.id } });
return { success: true, providerRef: `WALLET-${Date.now()}` };
});
} else {
const adapters = { TELEBIRR: telebirrAdapter, CBE_BIRR: cbeBirrAdapter, EBIRR: eBirrAdapter, CARD: cardAdapter } as any;
result = await adapters[dto.method](booking.totalMinor, booking.bookingRef);
}
const status = result.success ? 'SUCCEEDED' : 'FAILED';
const intent = await this.prisma.paymentIntent.upsert({
where: { bookingId: dto.bookingId },
update: { status, providerRef: result.providerRef, clientAction: result.clientAction as any },
create: { bookingId: dto.bookingId, amountMinor: booking.totalMinor, method: dto.method as any, status: status as any, providerRef: result.providerRef, clientAction: result.clientAction as any },
});
if (result.success) {
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CONFIRMED' } });
await this.ticketsService.generate(dto.bookingId);
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
this.eventEmitter.emit('payment.succeeded', { booking });
}
return { id: intent.id, status: result.success ? 'SUCCESS' : 'FAILED', success: result.success };
}
async refund(dto: RefundDto) {
const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId } });
if (!intent || intent.status !== 'SUCCEEDED') throw new BadRequestException('No successful payment to refund');
await this.prisma.paymentIntent.update({ where: { bookingId: dto.bookingId }, data: { status: 'CANCELLED' } });
const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } });
if (booking) {
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CANCELLED' } });
}
return { refunded: true, bookingRef: booking?.bookingRef };
}
addPaymentMethod(dto: AddPaymentMethodDto) { return this.prisma.paymentMethod.create({ data: dto }); }
getPaymentMethods(userId: string) { return this.prisma.paymentMethod.findMany({ where: { userId }, orderBy: { isDefault: 'desc' } }); }
private async awardLoyaltyPoints(passengerId: string, amountMinor: number, bookingId: string) {
const points = Math.floor(amountMinor / 100);
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } });
if (!account) return;
const newBalance = account.pointsBalance + points;
const tier = newBalance >= 10000 ? 'PLATINUM' : newBalance >= 5000 ? 'GOLD' : newBalance >= 2000 ? 'SILVER' : 'BRONZE';
await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } });
await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } });
}
}