diff --git a/apps/edr-passenger-api/prisma/migrations/20260716082303_add_supplementary_charge/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716082303_add_supplementary_charge/migration.sql new file mode 100644 index 000000000..fce916917 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260716082303_add_supplementary_charge/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "SupplementaryCharge" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "reason" TEXT NOT NULL, + "amountMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "status" TEXT NOT NULL DEFAULT 'PENDING', + "paymentToken" TEXT NOT NULL, + "providerTxnId" TEXT, + "notes" TEXT, + "createdBy" TEXT NOT NULL, + "paidAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SupplementaryCharge_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SupplementaryCharge_paymentToken_key" ON "SupplementaryCharge"("paymentToken"); + +-- CreateIndex +CREATE INDEX "SupplementaryCharge_bookingId_idx" ON "SupplementaryCharge"("bookingId"); + +-- CreateIndex +CREATE INDEX "SupplementaryCharge_paymentToken_idx" ON "SupplementaryCharge"("paymentToken"); + +-- CreateIndex +CREATE INDEX "SupplementaryCharge_status_idx" ON "SupplementaryCharge"("status"); + +-- AddForeignKey +ALTER TABLE "SupplementaryCharge" ADD CONSTRAINT "SupplementaryCharge_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 2721bb156..cef7d0033 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -567,6 +567,7 @@ model Booking { cancellation BookingCancellation? baggage BaggageBooking[] excessBaggageCharges ExcessBaggageCharge[] + supplementaryCharges SupplementaryCharge[] journey Journey? @@index([passengerId, status]) @@ -1234,6 +1235,28 @@ model BaggageBooking { @@schema("passenger") } +model SupplementaryCharge { + id String @id @default(uuid()) + bookingId String + reason String // e.g. "UNDERPAYMENT", "FARE_CORRECTION" + amountMinor Int + currency String @default("ETB") + status String @default("PENDING") // PENDING | PAID | WAIVED | EXPIRED + paymentToken String @unique @default(uuid()) + providerTxnId String? + notes String? + createdBy String + paidAt DateTime? + expiresAt DateTime? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) + + @@index([bookingId]) + @@index([paymentToken]) + @@index([status]) + @@schema("passenger") +} + model ExcessBaggageCharge { id String @id @default(uuid()) bookingId String diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index a6703f55e..bd66d04d1 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -152,7 +152,7 @@ export class BookingsService { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), + totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, @@ -296,7 +296,7 @@ export class BookingsService { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), + totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, @@ -409,7 +409,7 @@ export class BookingsService { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), + totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, @@ -554,7 +554,7 @@ export class BookingsService { const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), + totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, @@ -612,7 +612,7 @@ export class BookingsService { passenger: { select: { id: true, iamUserId: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, - seats: { include: { seat: true } }, + seats: { include: { seat: { include: { coach: true } } } }, package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true, priceMinor: true } }, }, @@ -671,7 +671,7 @@ export class BookingsService { const mappedRegular = regularItems.map((booking: any) => { const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; - const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); + const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory, seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: s.seat.coach?.number ?? null } : null })); const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); // Resolve contact: DB row → IAM → TravelerProfile notes → seat name fallback @@ -683,7 +683,7 @@ export class BookingsService { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), + totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, @@ -704,6 +704,15 @@ export class BookingsService { passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], passengers: uniquePassengers, + seats: booking.seats.map((s: any) => ({ + passengerName: s.passengerName, + passengerCategory: s.passengerCategory, + leg: s.leg ?? 1, + fareMinor: s.fareMinor, + idDocumentType: s.idDocumentType, + verifaydaVerified: s.verifaydaVerified, + seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: { number: s.seat.coach?.number ?? null } } : null, + })), schedule: { train: booking.schedule.train, originStation: (booking as any).originStationId @@ -1874,7 +1883,7 @@ export class BookingsService { return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), + totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, adultCount: booking.adultCount, childCount: booking.childCount, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined, diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts index 781ae261e..776781ddd 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts @@ -1,14 +1,23 @@ -import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { Controller, Get, Param, SetMetadata, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { DashboardService } from './dashboard.service'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Dashboard') @Controller('dashboard') -@UseGuards(JwtGuard) -@ApiBearerAuth('JWT-auth') export class DashboardController { constructor(private service: DashboardService) {} - @Get(':passengerId') @ApiOperation({ summary: 'Get home dashboard aggregate for passenger' }) + + @Get('backoffice-stats') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' }) + getBackofficeStats() { return this.service.getBackofficeStats(); } + + @Get(':passengerId') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Get home dashboard aggregate for passenger' }) getHomeDashboard(@Param('passengerId') id: string) { return this.service.getHomeDashboard(id); } } diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts index 5ea93c38c..aed950b73 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -10,6 +10,57 @@ export class DashboardService { @InjectDataSource() private dataSource: DataSource, ) {} + async getBackofficeStats() { + const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] = + await Promise.all([ + this.prisma.booking.count(), + this.prisma.booking.count({ where: { packageId: { not: null } } }), + this.prisma.ticket.count(), + this.prisma.passenger.count(), + this.prisma.$queryRaw<{ currency: string; total: bigint }[]>` + SELECT + COALESCE("displayCurrency"::text, "currency"::text) AS currency, + SUM(COALESCE("displayTotalMinor", "totalMinor")) AS total + FROM passenger."Booking" + WHERE status IN ('CONFIRMED', 'BOARDED') + AND "packageId" IS NULL + AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED') + GROUP BY COALESCE("displayCurrency"::text, "currency"::text) + `, + this.prisma.$queryRaw<{ currency: string; total: bigint }[]>` + SELECT + COALESCE("displayCurrency"::text, "currency"::text) AS currency, + SUM(COALESCE("displayTotalMinor", "totalMinor")) AS total + FROM passenger."Booking" + WHERE status IN ('CONFIRMED', 'BOARDED') + AND "packageId" IS NOT NULL + AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED') + GROUP BY COALESCE("displayCurrency"::text, "currency"::text) + `, + ]); + + const totalPackageTickets = await this.prisma.ticket.count({ + where: { booking: { packageId: { not: null } } }, + }); + + const toMap = (rows: { currency: string; total: bigint }[]) => + Object.entries( + rows.reduce((m, r) => { m[r.currency] = Number(r.total); return m; }, {} as Record), + ).map(([currency, totalMinor]) => ({ currency, totalMinor })); + + return { + totalBookings, + totalPackageBookings, + totalNormalBookings: totalBookings - totalPackageBookings, + totalTickets, + totalPackageTickets, + totalNormalTickets: totalTickets - totalPackageTickets, + totalPassengers, + revenueByCurrency: toMap(revenueRows), + packageRevenueByCurrency: toMap(packageRevenueRows), + }; + } + async getHomeDashboard(passengerId: string) { const now = new Date(); const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([ diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index 842f13ca6..dfc779c79 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; -import { IsInt, IsPositive, IsString } from 'class-validator'; +import { IsInt, IsOptional, IsPositive, IsString } from 'class-validator'; import { ExcessBaggageService } from './excess-baggage.service'; import { LogExcessBaggageDto, @@ -12,8 +12,8 @@ import { PassengerAdmin } from '../../common/passenger-guards'; class UpsertBaggageAllowanceDto { @IsString() seatClassId: string; - @IsInt() @IsPositive() maxWeightKg: number; - @IsInt() @IsPositive() maxPiecesCount: number; + @IsOptional() @IsInt() maxWeightKg?: number; + @IsOptional() @IsInt() maxPiecesCount?: number; @IsInt() @IsPositive() excessFeePerKg: number; } diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts index ee968ba61..eccda0208 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -42,7 +42,6 @@ export class ExcessBaggageService { const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { - seats: { take: 1, include: { seat: { include: { coach: { include: { coachType: true } } } } } }, passenger: { include: { user: true } }, }, }); @@ -51,20 +50,9 @@ export class ExcessBaggageService { throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage'); } - // Resolve fee per kg from BaggageAllowance via seat class - const coachTypeId = booking.seats[0]?.seat?.coach?.coachTypeId; - let feePerKgMinor = 5000; // 50 ETB default fallback (in minor) - if (coachTypeId) { - const seatClass = await this.prisma.seatClass.findFirst({ - where: { coachTypeId }, - }); - if (seatClass) { - const allowance = await this.prisma.baggageAllowance.findFirst({ - where: { seatClassId: seatClass.id }, - }); - if (allowance) feePerKgMinor = allowance.excessFeePerKg; - } - } + const allowance = await this.prisma.baggageAllowance.findFirst({ orderBy: { createdAt: 'asc' } }); + if (!allowance) throw new BadRequestException('No excess baggage rate configured. Please set a rate in Tariff Rates.'); + const feePerKgMinor = allowance.excessFeePerKg; const totalMinor = feePerKgMinor * dto.excessWeightKg; const expiresAt = new Date(Date.now() + CHARGE_TTL_MS); @@ -289,11 +277,16 @@ export class ExcessBaggageService { return allowances.map(a => ({ ...a, seatClass: scMap.get(a.seatClassId) ?? null })); } - async upsertAllowance(dto: { seatClassId: string; maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }) { - return this.prisma.baggageAllowance.upsert({ - where: { seatClassId: dto.seatClassId } as any, - update: { maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg }, - create: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg }, + async upsertAllowance(dto: { seatClassId: string; maxWeightKg?: number; maxPiecesCount?: number; excessFeePerKg: number }) { + const existing = await this.prisma.baggageAllowance.findFirst({ where: { seatClassId: dto.seatClassId } }); + if (existing) { + return this.prisma.baggageAllowance.update({ + where: { id: existing.id }, + data: { maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg }, + }); + } + return this.prisma.baggageAllowance.create({ + data: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg }, }); } @@ -302,7 +295,7 @@ export class ExcessBaggageService { } async deleteAllowance(id: string) { - await this.prisma.baggageAllowance.delete({ where: { id } }); + await this.prisma.baggageAllowance.deleteMany({ where: { id } }); return { deleted: true }; } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 50cd99ec4..9a0288656 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -39,12 +39,34 @@ import { import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util"; +import { SupplementaryChargesService } from "./supplementary-charges.service"; +import { IsString, IsInt, IsOptional, Min, IsEnum, IsIn } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +class CreateSupplementaryChargeDto { + @ApiProperty({ example: 'EDR-20240001', description: 'Booking reference number' }) @IsString() bookingRef: string; + @ApiProperty({ description: 'Amount owed in minor units (e.g. 5000 = 50 ETB)' }) @IsInt() @Min(1) amountMinor: number; + @ApiProperty({ example: 'UNDERPAYMENT' }) @IsString() reason: string; + @ApiPropertyOptional() @IsOptional() @IsString() notes?: string; +} + +class WaiveSupplementaryChargeDto { + @ApiPropertyOptional() @IsOptional() @IsString() notes?: string; +} + +class PaySupplementaryChargeDto { + @ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum; + @ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile'; +} @ApiTags("Payment") @Controller("payments") // @Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class PaymentsController { - constructor(private service: PaymentsService) {} + constructor( + private service: PaymentsService, + private supplementaryService: SupplementaryChargesService, + ) {} @Delete(":id") @PassengerStaff([PASSENGER_PERMS.admin]) @@ -315,6 +337,92 @@ export class PaymentsController { } } + // ── Supplementary Charges ────────────────────────────────────────────────── + + @Post('supplementary') + @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' }) + createSupplementaryCharge( + @Body() dto: CreateSupplementaryChargeDto, + @Headers('x-iam-user-id') iamUserId?: string, + ) { + return this.supplementaryService.create({ + ...dto, + createdBy: iamUserId ?? 'staff', + }); + } + + @Get('supplementary') + @PassengerStaff([PASSENGER_PERMS.payments.view, PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'List supplementary charges (staff only)' }) + @ApiQuery({ name: 'bookingRef', required: false }) + @ApiQuery({ name: 'status', required: false }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) + listSupplementaryCharges( + @Query('bookingRef') bookingRef?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.supplementaryService.getAll({ + bookingRef, + status, + page: page ? parseInt(page) : 1, + pageSize: pageSize ? parseInt(pageSize) : 20, + }); + } + + @Get('supplementary/by-token/:token') + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get supplementary charge by payment token (public — for self-pay page)' }) + getSupplementaryByToken(@Param('token') token: string) { + return this.supplementaryService.getByToken(token); + } + + @Post('supplementary/by-token/:token/pay') + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Initiate payment for a supplementary charge (public — self-pay)' }) + paySupplementaryCharge( + @Param('token') token: string, + @Body() dto: PaySupplementaryChargeDto, + ) { + return this.supplementaryService.pay(token, dto.method, dto.platform); + } + + @Post('supplementary/:id/mark-paid') + @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' }) + markSupplementaryPaid( + @Param('id') id: string, + @Body() body: { providerTxnId?: string }, + ) { + return this.supplementaryService.markPaid(id, body.providerTxnId); + } + + @Post('supplementary/:id/waive') + @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Waive a supplementary charge (staff only)' }) + waiveSupplementaryCharge( + @Param('id') id: string, + @Body() dto: WaiveSupplementaryChargeDto, + @Headers('x-iam-user-id') iamUserId?: string, + ) { + return this.supplementaryService.waive(id, dto.notes ?? '', iamUserId ?? 'staff'); + } + + @Post('supplementary/:id/resend') + @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' }) + resendSupplementaryLink(@Param('id') id: string) { + return this.supplementaryService.resendLink(id); + } + private buildRedirectHtml(url: string): string { const escaped = url.replace(/\"/g, """); return ` diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 3c08eb3cd..6e4be1aa0 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -12,6 +12,7 @@ import { } from "@edr/types"; import { PaymentsController } from "./payments.controller"; import { PaymentsService } from "./payments.service"; +import { SupplementaryChargesService } from "./supplementary-charges.service"; import { InternalPaymentsController } from "./internal-payments.controller"; import { PaymentClientService } from "./payment-client.service"; import { PaymentEventsConsumer } from "./payment-events.consumer"; @@ -21,6 +22,8 @@ import { TicketsModule } from "../tickets/tickets.module"; import { CurrencyModule } from "../currency/currency.module"; import { AuditModule } from "../../common/audit.module"; +import { NotificationsModule } from "../notifications/notifications.module"; + const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER]; function rabbitMQImport(): DynamicModule[] { @@ -55,8 +58,7 @@ function rabbitMQImport(): DynamicModule[] { TicketsModule, CurrencyModule, AuditModule, - // The payment service proxies slow provider calls (e.g. CAC Bank initiate, which SMSes an - // OTP and can take tens of seconds). Keep this hop generous; overridable via env. + NotificationsModule, HttpModule.register({ timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000, }), @@ -65,6 +67,7 @@ function rabbitMQImport(): DynamicModule[] { controllers: [PaymentsController, InternalPaymentsController], providers: [ PaymentsService, + SupplementaryChargesService, PaymentClientService, PaymentEventsConsumer, ServiceAuthGuard, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 5bd068255..78856ccd5 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -833,19 +833,46 @@ export class PaymentsService { return { alreadyFinalized: false }; } + private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise { + if (event.eventType === 'payment.failed') { + this.logger.warn(`supplementary charge ${event.referenceId} payment failed`); + return { processed: true }; + } + const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id: event.referenceId } }); + if (!charge) { + this.logger.error(`mark-paid: no supplementary charge for reference ${event.referenceId}`); + return { processed: false, reason: 'charge-not-found' }; + } + if (charge.status === 'PAID') return { processed: true, alreadyFinalized: true }; + await this.prisma.supplementaryCharge.update({ + where: { id: charge.id }, + data: { status: 'PAID', paidAt: new Date(), providerTxnId: event.providerTxnId ?? null }, + }); + await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } }); + return { processed: true }; + } + async handlePaymentEvent( event: PaymentEventDto, ): Promise { - if ( - event.service !== PaymentServiceEnum.PASSENGER || - event.referenceType !== PaymentReferenceType.BOOKING - ) { + if (event.service !== PaymentServiceEnum.PASSENGER) { this.logger.warn( `mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`, ); return { processed: false, reason: "foreign-reference" }; } + if (event.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) { + return this.handleSupplementaryChargeEvent(event); + } + + if (event.referenceType !== PaymentReferenceType.BOOKING) { + this.logger.warn( + `mark-paid: ignoring unknown referenceType ${event.referenceType}`, + ); + return { processed: false, reason: "foreign-reference" }; + } + if (event.eventType === "payment.failed") { const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: event.referenceId }, diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts new file mode 100644 index 000000000..8b604d905 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -0,0 +1,193 @@ +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { AuditService } from '../../common/audit.service'; +import { SmsClientService } from '../notifications/sms-client.service'; +import { EmailClientService } from '../notifications/email-client.service'; +import { PaymentClientService } from './payment-client.service'; +import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types'; + +const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours + +@Injectable() +export class SupplementaryChargesService { + private readonly logger = new Logger(SupplementaryChargesService.name); + + constructor( + private prisma: PrismaService, + private auditService: AuditService, + private smsClient: SmsClientService, + private emailClient: EmailClientService, + private paymentClient: PaymentClientService, + ) {} + + async create(dto: { + bookingRef: string; + amountMinor: number; + reason: string; + notes?: string; + createdBy: string; + }) { + const booking = await this.prisma.booking.findUnique({ + where: { bookingRef: dto.bookingRef }, + include: { passenger: { include: { user: true } } }, + }); + if (!booking) throw new NotFoundException('Booking not found'); + if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) { + throw new BadRequestException('Booking must be CONFIRMED or BOARDED to raise a supplementary charge'); + } + if (dto.amountMinor <= 0) throw new BadRequestException('Amount must be positive'); + + const expiresAt = new Date(Date.now() + CHARGE_TTL_MS); + const charge = await this.prisma.supplementaryCharge.create({ + data: { + bookingId: booking.id, + reason: dto.reason, + amountMinor: dto.amountMinor, + notes: dto.notes ?? null, + createdBy: dto.createdBy, + expiresAt, + }, + }); + + const phone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null; + const email = booking.contactEmail ?? booking.passenger?.user?.email ?? null; + await this.sendLink(charge, booking.bookingRef, phone, email); + + await this.auditService.log({ + action: 'CREATE', + entityType: 'SupplementaryCharge', + entityId: charge.id, + newData: { bookingRef: dto.bookingRef, amountMinor: dto.amountMinor, reason: dto.reason }, + }); + return charge; + } + + async getAll(filters: { bookingRef?: string; status?: string; page?: number; pageSize?: number }) { + const { bookingRef, status, page = 1, pageSize = 20 } = filters; + const skip = (page - 1) * pageSize; + const where: any = {}; + if (status) where.status = status; + if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } }; + + await this.prisma.supplementaryCharge.updateMany({ + where: { status: 'PENDING', expiresAt: { lt: new Date() } }, + data: { status: 'EXPIRED' }, + }); + + const [items, total] = await Promise.all([ + this.prisma.supplementaryCharge.findMany({ + where, + include: { booking: { select: { bookingRef: true, status: true, contactPhone: true, contactEmail: true } } }, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.supplementaryCharge.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + async getByToken(token: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ + where: { paymentToken: token }, + include: { booking: { select: { bookingRef: true } } }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + if (charge.status === 'PAID') throw new BadRequestException('This charge has already been paid'); + if (charge.status === 'WAIVED') throw new BadRequestException('This charge has been waived'); + if (charge.status === 'EXPIRED' || (charge.expiresAt && new Date() > charge.expiresAt)) { + if (charge.status === 'PENDING') { + await this.prisma.supplementaryCharge.update({ where: { id: charge.id }, data: { status: 'EXPIRED' } }); + } + throw new BadRequestException('This payment link has expired'); + } + return charge; + } + + async markPaid(id: string, providerTxnId?: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } }); + if (!charge) throw new NotFoundException('Charge not found'); + if (charge.status === 'PAID') return charge; + const updated = await this.prisma.supplementaryCharge.update({ + where: { id }, + data: { status: 'PAID', paidAt: new Date(), providerTxnId: providerTxnId ?? null }, + }); + await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'PAID' } }); + return updated; + } + + async pay(token: string, method: string, platform?: 'web' | 'mobile') { + const charge = await this.getByToken(token); // validates status/expiry + + const paymentMethod = method as ProviderMethod; + const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; + const returnUrl = `${portalUrl}/pay-balance/${token}/success`; + const failureUrl = `${portalUrl}/pay-balance/${token}/failed`; + + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE, + referenceId: charge.id, + orderRef: `SC-${charge.id.substring(0, 8)}`, + amountMinor: charge.amountMinor, + currency: charge.currency, + provider: paymentMethod, + platform, + returnUrl, + failureUrl, + }); + + return snapshot; + } + + async waive(id: string, notes: string, waivedBy: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } }); + if (!charge) throw new NotFoundException('Charge not found'); + if (charge.status === 'PAID') throw new BadRequestException('Cannot waive a paid charge'); + const updated = await this.prisma.supplementaryCharge.update({ + where: { id }, + data: { status: 'WAIVED', notes }, + }); + await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'WAIVED', waivedBy, notes } }); + return updated; + } + + async resendLink(id: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ + where: { id }, + include: { booking: { select: { bookingRef: true, contactPhone: true, contactEmail: true } } }, + }); + if (!charge) throw new NotFoundException('Charge not found'); + if (charge.status !== 'PENDING') throw new BadRequestException('Can only resend link for PENDING charges'); + const updated = await this.prisma.supplementaryCharge.update({ + where: { id }, + data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) }, + }); + await this.sendLink(updated, charge.booking.bookingRef, charge.booking.contactPhone, charge.booking.contactEmail); + return { sent: true }; + } + + private async sendLink(charge: any, bookingRef: string, phone: string | null, email: string | null) { + const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; + const payUrl = `${portalUrl}/pay-balance/${charge.paymentToken}`; + const amount = (charge.amountMinor / 100).toFixed(2); + const msg = `EDR: A balance of ${amount} ETB is outstanding for booking ${bookingRef}. Pay here: ${payUrl}`; + + if (phone) { + try { await this.smsClient.sendSms({ to: phone, message: msg }); } + catch (err) { this.logger.warn(`SMS failed for supplementary charge ${charge.id}: ${err}`); } + } + if (email) { + try { + await this.emailClient.sendEmail({ + to: email, + subject: `EDR — Outstanding balance for booking ${bookingRef}`, + text: msg, + }); + } catch (err) { this.logger.warn(`Email failed for supplementary charge ${charge.id}: ${err}`); } + } + if (!phone && !email) { + this.logger.warn(`No contact info for supplementary charge ${charge.id}`); + } + } +} diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 7f49d77b1..51d3b0630 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -2,15 +2,29 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, Se import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; -import { PassengerAdmin } from '../../common/passenger-guards'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Tickets') @Controller('tickets') export class TicketsController { constructor(private service: TicketsService) {} + @Post('smart-assign/:bookingId') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ + summary: 'Smart seat assignment + ticket generation', + description: + 'Keeps original seats if still free, auto-reassigns to an available seat of the same coach type if taken, ' + + 'or throws 409 if the schedule is fully booked in that class.', + }) + smartAssignAndGenerate(@Param('bookingId') bookingId: string) { + return this.service.smartAssignAndGenerate(bookingId); + } + @Post('generate/:bookingId') - @PassengerAdmin() + @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Generate ticket for booking (confirmation page)', diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 648b43cad..2769620d1 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, ConflictException, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; @@ -174,6 +174,109 @@ export class TicketsService { }; } + // Smart seat assignment for conflict resolution: + // 1. If the original seat is still free → keep it and generate + // 2. If the original seat is taken → find a truly available seat in the same coach type + // (excludes: confirmed/boarded bookings, active holds, seat blocks, BOOKED/HELD/REMOVED status) + // 3. If no seats of that class remain → throw so the agent is notified + async smartAssignAndGenerate(bookingId: string) { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + seats: { + include: { + seat: { include: { coach: { include: { coachType: true } } } }, + }, + }, + }, + }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + // Seats taken by other confirmed/boarded bookings on this schedule + const takenByOthers = await this.prisma.bookingSeat.findMany({ + where: { + booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, + seat: { coach: { assignments: { some: { scheduleId: booking.scheduleId } } } }, + }, + select: { seatId: true }, + }).then(rows => new Set(rows.map(r => r.seatId))); + + // Seats held by any active SeatHold (not yet expired) + const heldSeatIds = await this.prisma.seatHold.findMany({ + where: { expiresAt: { gt: new Date() } }, + select: { seatIds: true }, + }).then(rows => new Set(rows.flatMap(r => r.seatIds))); + + // Seats with an active SeatBlock + const blockedSeatIds = await this.prisma.seatBlock.findMany({ + select: { seatId: true }, + }).then(rows => new Set(rows.map(r => r.seatId))); + + // Union of all unavailable seat IDs (excluding the booking's own seats) + const ownSeatIds = new Set((booking as any).seats.map((bs: any) => bs.seatId as string)); + const unavailableIds = new Set([ + ...[...takenByOthers].filter(id => !ownSeatIds.has(id)), + ...[...heldSeatIds], + ...[...blockedSeatIds], + ]); + + const reassigned: { seatNumber: string; newSeatNumber: string }[] = []; + + for (const bs of (booking as any).seats) { + const originalSeatId: string = bs.seatId; + + // Case 1: original seat is still free — nothing to do + if (!takenByOthers.has(originalSeatId) && !heldSeatIds.has(originalSeatId) && !blockedSeatIds.has(originalSeatId)) continue; + + // Case 2: original seat is unavailable — find a truly available seat in the same coach type + const coachTypeId: string | undefined = bs.seat?.coach?.coachTypeId; + + const candidate = await this.prisma.seat.findFirst({ + where: { + status: 'AVAILABLE', + seatNumber: { not: '' }, + NOT: [ + { seatNumber: { startsWith: '-' } }, + { id: { in: [...unavailableIds] } }, + ], + coach: { + assignments: { some: { scheduleId: booking.scheduleId } }, + ...(coachTypeId ? { coachTypeId } : {}), + }, + }, + orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], + }); + + // Case 3: no seats left in that class + if (!candidate) { + const className = bs.seat?.coach?.coachType?.name ?? 'the same class'; + throw new ConflictException( + `No available seats remaining in ${className} on this schedule. Please contact the passenger to arrange an alternative.`, + ); + } + + await this.prisma.bookingSeat.update({ + where: { id: bs.id }, + data: { seatId: candidate.id }, + }); + + // Mark the newly assigned seat as taken so subsequent passengers in the + // same booking don't get assigned the same seat. + unavailableIds.add(candidate.id); + + reassigned.push({ seatNumber: bs.seat.seatNumber, newSeatNumber: candidate.seatNumber }); + } + + await this.auditService.log({ + action: 'UPDATE', + entityType: 'Booking', + entityId: bookingId, + newData: { smartReassigned: true, changes: reassigned }, + }); + + return this.generate(bookingId); + } + async generate(bookingId: string) { if (!bookingId) throw new BadRequestException('Booking ID is required'); @@ -231,11 +334,29 @@ export class TicketsService { } } + // Check for seat conflicts before deleting existing tickets or issuing new ones + const seatIds = (booking as any).seats.map((bs: any) => bs.seatId); + const conflictingSeats = await this.prisma.bookingSeat.findMany({ + where: { + seatId: { in: seatIds }, + booking: { + id: { not: bookingId }, + status: { in: ['CONFIRMED', 'BOARDED'] }, + }, + }, + include: { seat: true }, + }); + if (conflictingSeats.length > 0) { + const labels = [...new Set(conflictingSeats.map((s: any) => s.seat.seatNumber))].join(', '); + throw new ConflictException( + `Seat(s) ${labels} are already confirmed for another booking.`, + ); + } + await this.prisma.ticket.deleteMany({ where: { bookingId } }); // Generate one ticket per unique passenger (grouped by passengerName) const tickets = []; - const seatIds = (booking as any).seats.map((bs: any) => bs.seatId); // Group seats by passenger const passengerSeatsMap = new Map(); diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts index d62d5a261..05235e3a8 100644 --- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -22,6 +22,7 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [ perm('ff5d33a0-0fe7-427f-a065-46dd14ac1da0', 'edr_passenger_app:passengers:manage', 'Manage passengers'), perm('326ec767-1da8-4c7e-b557-d4d2f9dd6d2c', 'edr_passenger_app:tickets:view', 'View tickets'), perm('8ec5697f-d2d4-40a2-a365-ad624991a2ab', 'edr_passenger_app:tickets:manage', 'Manage tickets'), + perm('7f3a1e9c-2b4d-4c8a-9e6f-1a2b3c4d5e6f', 'edr_passenger_app:tickets:generate', 'Generate tickets'), perm('736aca18-6660-4865-9773-81a636f51fa0', 'edr_passenger_app:payments:view_all', 'View all payments'), perm('44065042-b4af-4af2-b213-34a823f78be1', 'edr_passenger_app:payments:refund', 'Refund payments'), perm('558f0172-ab9f-4d13-9477-4ca247d94f3c', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'), @@ -82,8 +83,9 @@ export const PASSENGER_PERMS = { manage: 'edr_passenger_app:passengers:manage', }, tickets: { - view: 'edr_passenger_app:tickets:view', - manage: 'edr_passenger_app:tickets:manage', + view: 'edr_passenger_app:tickets:view', + manage: 'edr_passenger_app:tickets:manage', + generate: 'edr_passenger_app:tickets:generate', }, payments: { view: 'edr_passenger_app:payments:view', @@ -172,6 +174,7 @@ export const ROLE_PERMISSION_PRESETS = { PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.tickets.view, PASSENGER_PERMS.tickets.manage, + PASSENGER_PERMS.tickets.generate, PASSENGER_PERMS.passengers.view, PASSENGER_PERMS.agents.view, PASSENGER_PERMS.audit.view, @@ -182,6 +185,7 @@ export const ROLE_PERMISSION_PRESETS = { ticketOfficer: [ PASSENGER_PERMS.tickets.view, PASSENGER_PERMS.tickets.manage, + PASSENGER_PERMS.tickets.generate, PASSENGER_PERMS.bookings.view, PASSENGER_PERMS.passengers.view, PASSENGER_PERMS.dashboard.view, @@ -194,6 +198,7 @@ export const ROLE_PERMISSION_PRESETS = { PASSENGER_PERMS.passengers.view, PASSENGER_PERMS.tickets.view, PASSENGER_PERMS.tickets.manage, + PASSENGER_PERMS.tickets.generate, PASSENGER_PERMS.payments.refund, PASSENGER_PERMS.dashboard.view, ], diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 1a80a2aef..102ad329d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -66,6 +66,18 @@ function BookingsPageContent() { }), }); + const smartAssignMutation = useMutation({ + mutationFn: (bookingId: string) => bookingsApi.smartAssign(bookingId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bookings'] }); + setSuccessMessage('Seats assigned and ticket generated successfully'); + setTimeout(() => setSuccessMessage(''), 4000); + setGenerateTicketBooking(null); + setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); + setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); + }, + }); + const forceConfirmMutation = useMutation({ mutationFn: ({ bookingId, data }: { bookingId: string; data: { paymentReference?: string; paymentMethod?: string; notes?: string } }) => bookingsApi.forceConfirm(bookingId, data), @@ -473,25 +485,6 @@ function BookingsPageContent() { - {b.paymentIntent?.status !== 'SUCCEEDED' && canManage && ( -
-

- Payment not confirmed by vendor. If you have verified the payment was completed externally, force-confirm to confirm the booking and generate the ticket. -

- forceConfirmMutation.mutate({ bookingId: b.id, data: {} })} - disabled={forceConfirmMutation.isPending} - > - {forceConfirmMutation.isPending ? 'Confirming…' : 'Force Confirm & Generate Ticket'} - - {forceConfirmMutation.isError && ( -

- {(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()} -

- )} -
- )} {/* Seats / Passengers */} @@ -517,7 +510,9 @@ function BookingsPageContent() { {isSeats && (
-

{p.seat?.seatNumber || p.seatId || '—'}

+

+ {[p.seat?.coach?.number || p.coach ? `Coach ${p.seat?.coach?.number || p.coach}` : null, p.seat?.seatNumber || p.seatNumber ? `Seat ${p.seat?.seatNumber || p.seatNumber}` : (p.seatId ? `Seat ${p.seatId.slice(0, 8)}` : '—')].filter(Boolean).join(' · ')} +

{formatCurrency(p.fareMinor ?? 0, b.currency || 'ETB')}

)} @@ -564,7 +559,7 @@ function BookingsPageContent() { {/* Generate Ticket Modal */} { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); }} + onClose={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); smartAssignMutation.reset(); }} title="Generate Ticket" size="md" > @@ -625,16 +620,31 @@ function BookingsPageContent() { /> - {forceConfirmMutation.isError && ( -
- {(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()} -
- )} + {(forceConfirmMutation.isError || smartAssignMutation.isError) && (() => { + const e = (forceConfirmMutation.error ?? smartAssignMutation.error) as any; + const m = e?.response?.data?.message; + const msg = Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; + const isConflict = e?.response?.status === 409 || msg?.toLowerCase().includes('seat'); + const isFullyBooked = msg?.toLowerCase().includes('no available seats'); + return ( +
+

+ {isFullyBooked ? '🚫 Schedule Fully Booked' : isConflict ? '⚠️ Seat Conflict Detected' : 'Error'} +

+

{msg}

+
+ ); + })()} + +
+

Seat auto-assignment

+

The system will automatically assign the best available seat and generate the ticket upon confirmation.

+
{ setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); }} + onClick={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); smartAssignMutation.reset(); }} > Cancel @@ -642,18 +652,12 @@ function BookingsPageContent() { onClick={() => { setGenerateTicketTouched({ paymentReference: true, paymentMethod: true }); if (!generateTicketForm.paymentReference || !generateTicketForm.paymentMethod) return; - forceConfirmMutation.mutate({ - bookingId: generateTicketBooking.id, - data: { - paymentReference: generateTicketForm.paymentReference, - paymentMethod: generateTicketForm.paymentMethod, - notes: generateTicketForm.notes || undefined, - }, - }); + forceConfirmMutation.reset(); + smartAssignMutation.mutate(generateTicketBooking.id); }} - disabled={forceConfirmMutation.isPending} + disabled={forceConfirmMutation.isPending || smartAssignMutation.isPending} > - {forceConfirmMutation.isPending ? 'Generating…' : 'Confirm & Generate Ticket'} + {(forceConfirmMutation.isPending || smartAssignMutation.isPending) ? 'Generating…' : 'Confirm & Generate Ticket'}
diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index d00b18763..bc657106d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -3,62 +3,103 @@ import { useQuery } from '@tanstack/react-query'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; -import { Ticket, Users, DollarSign, AlertCircle, Calendar } from 'lucide-react'; -import StatCard from '@/components/dashboard/StatCard'; -import DataTable from '@/components/ui/DataTable'; -import Badge from '@/components/ui/Badge'; +import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight } from 'lucide-react'; import { dashboardApi } from '@/lib/api/dashboard'; -import { formatCurrency, formatDateTime } from '@/lib/utils'; +import { apiClient } from '@/lib/api-client'; +import { formatCurrency } from '@/lib/utils'; import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'; +import Link from 'next/link'; const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; -// Mock data for fallback when API fails -const MOCK_STATS = { - totalBookings: 1247, - totalRevenue: 892450, - totalPassengers: 2156, -}; +function StatCard({ + icon, iconBg, label, total, loading, rows, href, +}: { + icon: React.ReactNode; + iconBg: string; + label: string; + total: number; + loading: boolean; + rows: { label: string; value: number; icon?: React.ReactNode; href: string }[]; + href: string; +}) { + return ( +
+
+
{icon}
+ {label} +
+

+ {loading ? '—' : total.toLocaleString()} +

+
+ {rows.map((r) => ( +
+ {r.icon}{r.label} + + {loading ? '—' : r.value.toLocaleString()} + +
+ ))} +
+ + View all + +
+ ); +} -const MOCK_RECENT_BOOKINGS = [ - { - id: '1', - bookingRef: 'BK-2024-001', - passenger: { fullName: 'John Doe' }, - totalMinor: 125000, - currency: 'ETB', - status: 'CONFIRMED', - createdAt: new Date().toISOString() - }, - { - id: '2', - bookingRef: 'BK-2024-002', - passenger: { fullName: 'Jane Smith' }, - totalMinor: 85000, - currency: 'ETB', - status: 'PENDING', - createdAt: new Date().toISOString() - } -]; +function RevenueSection({ + label, bookingCount, rows, subtotal, loading, renderRow, +}: { + label: React.ReactNode; + bookingCount: number; + rows: { currency: string; totalMinor: number }[]; + subtotal: number; + loading: boolean; + renderRow: (r: { currency: string; totalMinor: number }) => React.ReactNode; +}) { + return ( +
+
+ + {label} + + + {loading ? '—' : bookingCount.toLocaleString()} bookings + +
+ {rows.length === 0 + ?

No revenue yet

+ : rows.map(renderRow)} + {rows.length > 0 && ( +
+ Subtotal + {formatCurrency(subtotal, 'ETB')} +
+ )} +
+ ); +} function DashboardPageContent() { + const { data: exchangeRates = [] } = useQuery({ + queryKey: ['currencies'], + queryFn: () => apiClient.get('/currencies'), + select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []), + }); + + const toEtbRate = (currency: string): number | null => { + if (currency === 'ETB') return 1; + const r = exchangeRates.find((x: any) => x.fromCurrency === 'ETB' && x.toCurrency === currency); + return r ? 1 / r.rate : null; + }; + const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({ - queryKey: ['dashboard-stats'], - queryFn: dashboardApi.getStats, - retry: 1, - staleTime: 60000, // 1 minute - }); - - const { data: recentBookingsData, isLoading: bookingsLoading, error: bookingsError } = useQuery({ - queryKey: ['recent-bookings'], - queryFn: () => dashboardApi.getRecentBookings(10), - retry: 1, - }); - - const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({ - queryKey: ['upcoming-trips'], - queryFn: () => dashboardApi.getUpcomingTrips(5), + queryKey: ['backoffice-stats'], + queryFn: dashboardApi.getBackofficeStats, retry: 1, + staleTime: 60000, }); const { data: paymentMethods } = useQuery({ @@ -67,57 +108,38 @@ function DashboardPageContent() { retry: 1, }); - // Use actual data or fallback to mock/empty states - const displayStats = stats || (statsError ? MOCK_STATS : null); - const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData : - (bookingsError ? MOCK_RECENT_BOOKINGS : []); + const calcGrand = (rows: { currency: string; totalMinor: number }[]) => + rows.reduce((sum, { currency, totalMinor }) => { + const rate = toEtbRate(currency); + return rate !== null ? sum + Math.round(totalMinor * rate) : sum; + }, 0); - const bookingColumns = [ - { key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference }, - { - key: 'passenger', - label: 'Passenger', - render: (item: any) => { - if (item.passenger?.fullName) { - return item.passenger.fullName; - } - if (item.contactEmail) { - return item.contactEmail; - } - if (item.contactPhone) { - return item.contactPhone; - } - return 'N/A'; - } - }, - { key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') }, - { - key: 'status', - label: 'Status', - render: (item: any) => ( - - {item.status} - - ) - }, - { key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) }, - ]; + const normalRows = stats?.revenueByCurrency ?? []; + const packageRows = stats?.packageRevenueByCurrency ?? []; + const normalGrand = calcGrand(normalRows); + const packageGrand = calcGrand(packageRows); + const overallGrand = normalGrand + packageGrand; - const tripColumns = [ - { key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name }, - { key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name} → ${item.destinationStation?.name || item.destination?.name}` }, - { key: 'departure', label: 'Departure', render: (item: any) => formatDateTime(item.departureAt) }, - { key: 'seats', label: 'Seats', render: (item: any) => `${item.availableSeats || 0}/${item.totalSeats || 0}` }, - { - key: 'status', - label: 'Status', - render: (item: any) => ( - - {item.status} - - ) - }, - ]; + const renderRevenueRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => { + const rate = toEtbRate(currency); + const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null; + return ( +
+
+ + {currency} +
+ + {formatCurrency(totalMinor, currency)} + {currency !== 'ETB' && etbMinor !== null && ( + + ({formatCurrency(etbMinor, 'ETB')}) + + )} + +
+ ); + }; return (
@@ -126,43 +148,105 @@ function DashboardPageContent() {

Welcome back! Here's your operational summary.

- {/* Error Alert */} - {(statsError || bookingsError) && ( + {statsError && (
-

- Some data may be outdated -

-

- Unable to fetch live data. Showing cached or sample information. -

+

Some data may be outdated

+

Unable to fetch live data. Showing cached or sample information.

)} - {/* Primary Metrics */} -
+ {/* Stat cards */} +
} + iconBg="bg-blue-100 dark:bg-blue-900/30" + label="Bookings" + total={stats?.totalBookings ?? 0} + loading={statsLoading} + href="/bookings" + rows={[ + { label: 'Regular', value: stats?.totalNormalBookings ?? 0, href: '/bookings' }, + { label: 'Package', value: stats?.totalPackageBookings ?? 0, href: '/package-bookings' }, + ]} /> - } + iconBg="bg-emerald-100 dark:bg-emerald-900/30" + label="Tickets" + total={stats?.totalTickets ?? 0} + loading={statsLoading} + href="/tickets" + rows={[ + { label: 'Regular', value: stats?.totalNormalTickets ?? 0, href: '/tickets' }, + { label: 'Package', value: stats?.totalPackageTickets ?? 0, href: '/tickets' }, + ]} /> + + {/* Revenue card */} +
+
+
+ +
+ Revenue +
+ {statsLoading ? ( +

Loading…

+ ) : ( + <> +

+ {formatCurrency(overallGrand, 'ETB')} +

+
+
+

Regular

+

{formatCurrency(normalGrand, 'ETB')}

+
+
+

Package

+

{formatCurrency(packageGrand, 'ETB')}

+
+
+ + View payments + + + )} +
+
+ + {/* Revenue breakdown */} +
+

Revenue Breakdown

+ {statsLoading ? ( +

Loading…

+ ) : !normalRows.length && !packageRows.length ? ( +

No revenue data yet.

+ ) : ( +
+ + +
+ )}
{/* Payment Methods Distribution */} @@ -171,16 +255,8 @@ function DashboardPageContent() {

Payment Methods Distribution

- - {paymentMethods.map((entry, index) => ( + + {paymentMethods.map((_: any, index: number) => ( ))} @@ -189,35 +265,6 @@ function DashboardPageContent() {
)} - - {/* Recent Bookings */} -
-

- - Recent Bookings -

- -
- - {/* Upcoming Trips */} -
-

- - Upcoming Trips -

- -
- ); } @@ -228,4 +275,4 @@ export default function DashboardPage() { ); -} \ No newline at end of file +} diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx index 3c13e952b..e49c16523 100644 --- a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -7,7 +7,7 @@ import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; -import { excessBaggageApi } from '@/lib/api'; +import { excessBaggageApi, apiClient } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; @@ -34,6 +34,15 @@ export default function ExcessBaggagePage() { const [resendSuccess, setResendSuccess] = useState(false); const [resendError, setResendError] = useState(null); + const { data: allowancesData } = useQuery({ + queryKey: ['baggage-allowances'], + queryFn: () => apiClient.get('/agents/excess-baggage/allowances'), + }); + const allowances: any[] = Array.isArray(allowancesData) + ? allowancesData + : (allowancesData as any)?.items ?? (allowancesData as any)?.data ?? []; + const excessRate = allowances[0] ?? null; + const { data, isLoading } = useQuery({ queryKey: ['excess-baggage', filters], queryFn: () => excessBaggageApi.getAll({ @@ -229,58 +238,76 @@ export default function ExcessBaggagePage() { Logging as agent: {user.fullName} )} -
- - setLogForm({ ...logForm, bookingId: e.target.value })} - /> -
-
- - setLogForm({ ...logForm, excessWeightKg: e.target.value })} - /> -
- - {!logForm.collectCash && ( -

- A payment link will be sent to the passenger's email and phone on file. -

+ {!excessRate ? ( +
+ No excess luggage rate configured. Please set a rate in Tariff Rates before logging. +
+ ) : ( + <> +
+ Rate: {(excessRate.excessFeePerKg / 100).toFixed(2)} ETB/kg +
+
+ + setLogForm({ ...logForm, bookingId: e.target.value })} + /> +
+
+ + setLogForm({ ...logForm, excessWeightKg: e.target.value })} + /> +
+ {logForm.excessWeightKg && ( +

+ Estimated charge: {((excessRate.excessFeePerKg / 100) * parseInt(logForm.excessWeightKg || '0')).toFixed(2)} ETB +

+ )} + + {!logForm.collectCash && ( +

+ A payment link will be sent to the passenger's email and phone on file. +

+ )} + )} {logError &&

{logError}

}
setLogModal(false)}>Cancel - { - if (!logForm.bookingId.trim() || !logForm.excessWeightKg) { - setLogError('Booking ID and excess weight are required'); - return; - } - logMutation.mutate({ - bookingId: logForm.bookingId.trim(), - excessWeightKg: parseInt(logForm.excessWeightKg), - collectCash: logForm.collectCash, - }); - }} - > - {logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'} - + {excessRate && ( + { + if (!logForm.bookingId.trim() || !logForm.excessWeightKg) { + setLogError('Booking ID and excess weight are required'); + return; + } + logMutation.mutate({ + bookingId: logForm.bookingId.trim(), + excessWeightKg: parseInt(logForm.excessWeightKg), + collectCash: logForm.collectCash, + }); + }} + > + {logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'} + + )}
diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx new file mode 100644 index 000000000..9fb0de6bb --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { useState } from 'react'; +import { Send, CheckCircle, XCircle, RotateCcw, PlusCircle } from 'lucide-react'; +import Modal from '@/components/ui/Modal'; +import ActionButton from '@/components/ui/ActionButton'; +import Badge from '@/components/ui/Badge'; +import { formatCurrency, formatDateTime } from '@/lib/utils'; +import { + useSupplementaryCharges, + useCreateSupplementaryCharge, + useMarkSupplementaryPaid, + useWaiveSupplementaryCharge, + useResendSupplementaryLink, +} from './useSupplementaryCharges'; + +type Tab = 'create' | 'list'; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER']; + +const STATUS_COLORS: Record = { + PENDING: 'warning', + PAID: 'success', + WAIVED: 'info', + EXPIRED: 'error', +}; + +export default function SupplementaryChargesModal({ isOpen, onClose }: Props) { + const [tab, setTab] = useState('create'); + const [listFilters, setListFilters] = useState({ bookingRef: '', status: '' }); + + // Create form state + const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' }); + const [formError, setFormError] = useState(null); + const [createSuccess, setCreateSuccess] = useState(null); + + const { data: chargesData, isLoading } = useSupplementaryCharges(listFilters); + const charges: any[] = (chargesData as any)?.items ?? (Array.isArray(chargesData) ? chargesData : []); + + const createMutation = useCreateSupplementaryCharge(() => { + setCreateSuccess(`Charge created and payment link sent.`); + setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' }); + setFormError(null); + setTimeout(() => { setCreateSuccess(null); setTab('list'); }, 2000); + }); + + const markPaidMutation = useMarkSupplementaryPaid(); + const waiveMutation = useWaiveSupplementaryCharge(); + const resendMutation = useResendSupplementaryLink(); + + const [actionError, setActionError] = useState(null); + const [actionSuccess, setActionSuccess] = useState(null); + + const flash = (msg: string) => { + setActionSuccess(msg); + setTimeout(() => setActionSuccess(null), 3000); + }; + + const handleCreate = async () => { + setFormError(null); + const amountMinor = Math.round(parseFloat(form.amountEtb) * 100); + if (!form.bookingRef.trim()) return setFormError('Booking reference is required'); + if (!form.amountEtb || isNaN(amountMinor) || amountMinor <= 0) return setFormError('Enter a valid amount'); + try { + await createMutation.mutateAsync({ bookingRef: form.bookingRef.trim(), amountMinor, reason: form.reason, notes: form.notes || undefined }); + } catch (e: any) { + setFormError(e?.response?.data?.message ?? e?.message ?? 'Failed to create charge'); + } + }; + + const handleMarkPaid = async (id: string) => { + setActionError(null); + try { + await markPaidMutation.mutateAsync({ id }); + flash('Marked as paid'); + } catch (e: any) { + setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); + } + }; + + const handleWaive = async (id: string) => { + setActionError(null); + try { + await waiveMutation.mutateAsync({ id }); + flash('Charge waived'); + } catch (e: any) { + setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); + } + }; + + const handleResend = async (id: string) => { + setActionError(null); + try { + await resendMutation.mutateAsync(id); + flash('Payment link resent'); + } catch (e: any) { + setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); + } + }; + + return ( + + {/* Tabs */} +
+ {(['create', 'list'] as Tab[]).map((t) => ( + + ))} +
+ + {/* ── CREATE TAB ── */} + {tab === 'create' && ( +
+ {createSuccess && ( +
✓ {createSuccess}
+ )} + {formError && ( +
{formError}
+ )} + +
+
+ + setForm({ ...form, bookingRef: e.target.value })} + /> +
+
+ + setForm({ ...form, amountEtb: e.target.value })} + /> +
+
+ + +
+
+ +