From 3d1b06b7ee8ac3e4ea1c10d6fff1fbfaf661b9b9 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 24 Jul 2026 13:51:53 +0300 Subject: [PATCH 1/2] fix: ( payment ) prevent duplicate booking charges and fix D-Money queryOrder --- .../src/modules/payments/payments.service.ts | 55 ++++++++++++++++++ .../src/modules/intents/intents.service.ts | 56 +++++++++++++------ .../src/providers/dmoney/dmoney.provider.ts | 15 ++++- 3 files changed, 109 insertions(+), 17 deletions(-) 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 e5846b48f..bee599ba4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -242,6 +242,61 @@ export class PaymentsService { return this.initiateWalletPayment(booking); } + // Double-charge guard for payment-method switches. Before opening a fresh charge over + // this booking, reconcile any still-open intent against the authoritative provider + // status — the booking-status check above only blocks once the booking is CONFIRMED, + // which leaves a window where the first attempt actually paid but the mark-paid + // webhook/poll hasn't landed yet. + const existingIntent = await this.prisma.paymentIntent.findUnique({ + where: { bookingId: booking.id }, + }); + if (existingIntent && NON_TERMINAL_STATUSES.includes(existingIntent.status)) { + let snapshot: PaymentIntentSnapshot | null = null; + try { + snapshot = await this.paymentClient.getIntentByReference( + PaymentReferenceType.BOOKING, + booking.id, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `payment reconcile before initiate failed for booking ${booking.id}: ${message}; treating existing intent as still open`, + ); + } + + // The previous attempt actually paid (provider SUCCEEDED, event just late): + // converge the booking now and return it — never charge a second time. + if (snapshot?.status === ProviderPaymentStatus.SUCCEEDED) { + let intent = await this.syncIntentProjection(booking.id, snapshot); + await this.finalizePaymentSuccess({ + intentId: intent.id, + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + }); + intent = await this.prisma.paymentIntent.findUniqueOrThrow({ + where: { id: intent.id }, + }); + return this.formatIntentResponse(intent); + } + + // Still pending at the provider (REQUIRES_ACTION/PROCESSING) — or the payment + // service was unreachable and the local status is non-terminal. Block the switch: + // return the existing intent so the payer completes or waits out the open attempt + // rather than opening a second concurrent charge. + if ( + !snapshot || + snapshot.status === ProviderPaymentStatus.REQUIRES_ACTION || + snapshot.status === ProviderPaymentStatus.PROCESSING + ) { + const intent = snapshot + ? await this.syncIntentProjection(booking.id, snapshot) + : existingIntent; + return this.formatIntentResponse(intent); + } + // Otherwise the provider reports FAILED/CANCELLED — fall through and initiate + // the newly selected method below. + } + const { returnUrl, failureUrl } = this.resolveReturnUrls( method, requestOrigin, diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index 704a46c2f..d54499108 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -100,11 +100,13 @@ export class IntentsService { existing.status === ProviderPaymentStatus.REQUIRES_ACTION ) { // Same provider, payer re-initiated while a session is open (back button, - // abandoned checkout). Provider sessions are single-use, so re-serving the - // old clientAction hands the payer a dead checkout. Verify at the provider, - // then supersede: paid/processing intents are adopted, unpaid ones retired - // so a fresh session opens below. - const settled = await this.verifyThenSupersede(existing); + // abandoned checkout, second device). Verify at the provider first, then: + // paid/processing sessions are adopted; an unpaid session that is still live + // (unexpired, same amount) is REUSED — its hosted page stays payable until + // expiresAt, so minting a fresh session would leave the old one concurrently + // payable and invite a double charge. Only genuinely expired/changed sessions + // are retired so a fresh one opens below. + const settled = await this.verifyThenReuseOrRetire(existing, request); if (settled) return this.toSnapshot(settled); } else { // PROCESSING (money in flight) or SUCCEEDED (already paid): never reopen — @@ -272,18 +274,27 @@ export class IntentsService { } /** - * Re-initiate guard for an open REQUIRES_ACTION intent on the same provider. + * Re-initiate handler for an open REQUIRES_ACTION intent on the same provider. * Queries the provider first — the payer may have paid on the old session with - * the webhook still in flight. Paid/processing answers are applied through the - * state machine and the intent is returned for reuse. Anything still unpaid is - * retired (CANCELLED, no notification — nothing was paid; a payment.failed here - * would wrongly fail the domain order mid-retry) and null is returned so the - * caller opens a fresh provider session. When the status query itself errors, - * the existing intent is reused unchanged: superseding blind could leave two - * live sessions and a double charge. + * the webhook still in flight. Then: + * + * - Paid/processing: applied through the state machine and the intent is returned + * for the caller to adopt. + * - Unpaid but still live (not expired, same amount/currency): the existing intent + * is REUSED and returned — the provider's hosted page remains payable until + * expiresAt, so opening a fresh session would leave two concurrently-payable + * sessions and invite a double charge (observed in prod: a superseded Telebirr + * session was paid after cancellation, orphaning the capture). + * - Expired, or the requested amount/currency changed: retired (CANCELLED, no + * notification — nothing was paid; a payment.failed here would wrongly fail the + * domain order mid-retry) and null is returned so the caller opens a fresh session. + * + * When the status query itself errors, the existing intent is reused unchanged: + * superseding blind could leave two live sessions and a double charge. */ - private async verifyThenSupersede( + private async verifyThenReuseOrRetire( intent: PaymentIntent, + request: InitiatePaymentRequest, ): Promise { let status: ProviderStatus; try { @@ -291,7 +302,7 @@ export class IntentsService { } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.warn( - `verify-before-supersede: queryStatus failed for intent ${intent.id}: ${message}; reusing existing session`, + `verify-before-reuse: queryStatus failed for intent ${intent.id}: ${message}; reusing existing session`, ); return intent; } @@ -304,14 +315,27 @@ export class IntentsService { return (await this.intentsRepository.findById(intent.id)) ?? intent; } + // Unpaid at the provider. Reuse the still-live session rather than superseding it. const expired = intent.expiresAt != null && intent.expiresAt.getTime() < Date.now(); + const chargeChanged = + intent.amountMinor !== request.amountMinor || + intent.currency !== request.currency; + + if (!expired && !chargeChanged) { + this.logger.log( + `intent ${intent.id} reused (live ${intent.provider} session, unpaid, not expired) for ` + + `${request.service}/${request.referenceType}/${request.referenceId}`, + ); + return intent; + } + await this.intentsRepository.update(intent.id, { status: ProviderPaymentStatus.CANCELLED, failureCode: expired ? "EXPIRED" : "SUPERSEDED", failureMessage: expired ? "Provider session expired before the payer acted" - : "Payer re-initiated; previous provider session superseded", + : "Payer re-initiated with a changed amount; previous session superseded", }); this.logger.log( `intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`, diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 91e33d2da..51e90381b 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -102,8 +102,12 @@ export class DMoneyProvider implements PaymentProvider { async queryStatus(merchantOrderId: string): Promise { const fabricToken = await this.applyFabricToken(); const requestBody = this.buildQueryOrderRequest(merchantOrderId); + const { sign: _sign, ...sanitizedBody } = requestBody; + this.logger.log( + `D-Money queryOrder send request merchOrderId=${merchantOrderId} body=${JSON.stringify(sanitizedBody)}`, + ); const response = await this.postJson( - `${this.baseUrl}/apiaccess/payment/v1/merchant/queryOrder`, + `${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/queryOrder`, requestBody, { "Content-Type": "application/json", @@ -112,9 +116,18 @@ export class DMoneyProvider implements PaymentProvider { }, ); + this.logger.log( + `D-Money queryOrder response merchOrderId=${merchantOrderId} body=${JSON.stringify(response)}`, + ); + const orderStatus = response.biz_content?.order_status; const providerTxnId = response.biz_content?.payment_order_id; const mapped = this.mapOrderStatus(orderStatus); + this.logger.log( + `D-Money queryOrder result merchOrderId=${merchantOrderId} ` + + `orderStatus=${orderStatus ?? "n/a"} mapped=${mapped} ` + + `providerTxnId=${providerTxnId ?? "n/a"}`, + ); return { status: mapped, From 050a08f3301619f346fc8eaebce95416e77e5f3b Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Fri, 24 Jul 2026 16:09:27 +0300 Subject: [PATCH 2/2] Boarding report added --- .../src/modules/reports/reports.controller.ts | 6 + .../src/modules/reports/reports.service.ts | 113 ++++++ .../src/app/reports/boarding/layout.tsx | 3 + .../src/app/reports/boarding/page.tsx | 382 ++++++++++++++++++ .../src/components/layout/Sidebar.tsx | 1 + 5 files changed, 505 insertions(+) create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/boarding/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/boarding/page.tsx diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 6f91de06f..52b84da66 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -53,6 +53,12 @@ export class ReportsController { return this.service.getSeatStatusReport(scheduleId); } + @Get("boarding") + @ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" }) + getBoardingReport(@Query('scheduleId') scheduleId: string) { + return this.service.getBoardingReport(scheduleId); + } + @Get("payments") @ApiOperation({ summary: "Payments collected for a schedule" }) getPaymentsReport(@Query('scheduleId') scheduleId: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 1aef00185..49be0859c 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -1022,6 +1022,119 @@ export class ReportsService { return { total: rows.length, rows }; } + async getBoardingReport(scheduleId: string) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { + id: true, + departureAt: true, + arrivalAt: true, + train: { select: { number: true, name: true } }, + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + }); + if (!schedule) return null; + + const tickets = await this.prisma.ticket.findMany({ + where: { + OR: [ + { scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } }, + { leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } }, + { scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } }, + ], + }, + select: { + id: true, + bookingRef: true, + passengerName: true, + boardedAt: true, + validatorId: true, + status: true, + booking: { + select: { + status: true, + originStationId: true, + destinationStationId: true, + }, + }, + seat: { + select: { + seatNumber: true, + bedPosition: true, + coach: { + select: { + number: true, + coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } }, + }, + }, + }, + }, + }, + orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }], + }); + + const stationIds = [...new Set( + tickets.flatMap(t => [t.booking.originStationId, t.booking.destinationStationId]).filter(Boolean) as string[], + )]; + const stations = stationIds.length > 0 + ? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } }) + : []; + const stationName = new Map(stations.map(s => [s.id, s.name])); + + const resolveSeatClass = (seat: any): string | null => { + const classes = seat?.coach?.coachType?.seatClasses ?? []; + const matched = seat?.bedPosition + ? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition.toLowerCase()) + : null; + return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null; + }; + + const rows = tickets.map(t => ({ + bookingRef: t.bookingRef, + passengerName: t.passengerName, + coachNumber: t.seat?.coach?.number ?? null, + seatNumber: t.seat?.seatNumber ?? null, + seatClassName: resolveSeatClass(t.seat), + origin: t.booking.originStationId ? (stationName.get(t.booking.originStationId) ?? null) : null, + destination: t.booking.destinationStationId ? (stationName.get(t.booking.destinationStationId) ?? null) : null, + boarded: !!t.boardedAt, + boardedAt: t.boardedAt ?? null, + validatorId: t.validatorId ?? null, + bookingStatus: t.booking.status, + })); + + const boardedCount = rows.filter(r => r.boarded).length; + const notBoardedCount = rows.length - boardedCount; + + const byCoach = new Map(); + for (const r of rows) { + const key = r.coachNumber ?? 'Unknown'; + if (!byCoach.has(key)) byCoach.set(key, { coachNumber: key, total: 0, boarded: 0 }); + byCoach.get(key)!.total++; + if (r.boarded) byCoach.get(key)!.boarded++; + } + + return { + schedule: { + id: schedule.id, + trainName: (schedule.train as any)?.name ?? (schedule.train as any)?.number, + origin: (schedule.originStation as any)?.name, + destination: (schedule.destinationStation as any)?.name, + departureAt: schedule.departureAt, + arrivalAt: schedule.arrivalAt, + }, + summary: { + total: rows.length, + boardedCount, + notBoardedCount, + boardingRate: rows.length > 0 ? +((boardedCount / rows.length) * 100).toFixed(1) : 0, + }, + byCoach: [...byCoach.values()].sort((a, b) => a.coachNumber.localeCompare(b.coachNumber)), + rows, + }; + } + async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId }, diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/boarding/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/boarding/layout.tsx new file mode 100644 index 000000000..790272de1 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/boarding/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/boarding/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/boarding/page.tsx new file mode 100644 index 000000000..73ec7012d --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/boarding/page.tsx @@ -0,0 +1,382 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { LogIn, Users, CheckCircle, XCircle, BarChart3, Train, Download } from "lucide-react"; +import { apiClient } from "@/lib/api-client"; +import Badge from "@/components/ui/Badge"; +import ActionButton from "@/components/ui/ActionButton"; +import { formatDateTime } from "@/lib/utils"; +import Pagination from "@/components/ui/Pagination"; +import { usePagination } from "@/lib/use-pagination"; + +interface ScheduleOption { + id: string; + label: string; +} + +interface BoardingRow { + bookingRef: string; + passengerName: string; + coachNumber: string | null; + seatNumber: string | null; + seatClassName: string | null; + origin: string | null; + destination: string | null; + boarded: boolean; + boardedAt: string | null; + validatorId: string | null; + bookingStatus: string; +} + +interface BoardingReport { + schedule: { + id: string; + trainName: string; + origin: string; + destination: string; + departureAt: string; + arrivalAt: string; + }; + summary: { + total: number; + boardedCount: number; + notBoardedCount: number; + boardingRate: number; + }; + byCoach: { coachNumber: string; total: number; boarded: number }[]; + rows: BoardingRow[]; +} + +type Tab = "summary" | "details"; + +export default function BoardingReportPage() { + const [scheduleId, setScheduleId] = useState(""); + const [tab, setTab] = useState("summary"); + const [search, setSearch] = useState(""); + const [filterBoarded, setFilterBoarded] = useState<"ALL" | "BOARDED" | "NOT_BOARDED">("ALL"); + + const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ + queryKey: ["report-schedules-all"], + queryFn: () => apiClient.get("/reports/schedules?all=true"), + }); + const schedules = schedulesRaw ?? []; + + const { data, isLoading, isError } = useQuery({ + queryKey: ["boarding-report", scheduleId], + queryFn: () => apiClient.get(`/reports/boarding?scheduleId=${scheduleId}`), + enabled: !!scheduleId, + }); + + const filtered = (data?.rows ?? []).filter((r) => { + if (filterBoarded === "BOARDED" && !r.boarded) return false; + if (filterBoarded === "NOT_BOARDED" && r.boarded) return false; + if (search.trim()) { + const q = search.toLowerCase(); + return ( + r.passengerName.toLowerCase().includes(q) || + r.bookingRef.toLowerCase().includes(q) || + (r.seatNumber ?? "").toLowerCase().includes(q) || + (r.coachNumber ?? "").toLowerCase().includes(q) + ); + } + return true; + }); + + const { paged, page, totalPages, setPage, reset } = usePagination(filtered, 50); + + const doExport = () => { + if (!filtered.length) return; + const headers = ["Booking Ref", "Passenger", "Seat Class", "Coach", "Seat", "Origin", "Destination", "Boarded", "Boarded At", "Validator"]; + const rows = filtered.map((r) => [ + r.bookingRef, + r.passengerName, + r.seatClassName ?? "—", + r.coachNumber ?? "—", + r.seatNumber ?? "—", + r.origin ?? "—", + r.destination ?? "—", + r.boarded ? "Yes" : "No", + r.boardedAt ? formatDateTime(r.boardedAt) : "—", + r.validatorId ?? "—", + ]); + const csv = [ + headers.map((h) => `"${h}"`).join(","), + ...rows.map((row) => row.map((v) => `"${v}"`).join(",")), + ].join("\n"); + const blob = new Blob([csv], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `boarding-${scheduleId}-${new Date().toISOString().split("T")[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+

