mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Initial commit of edr-passenger-api alpha version
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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()}` }; }
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
22
apps/edr-passenger-api/src/modules/payments/payments.dto.ts
Normal file
22
apps/edr-passenger-api/src/modules/payments/payments.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user