diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 2c9010270..482c34268 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -152,6 +152,12 @@ export class BookingsController { @ApiQuery({ name: "returnLegStatus", required: false }) @ApiQuery({ name: "bookingType", required: false }) @ApiQuery({ name: "paymentStatus", required: false }) + @ApiQuery({ + name: "providerTxnId", + required: false, + description: + "Payment provider transaction / order / merchant reference (partial, case-insensitive)", + }) @ApiQuery({ name: "dateFrom", required: false }) @ApiQuery({ name: "dateTo", required: false }) @ApiQuery({ name: "page", required: false }) @@ -162,6 +168,7 @@ export class BookingsController { @Query("returnLegStatus") returnLegStatus?: string, @Query("bookingType") bookingType?: string, @Query("paymentStatus") paymentStatus?: string, + @Query("providerTxnId") providerTxnId?: string, @Query("dateFrom") dateFrom?: string, @Query("dateTo") dateTo?: string, @Query("page") page?: string, @@ -173,6 +180,7 @@ export class BookingsController { returnLegStatus, bookingType, paymentStatus, + providerTxnId, dateFrom, dateTo, page: page ? parseInt(page) : 1, 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 ce389ef3b..7f0a6eee5 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -91,6 +91,7 @@ interface BookingFilters { returnLegStatus?: string; bookingType?: string; paymentStatus?: string; + providerTxnId?: string; dateFrom?: string; dateTo?: string; page?: number; @@ -453,8 +454,9 @@ export class BookingsService { } async findAll(filters: BookingFilters = {}) { - const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters; + const { search, status, returnLegStatus, bookingType, paymentStatus, providerTxnId, dateFrom, dateTo, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; + const txn = providerTxnId?.trim() || undefined; const onlyPackages = bookingType === 'PACKAGE'; const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT'; @@ -495,11 +497,24 @@ export class BookingsService { ...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}), }; } + // paymentStatus and providerTxnId both narrow the same relation — build one `is` filter + // so the second doesn't overwrite the first. + const paymentIntentIs: any = {}; if (paymentStatus) { const statusMap: Record = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' }; - const mapped = statusMap[paymentStatus] ?? paymentStatus; - where.paymentIntent = { is: { status: mapped } }; + paymentIntentIs.status = statusMap[paymentStatus] ?? paymentStatus; } + if (txn) { + // Providers are inconsistent about which reference they hand back to the customer — + // match the transaction id, the provider/merchant order ids, and the generic ref. + paymentIntentIs.OR = [ + { providerTxnId: { contains: txn, mode: 'insensitive' } }, + { providerOrderId: { contains: txn, mode: 'insensitive' } }, + { merchantOrderId: { contains: txn, mode: 'insensitive' } }, + { providerRef: { contains: txn, mode: 'insensitive' } }, + ]; + } + if (Object.keys(paymentIntentIs).length) where.paymentIntent = { is: paymentIntentIs }; const pkgWhere: any = {}; if (search) { @@ -512,7 +527,12 @@ export class BookingsService { } if (status) pkgWhere.status = status; if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt; - if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } }; + const pkgPaymentIntentIs: any = {}; + if (paymentStatus) pkgPaymentIntentIs.status = paymentIntentIs.status; + // PackagePaymentIntent has no providerTxnId/providerOrderId/merchantOrderId columns — + // providerRef is the only reference we can match a package booking on. + if (txn) pkgPaymentIntentIs.providerRef = { contains: txn, mode: 'insensitive' }; + if (Object.keys(pkgPaymentIntentIs).length) pkgWhere.paymentIntent = { is: pkgPaymentIntentIs }; if (onlyPackages) { // Package bookings live in two places: @@ -521,7 +541,7 @@ export class BookingsService { const bookingPkgWhere: any = { packageId: { not: null } }; if (status) bookingPkgWhere.status = status; if (dateFrom || dateTo) bookingPkgWhere.createdAt = where.createdAt; - if (paymentStatus) bookingPkgWhere.paymentIntent = where.paymentIntent; + if (where.paymentIntent) bookingPkgWhere.paymentIntent = where.paymentIntent; if (search) bookingPkgWhere.OR = where.OR; const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([ diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts index 4379ae28d..df0abd8be 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts @@ -2,7 +2,10 @@ import { IsString, IsInt, IsOptional, IsPositive } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class LogExcessBaggageDto { - @ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string; + @ApiPropertyOptional({ example: 'booking-uuid', description: 'Booking UUID for the passenger booking' }) + @IsOptional() @IsString() bookingId?: string; + @ApiPropertyOptional({ example: 'JS6MJ9', description: 'Booking reference for the passenger booking' }) + @IsOptional() @IsString() bookingReference?: string; @ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' }) @IsOptional() @IsString() agentId?: string; @ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' }) 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 eccda0208..32fc06c13 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 @@ -39,12 +39,25 @@ export class ExcessBaggageService { ) {} async logCharge(dto: LogExcessBaggageDto) { - const booking = await this.prisma.booking.findUnique({ - where: { id: dto.bookingId }, - include: { - passenger: { include: { user: true } }, - }, - }); + const bookingRef = dto.bookingReference?.trim(); + const bookingId = dto.bookingId?.trim(); + + const booking = bookingRef + ? await this.prisma.booking.findFirst({ + where: { bookingRef: { equals: bookingRef, mode: 'insensitive' } }, + include: { + passenger: { include: { user: true } }, + }, + }) + : bookingId + ? await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + passenger: { include: { user: true } }, + }, + }) + : null; + if (!booking) throw new NotFoundException('Booking not found'); if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) { throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage'); @@ -64,7 +77,7 @@ export class ExcessBaggageService { const charge = await this.prisma.excessBaggageCharge.create({ data: { - bookingId: dto.bookingId, + bookingId: booking.id, agentId: dto.agentId ?? '', excessWeightKg: dto.excessWeightKg, feePerKgMinor, @@ -81,7 +94,7 @@ export class ExcessBaggageService { await this.sendPaymentLink(charge, booking, contactPhone, contactEmail); } - await this.auditService.log({ action: 'CREATE', entityType: 'ExcessBaggageCharge', entityId: charge.id, newData: { bookingId: dto.bookingId, excessWeightKg: dto.excessWeightKg, totalMinor, status } }); + await this.auditService.log({ action: 'CREATE', entityType: 'ExcessBaggageCharge', entityId: charge.id, newData: { bookingId: booking.id, excessWeightKg: dto.excessWeightKg, totalMinor, status } }); return charge; } 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 0bbd1c7ae..6a45eeb69 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -177,7 +177,7 @@ export class TicketsService { } : null, status: t.status, validatedAt: t.validatedAt, - boardedAt: t.validatedAt, + boardedAt: t.boardedAt ?? t.validatedAt, qrCode: t.qrPayload ?? null, createdAt: t.issuedAt, }; @@ -663,6 +663,13 @@ export class TicketsService { // Use existing validation logic to handle round trips properly const result = await this.validate(bookingRef, validatorId, gateId); + if ((result as any).alreadyValidated) { + return { + success: false, + error: 'Ticket already used', + errorCode: 'ALREADY_USED', + }; + } // Get seat information const seatInfo = (booking as any).seats[0]; @@ -756,12 +763,23 @@ export class TicketsService { const type = booking.bookingType; const now = new Date(); + const markTicketUsed = async () => { + if (ticket.status !== 'USED') { + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'USED' } }); + ticket.status = 'USED'; + } + }; + // ── ONE_WAY / TRANSIT (single scan) ─────────────────────────────────── if (type === 'ONE_WAY') { if (ticket.validatedAt) { + await markTicketUsed(); return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true }; } - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.ticket.update({ + where: { id: ticket.id }, + data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId, status: 'USED' }, + }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); this.fireBoardingPassNotification(booking, ticket, null); await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: 'ONE_WAY' } }); @@ -778,13 +796,22 @@ export class TicketsService { const alreadyValidated = logs.some(l => l.leg === resolvedLeg); if (alreadyValidated) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); + await markTicketUsed(); throw new BadRequestException(`${resolvedLeg} already validated`); } - if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } }); + const validatedAt = ticket.validatedAt ?? now; + if (!ticket.validatedAt) { + await this.prisma.ticket.update({ + where: { id: ticket.id }, + data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId, status: 'USED' }, + }); + } else { + await markTicketUsed(); + } await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); this.fireBoardingPassNotification(booking, ticket, resolvedLeg); await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } }); - return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; + return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt }; } // ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ──────────────────────── @@ -798,12 +825,14 @@ export class TicketsService { if (resolvedLeg === 'OUTBOUND') { if ((booking as any).outboundBoardedAt) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any }); + await markTicketUsed(); throw new BadRequestException('Outbound leg already validated'); } bookingData.outboundBoardedAt = now; } else if (resolvedLeg === 'RETURN') { if ((booking as any).returnBoardedAt) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any }); + await markTicketUsed(); throw new BadRequestException('Return leg already validated'); } bookingData.returnBoardedAt = now; @@ -812,13 +841,19 @@ export class TicketsService { } await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); + const validatedAt = ticket.validatedAt ?? now; if (!ticket.validatedAt) { - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.ticket.update({ + where: { id: ticket.id }, + data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId, status: 'USED' }, + }); + } else { + await markTicketUsed(); } await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); this.fireBoardingPassNotification(booking, ticket, resolvedLeg); await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } }); - return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; + return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt }; } throw new BadRequestException(`Unsupported booking type: ${type}`); diff --git a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts index 5a91601ab..dace45843 100644 --- a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts +++ b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts @@ -111,6 +111,43 @@ describe("Money integrity (Tier-2 direct instantiation)", () => { expect(walletAfter?.balanceMinor).toBe(0); }); + it("accepts a booking reference when logging an excess baggage charge", async () => { + const passenger = await prisma.passenger.create({ data: {} }); + const schedule = await makeSchedule(prisma, passenger.id); + const booking = await prisma.booking.create({ + data: { + bookingRef: "BAG-REF-001", + passengerId: passenger.id, + scheduleId: schedule.id, + totalMinor: 30_000, + status: "CONFIRMED", + }, + }); + + await prisma.baggageAllowance.create({ + data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 80 }, + }); + + const service = new ExcessBaggageService( + prisma as any, + asyncStub(), + asyncStub(), + asyncStub(), + asyncStub(), + asyncStub(), + ); + + const charge: any = await service.logCharge({ + bookingReference: booking.bookingRef, + excessWeightKg: 2, + collectCash: true, + } as any); + + expect(charge.bookingId).toBe(booking.id); + expect(charge.feePerKgMinor).toBe(80); + expect(charge.totalMinor).toBe(160); + }); + // ── E1 / E2 ──────────────────────────────────────────────────────────────── it("E1/E2 🔴 excess-baggage uses the OLDEST allowance globally (ignores seat class); fee = rate×kg", async () => { const passenger = await prisma.passenger.create({ data: {} }); diff --git a/apps/edr-passenger-api/test/ticketing.e2e-spec.ts b/apps/edr-passenger-api/test/ticketing.e2e-spec.ts index b8a380577..ee5baeb7d 100644 --- a/apps/edr-passenger-api/test/ticketing.e2e-spec.ts +++ b/apps/edr-passenger-api/test/ticketing.e2e-spec.ts @@ -228,6 +228,24 @@ describe("Ticketing — generate / scanAndBoard / validate / smart-reassign", () const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } }); expect(ticket?.validatedAt).toBeTruthy(); + expect(ticket?.status).toBe('USED'); + }); + + it("does not allow boarding the same ticket twice", async () => { + const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-REUSE-${Date.now()}`, departureAt: future(60), arrivalAt: future(120) }); + const booking = await createOneWayBooking(schedule.id, seats[0].id); + await markSucceeded(booking.id); + await ticketsService.generate(booking.id); + + const first = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1"); + expect(first.success).toBe(true); + + const second = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1"); + expect(second.success).toBe(false); + expect(second.error).toMatch(/already used/i); + + const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } }); + expect(ticket?.status).toBe('USED'); }); it("refuses boarding before the boarding window opens", async () => { 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 66b5c6c5a..a1909d6e8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -32,7 +32,7 @@ const SectionHeader = ({ title }: { title: string }) => ( function BookingsPageContent() { const canManage = usePermission(PERMS.bookings.manage); const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', status: '' }); - const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' }); + const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '', providerTxnId: '' }); const [showExtraFilters, setShowExtraFilters] = useState(false); const [selectedBooking, setSelectedBooking] = useState(null); const [generateTicketBooking, setGenerateTicketBooking] = useState(null); @@ -50,20 +50,26 @@ function BookingsPageContent() { const [exportDateTo, setExportDateTo] = useState(''); const [exportColumns, setExportColumns] = useState>({ bookingRef: true, bookingType: true, passengerNames: true, contactPhone: true, - contactEmail: true, passengerCount: false, paymentStatus: true, totalMinor: true, status: true, createdAt: true, + contactEmail: true, passengerCount: false, paymentStatus: true, providerTxnId: false, totalMinor: true, status: true, createdAt: true, }); const queryClient = useQueryClient(); + // Single source of truth for the query params — the export path must send the same + // filters as the table, otherwise exporting while filtered dumps every booking. + const buildQueryFilters = (overrides: Partial = {}): BookingFilters => ({ + ...filters, + ...(extraFilters.bookingType && { bookingType: extraFilters.bookingType }), + ...(extraFilters.paymentStatus && { paymentStatus: extraFilters.paymentStatus }), + ...(extraFilters.providerTxnId && { providerTxnId: extraFilters.providerTxnId }), + ...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }), + ...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }), + ...overrides, + }); + const { data, isLoading, error } = useQuery({ queryKey: ['bookings', filters, extraFilters], - queryFn: () => bookingsApi.getAll({ - ...filters, - ...(extraFilters.bookingType && { bookingType: extraFilters.bookingType }), - ...(extraFilters.paymentStatus && { paymentStatus: extraFilters.paymentStatus }), - ...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }), - ...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }), - }), + queryFn: () => bookingsApi.getAll(buildQueryFilters()), }); const smartAssignMutation = useMutation({ @@ -112,7 +118,8 @@ function BookingsPageContent() { { key: 'bookingRef', label: 'Booking Reference' }, { key: 'journeyType', label: 'Journey Type' }, { key: 'passengerNames', label: 'Passenger Names' }, { key: 'contactPhone', label: 'Contact Phone' }, { key: 'contactEmail', label: 'Contact Email' }, { key: 'passengerCount', label: 'Passenger Count' }, - { key: 'paymentStatus', label: 'Payment Status' }, { key: 'totalMinor', label: 'Amount' }, + { key: 'paymentStatus', label: 'Payment Status' }, { key: 'providerTxnId', label: 'Provider Txn ID' }, + { key: 'totalMinor', label: 'Amount' }, { key: 'status', label: 'Status' }, { key: 'createdAt', label: 'Created At' }, ]; @@ -120,7 +127,7 @@ function BookingsPageContent() { const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); if (!cols.length) { alert('Please select at least one column'); return; } // Fetch all records (not just current page) - const allData = await bookingsApi.getAll({ ...filters, page: 1, pageSize: 9999 }); + const allData = await bookingsApi.getAll(buildQueryFilters({ page: 1, pageSize: 9999 })); const exportItems = (allData?.items || []).filter((b: any) => { if (!exportDateFrom && !exportDateTo) return true; const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null; @@ -138,6 +145,7 @@ function BookingsPageContent() { case 'contactEmail': return booking.contactEmail || 'N/A'; case 'passengerCount': return String((booking.adultCount ?? 0) + (booking.childCount ?? 0)); case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING'; + case 'providerTxnId': return booking.paymentIntent?.providerTxnId || 'N/A'; case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency); case 'status': return booking.status; case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : ''; @@ -274,6 +282,11 @@ function BookingsPageContent() {
{booking.paymentIntent?.status || 'PENDING'}
{formatCurrency(booking.displayTotalMinor ?? booking.totalMinor, booking.displayCurrency ?? booking.currency ?? 'ETB')}
+ {booking.paymentIntent?.providerTxnId && ( +
+ {booking.paymentIntent.providerTxnId} +
+ )}
), }, @@ -358,6 +371,12 @@ function BookingsPageContent() { setExtraFilters({ ...extraFilters, dateTo: e.target.value })} /> +
+ + setExtraFilters({ ...extraFilters, providerTxnId: e.target.value })} /> +
)} @@ -471,6 +490,9 @@ function BookingsPageContent() {

Payment Status

{b.paymentIntent?.status || 'PENDING'} + + + 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 e49c16523..f571499a4 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 @@ -28,7 +28,7 @@ export default function ExcessBaggagePage() { const [waiveReason, setWaiveReason] = useState(''); const [waiveError, setWaiveError] = useState(null); const [logModal, setLogModal] = useState(false); - const [logForm, setLogForm] = useState({ bookingId: '', excessWeightKg: '', collectCash: false }); + const [logForm, setLogForm] = useState({ bookingReference: '', excessWeightKg: '', collectCash: false }); const [logError, setLogError] = useState(null); const [resendModal, setResendModal] = useState(null); const [resendSuccess, setResendSuccess] = useState(false); @@ -59,7 +59,7 @@ export default function ExcessBaggagePage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }); setLogModal(false); - setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); + setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); setLogError(null); }, onError: (e: any) => setLogError(e?.response?.data?.message || e?.message || 'Failed to log charge'), @@ -178,7 +178,7 @@ export default function ExcessBaggagePage() {

Excess Lugagge

Track and manage excess luggage charges at boarding

- { setLogModal(true); setLogError(null); setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); }}> + { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); }}> Log Excess Luggage @@ -248,12 +248,12 @@ export default function ExcessBaggagePage() { Rate: {(excessRate.excessFeePerKg / 100).toFixed(2)} ETB/kg
- + setLogForm({ ...logForm, bookingId: e.target.value })} + placeholder="e.g. JS6MJ9" + value={logForm.bookingReference} + onChange={(e) => setLogForm({ ...logForm, bookingReference: e.target.value })} />
@@ -294,12 +294,12 @@ export default function ExcessBaggagePage() { { - if (!logForm.bookingId.trim() || !logForm.excessWeightKg) { - setLogError('Booking ID and excess weight are required'); + if (!logForm.bookingReference.trim() || !logForm.excessWeightKg) { + setLogError('Booking reference and excess weight are required'); return; } logMutation.mutate({ - bookingId: logForm.bookingId.trim(), + bookingReference: logForm.bookingReference.trim(), excessWeightKg: parseInt(logForm.excessWeightKg), collectCash: logForm.collectCash, }); diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/bookings.ts b/apps/edr-passenger-web/backoffice/src/lib/api/bookings.ts index fe2473d0a..41f2f9fda 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/bookings.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/bookings.ts @@ -8,6 +8,7 @@ export const bookingsApi = { if (filters?.status) params.append('status', filters.status); if (filters?.bookingType) params.append('bookingType', filters.bookingType); if (filters?.paymentStatus) params.append('paymentStatus', filters.paymentStatus); + if (filters?.providerTxnId) params.append('providerTxnId', filters.providerTxnId); if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom); if (filters?.dateTo) params.append('dateTo', filters.dateTo); if (filters?.search) params.append('search', filters.search); diff --git a/apps/edr-passenger-web/backoffice/src/types/index.ts b/apps/edr-passenger-web/backoffice/src/types/index.ts index 88ffee43d..c688cc293 100644 --- a/apps/edr-passenger-web/backoffice/src/types/index.ts +++ b/apps/edr-passenger-web/backoffice/src/types/index.ts @@ -48,6 +48,8 @@ export interface BookingFilters { status?: string; bookingType?: string; paymentStatus?: string; + /** Payment provider transaction / order / merchant reference — partial, case-insensitive. */ + providerTxnId?: string; dateFrom?: string; dateTo?: string; search?: string; diff --git a/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx new file mode 100644 index 000000000..209651756 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api-client"; +import { PaymentMethod } from "@/types"; +import { + AlertCircle, + CheckCircle, + CreditCard, + Landmark, + Loader2, + Smartphone, + Wallet, +} from "lucide-react"; + +const getIconForMethod = (methodId: string) => { + if (methodId.includes("CARD")) return CreditCard; + if (methodId.includes("WALLET")) return Wallet; + if (methodId.includes("CAC")) return Landmark; + return Smartphone; +}; + +export default function ExcessBaggagePayPage() { + const { token } = useParams<{ token: string }>(); + const router = useRouter(); + const [selectedMethod, setSelectedMethod] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [paymentError, setPaymentError] = useState(null); + + const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({ + queryKey: ["excessBaggageCharge", token], + queryFn: () => apiClient.get(`/excess-baggage/pay/${token}`), + retry: false, + enabled: !!token, + }); + + const { data: paymentMethods = [], isLoading: loadingMethods } = useQuery({ + queryKey: ["paymentMethods"], + queryFn: async () => { + const res = await apiClient.get("/payments/methods"); + return Array.isArray(res) ? res : []; + }, + enabled: !!charge, + }); + + const amountDisplay = useMemo(() => { + const amountMinor = Number(charge?.totalMinor ?? charge?.amountMinor ?? 0); + return (amountMinor / 100).toFixed(2); + }, [charge]); + + const currency = charge?.currency ?? charge?.booking?.currency ?? "ETB"; + + const payMutation = useMutation({ + mutationFn: (method: string) => + apiClient.post(`/excess-baggage/pay/${token}/initiate`, { + method, + platform: "web", + }), + onSuccess: (data: any) => { + if (data?.clientAction?.type === "REDIRECT") { + window.location.href = data.clientAction.url; + return; + } + router.push(`/excess-baggage/pay/${token}/result`); + }, + onError: (err: any) => { + setPaymentError(err?.response?.data?.message ?? err?.message ?? "Payment failed. Please try again."); + setIsProcessing(false); + }, + }); + + const handlePay = () => { + if (!selectedMethod) return; + setIsProcessing(true); + setPaymentError(null); + payMutation.mutate(selectedMethod); + }; + + if (loadingCharge) { + return ( +
+ +
+ ); + } + + if (chargeError || !charge) { + const msg = (chargeError as any)?.response?.data?.message ?? "This payment link is invalid or has expired."; + return ( +
+
+ +

Link unavailable

+

{msg}

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

Pay excess baggage

+

+ Booking {charge.booking?.bookingRef ?? "—"} +

+
+ +
+
+ Amount due + + {currency} {amountDisplay} + +
+
+ Weight + {charge.excessWeightKg ?? "—"} kg +
+
+ +
+

Select payment method

+ {loadingMethods ? ( +
+ + Loading... +
+ ) : ( +
+ {paymentMethods.filter((m) => m.enabled).map((method) => { + const Icon = getIconForMethod(method.type); + const isSelected = selectedMethod === method.type; + return ( + + ); + })} +
+ )} +
+ + {paymentError &&

⚠️ {paymentError}

} + + +
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/result/page.tsx b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/result/page.tsx new file mode 100644 index 000000000..cc334c859 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/result/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { CheckCircle } from "lucide-react"; + +export default function ExcessBaggagePayResultPage() { + const { token } = useParams<{ token: string }>(); + + return ( +
+
+ +

Payment submitted

+

+ Your excess baggage payment request is being processed. Reference: {token} +

+
+
+ ); +}