Boarding Report

+

+ Boarding status and passenger breakdown for a schedule +

+
+ + {/* Schedule selector */} +
+
+
+ + +
+
+ {isLoading &&

Loading…

} + {isError &&

Failed to load report.

} +
+ + {!scheduleId && ( +
+ +

Select a schedule above to load the boarding report

+
+ )} + + {data && ( + <> + {/* Schedule info */} +
+
+ +
+
+

{data.schedule.trainName}

+

+ {data.schedule.origin} → {data.schedule.destination} · + Departure: {formatDateTime(data.schedule.departureAt)} +

+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Summary Tab */} + {tab === "summary" && ( +
+ {/* KPI cards */} +
+
+
+
+

Total Tickets

+

{data.summary.total}

+

Confirmed passengers

+
+ +
+
+ +
+
+
+

Boarded

+

+ {data.summary.boardedCount} +

+

Scanned at gate

+
+ +
+
+ +
+
+
+

Not Boarded

+

+ {data.summary.notBoardedCount} +

+

No-shows / pending

+
+ +
+
+ +
+
+
+

Boarding Rate

+

+ {data.summary.boardingRate}% +

+
+
+
+
+ +
+
+
+ + {/* By Coach */} + {data.byCoach.length > 0 && ( +
+
+ + + + + + + + + + + + {data.byCoach.map((c) => { + const rate = c.total > 0 ? +((c.boarded / c.total) * 100).toFixed(1) : 0; + return ( + + + + + + + + ); + })} + +
CoachTotalBoardedNot BoardedRate
{c.coachNumber}{c.total}{c.boarded}{c.total - c.boarded} +
+
+
+
+ {rate}% +
+
+
+
+ )} +
+ )} + + {/* Details Tab */} + {tab === "details" && ( +
+
+ { setSearch(e.target.value); reset(); }} + /> + + + Export CSV + +
+
+ + + + {["Booking Ref", "Passenger", "Seat Class · Coach · Seat", "Route", "Status", "Boarded At"].map((h) => ( + + ))} + + + + {paged.map((row, i) => ( + + + + + + + + + ))} + {paged.length === 0 && ( + + + + )} + +
+ {h} +
{row.bookingRef}{row.passengerName} + {row.seatClassName ?? "—"} + {row.coachNumber && · {row.coachNumber}} + {row.seatNumber && · #{row.seatNumber}} + + {row.origin && row.destination ? `${row.origin} → ${row.destination}` : (row.origin ?? row.destination ?? "—")} + + + {row.boarded ? "Boarded" : "Not Boarded"} + + + {row.boardedAt ? formatDateTime(row.boardedAt) : "—"} +
+ No passengers found +
+
+ +
+ )} + + )} + + {!data && !isLoading && scheduleId && ( +
+ No data found for this schedule. +
+ )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 98f76cc18..ca8e1fb15 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -124,6 +124,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view }, { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view }, + { name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: PERMS.reports.view }, { name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view }, // { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view }, // { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },