mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Excess baggage payment link changes
This commit is contained in:
@@ -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' })
|
||||
|
||||
@@ -39,12 +39,25 @@ export class ExcessBaggageService {
|
||||
) {}
|
||||
|
||||
async logCharge(dto: LogExcessBaggageDto) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: dto.bookingId },
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {} });
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function ExcessBaggagePage() {
|
||||
const [waiveReason, setWaiveReason] = useState('');
|
||||
const [waiveError, setWaiveError] = useState<string | null>(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<string | null>(null);
|
||||
const [resendModal, setResendModal] = useState<any>(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() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
|
||||
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
|
||||
</div>
|
||||
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); }}>
|
||||
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); }}>
|
||||
Log Excess Luggage
|
||||
</ActionButton>
|
||||
</div>
|
||||
@@ -248,12 +248,12 @@ export default function ExcessBaggagePage() {
|
||||
Rate: <span className="font-semibold">{(excessRate.excessFeePerKg / 100).toFixed(2)} ETB/kg</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Booking ID</label>
|
||||
<label className="label">Booking Reference</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Booking UUID"
|
||||
value={logForm.bookingId}
|
||||
onChange={(e) => setLogForm({ ...logForm, bookingId: e.target.value })}
|
||||
placeholder="e.g. JS6MJ9"
|
||||
value={logForm.bookingReference}
|
||||
onChange={(e) => setLogForm({ ...logForm, bookingReference: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -294,12 +294,12 @@ export default function ExcessBaggagePage() {
|
||||
<ActionButton
|
||||
loading={logMutation.isPending}
|
||||
onClick={() => {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||
|
||||
const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({
|
||||
queryKey: ["excessBaggageCharge", token],
|
||||
queryFn: () => apiClient.get<any>(`/excess-baggage/pay/${token}`),
|
||||
retry: false,
|
||||
enabled: !!token,
|
||||
});
|
||||
|
||||
const { data: paymentMethods = [], isLoading: loadingMethods } = useQuery<PaymentMethod[]>({
|
||||
queryKey: ["paymentMethods"],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<PaymentMethod[]>("/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<any>(`/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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-10 h-10 text-primary animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (chargeError || !charge) {
|
||||
const msg = (chargeError as any)?.response?.data?.message ?? "This payment link is invalid or has expired.";
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4">
|
||||
<div className="max-w-sm w-full text-center space-y-4">
|
||||
<AlertCircle className="w-14 h-14 text-red-500 mx-auto" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Link unavailable</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{msg}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-start justify-center px-4 py-10">
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Pay excess baggage</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Booking <span className="font-semibold text-gray-700 dark:text-gray-300">{charge.booking?.bookingRef ?? "—"}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Amount due</span>
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{currency} {amountDisplay}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 pt-3 flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Weight</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{charge.excessWeightKg ?? "—"} kg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card space-y-3">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100">Select payment method</h2>
|
||||
{loadingMethods ? (
|
||||
<div className="flex items-center justify-center py-6 gap-2">
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{paymentMethods.filter((m) => m.enabled).map((method) => {
|
||||
const Icon = getIconForMethod(method.type);
|
||||
const isSelected = selectedMethod === method.type;
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
onClick={() => setSelectedMethod(method.type)}
|
||||
disabled={isProcessing}
|
||||
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/8 dark:bg-primary/15 shadow-md"
|
||||
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary/50"
|
||||
} ${isProcessing ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${isSelected ? "bg-primary" : "bg-gray-100 dark:bg-gray-700"}`}>
|
||||
<Icon className={`w-5 h-5 ${isSelected ? "text-white" : "text-primary"}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.displayName}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{method.region} · {method.currency}</p>
|
||||
</div>
|
||||
{isSelected && <CheckCircle className="w-5 h-5 text-primary flex-shrink-0" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{paymentError && <p className="text-red-600 dark:text-red-400 text-sm text-center">⚠️ {paymentError}</p>}
|
||||
|
||||
<button
|
||||
onClick={handlePay}
|
||||
disabled={!selectedMethod || isProcessing}
|
||||
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||
</span>
|
||||
) : (
|
||||
`Pay ${currency} ${amountDisplay}`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center px-4">
|
||||
<div className="max-w-sm w-full text-center space-y-4">
|
||||
<CheckCircle className="w-14 h-14 text-green-600 mx-auto" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Payment submitted</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Your excess baggage payment request is being processed. Reference: {token}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user