Merge pull request #1118 from Tria-plc/alpha

Alpha
This commit is contained in:
Stephanos A.
2026-08-06 07:49:35 +03:00
committed by GitHub
13 changed files with 403 additions and 41 deletions

View File

@@ -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,

View File

@@ -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<string, string> = { 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([

View File

@@ -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' })

View File

@@ -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;
}

View File

@@ -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}`);

View File

@@ -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: {} });

View File

@@ -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 () => {

View File

@@ -32,7 +32,7 @@ const SectionHeader = ({ title }: { title: string }) => (
function BookingsPageContent() {
const canManage = usePermission(PERMS.bookings.manage);
const [filters, setFilters] = useState<BookingFilters>({ 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<any>(null);
const [generateTicketBooking, setGenerateTicketBooking] = useState<any>(null);
@@ -50,20 +50,26 @@ function BookingsPageContent() {
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
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> = {}): 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() {
<div>
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>{booking.paymentIntent?.status || 'PENDING'}</Badge>
<div className="text-sm text-muted-foreground">{formatCurrency(booking.displayTotalMinor ?? booking.totalMinor, booking.displayCurrency ?? booking.currency ?? 'ETB')}</div>
{booking.paymentIntent?.providerTxnId && (
<div className="text-xs font-mono text-muted-foreground truncate max-w-[10rem]" title={booking.paymentIntent.providerTxnId}>
{booking.paymentIntent.providerTxnId}
</div>
)}
</div>
),
},
@@ -358,6 +371,12 @@ function BookingsPageContent() {
<input type="date" className="input" value={extraFilters.dateTo}
onChange={(e) => setExtraFilters({ ...extraFilters, dateTo: e.target.value })} />
</div>
<div>
<label className="label">Provider Txn ID</label>
<input type="text" className="input" placeholder="Transaction / order ref"
value={extraFilters.providerTxnId}
onChange={(e) => setExtraFilters({ ...extraFilters, providerTxnId: e.target.value })} />
</div>
</div>
)}
</div>
@@ -471,6 +490,9 @@ function BookingsPageContent() {
<p className="text-xs text-muted-foreground mb-2">Payment Status</p>
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>{b.paymentIntent?.status || 'PENDING'}</Badge>
</div>
<Field label="Payment Method" value={b.paymentIntent?.method} />
<Field label="Provider Txn ID" value={b.paymentIntent?.providerTxnId} mono truncate />
<Field label="Merchant Order ID" value={b.paymentIntent?.merchantOrderId} mono truncate />
</div>
</section>

View File

@@ -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,
});

View File

@@ -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);

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}