From d1a15a82e3cca580276d05a344baf0d3002bf2a4 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 5 Aug 2026 10:37:49 +0300 Subject: [PATCH] Excess baggage payment link changes --- .../excess-baggage/excess-baggage.dto.ts | 5 +- .../excess-baggage/excess-baggage.service.ts | 29 ++- .../test/money-integrity.e2e-spec.ts | 37 ++++ .../src/app/excess-baggage/page.tsx | 20 +- .../app/excess-baggage/pay/[token]/page.tsx | 183 ++++++++++++++++++ .../pay/[token]/result/page.tsx | 20 ++ 6 files changed, 275 insertions(+), 19 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/result/page.tsx 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/test/money-integrity.e2e-spec.ts b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts index 02ca87863..66dfa9015 100644 --- a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts +++ b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts @@ -110,6 +110,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-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/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} +

+
+
+ ); +}