From e316e59eda73b8f3934a733e5598f38e77f81a6b Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 16 Jul 2026 13:39:44 +0300 Subject: [PATCH 1/2] Update altenative dates --- .../portal/src/app/booking/results/page.tsx | 263 +++++++---- .../components/AlternativeDatesCalendar.tsx | 426 ++++++++++++++++++ 2 files changed, 611 insertions(+), 78 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/components/AlternativeDatesCalendar.tsx diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 4194324d4..7f16682e0 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -24,6 +24,7 @@ import { format } from "date-fns"; import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format"; import { formatFare } from "@/utils/fare-utils"; import { useState, useEffect } from "react"; +import AlternativeDatesCalendar from "@/components/AlternativeDatesCalendar"; // Shared by the compact schedule card's coach-type badges and the "Choose Your Coach" // modal, so both pick the same icon for a given coach type name. @@ -47,6 +48,11 @@ export default function ResultsPage() { ); const [effectiveDepartureDate, setEffectiveDepartureDate] = useState(''); const [effectiveReturnDate, setEffectiveReturnDate] = useState(''); + // Round-trip "both legs empty" dual-calendar: holds picks until both outbound + // and return dates are chosen, then a single search fires for the pair — see + // the effect below, right after searchData/pushResultsWithDates are defined. + const [pendingOutboundDate, setPendingOutboundDate] = useState(); + const [pendingInboundDate, setPendingInboundDate] = useState(); const [classModal, setClassModal] = useState(null); const [promoData, setPromoData] = useState<{ code: string; @@ -96,11 +102,14 @@ export default function ResultsPage() { promoCode: searchParams.get("promoCode") || searchCriteria?.promoCode || "", }; - // Initialise effective dates from URL/store once searchData is stable + // Keep the displayed effective dates in sync with the URL-driven search + // criteria — not just on first load, but every time searchData.date/returnDate + // actually changes (e.g. picking a new date on the alternative-dates calendar + // re-runs the search with a different date; the heading must follow it, not + // stay frozen on whatever was first requested). useEffect(() => { - if (searchData.date && !effectiveDepartureDate) setEffectiveDepartureDate(searchData.date); - if (searchData.returnDate && !effectiveReturnDate) setEffectiveReturnDate(searchData.returnDate); - // eslint-disable-next-line react-hooks/exhaustive-deps + if (searchData.date) setEffectiveDepartureDate(searchData.date); + if (searchData.returnDate) setEffectiveReturnDate(searchData.returnDate); }, [searchData.date, searchData.returnDate]); const nat = (searchData.nationality ?? '').toUpperCase(); @@ -161,6 +170,46 @@ export default function ResultsPage() { return `/booking/search?${params}`; }; + // Pushes a new date onto the CURRENT results route (not back to the search form, + // unlike buildSearchUrl) — the query's queryKey is derived from these URL params + // (see searchData/useQuery below), so this alone re-triggers a search with the + // new date(s) while preserving route/passengers/nationality/promo unchanged. + const pushResultsWithDates = (overrides: { date?: string; returnDate?: string }) => { + const params = new URLSearchParams({ + tripType: searchData.journeyType, + origin: searchData.originStationId, + destination: searchData.destinationStationId, + date: overrides.date ?? searchData.date, + adults: searchData.adultCount.toString(), + children: searchData.childCount.toString(), + nationality: searchData.nationality, + ...(searchData.promoCode && { promoCode: searchData.promoCode }), + }); + const returnDate = overrides.returnDate ?? searchData.returnDate; + if (returnDate) params.set("returnDate", returnDate); + router.push(`/booking/results?${params}`); + }; + + // Round-trip dual calendar: fire the search only once both legs have a pick — + // picking outbound alone must not trigger a search on its own. + useEffect(() => { + if (pendingOutboundDate && pendingInboundDate) { + pushResultsWithDates({ + date: format(pendingOutboundDate, "yyyy-MM-dd"), + returnDate: format(pendingInboundDate, "yyyy-MM-dd"), + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingOutboundDate, pendingInboundDate]); + + // Clear stale picks once the URL-driven search criteria actually changes (i.e. + // after a real navigation completes), so a previous failed attempt's picks don't + // leak into the next "still no results" render. + useEffect(() => { + setPendingOutboundDate(undefined); + setPendingInboundDate(undefined); + }, [searchData.date, searchData.returnDate]); + const { data: results, isLoading, @@ -1064,6 +1113,90 @@ export default function ResultsPage() { ); } + // Round trip, both legs empty, but at least one leg has alternative dates to + // offer — show both date pickers together instead of forcing the user through + // the outbound-then-return step wizard for a case we already know is doubly empty. + const isRoundTripBothLegsEmpty = + isRoundTrip && outboundSchedules.length === 0 && inboundSchedules.length === 0; + if (isRoundTripBothLegsEmpty) { + const bothCalendarsAvailable = alternativeOutbound.length > 0 && alternativeInbound.length > 0; + const outboundValue = pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined); + const inboundValue = pendingInboundDate ?? (searchData.returnDate ? new Date(`${searchData.returnDate}T00:00:00`) : undefined); + + return ( +
+
+
+
+
+ +
+

+ No trains available +

+

+ No trains available on your selected dates. Please choose another + date below. +

+
+ {alternativeOutbound.length > 0 ? ( + { + if (!bothCalendarsAvailable) { + pushResultsWithDates({ date: format(date, "yyyy-MM-dd") }); + return; + } + setPendingOutboundDate(date); + if (pendingInboundDate && pendingInboundDate < date) setPendingInboundDate(undefined); + }} + /> + ) : ( +

+ No alternative outbound dates found nearby. +

+ )} + {alternativeInbound.length > 0 ? ( + { + if (!bothCalendarsAvailable) { + pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") }); + return; + } + setPendingInboundDate(date); + }} + /> + ) : ( +

+ No alternative return dates found nearby. +

+ )} +
+ {bothCalendarsAvailable && pendingOutboundDate && !pendingInboundDate && ( +

+ Now choose a return date to search. +

+ )} + +
+
+
+
+ ); + } + if (isOneWayNoOutbound) { return (
@@ -1078,42 +1211,26 @@ export default function ResultsPage() { No trains available

- There are no trains scheduled on{" "} - - {searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"} - - . Try a different date to see available trains. + No trains available on your selected date. Please choose another + date below.

- + {alternativeOutbound.length > 0 ? ( + pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })} + /> + ) : ( + + )}
- - {/* Alternative Travel Options — commented out for the time being; - only the "No trains available" banner above is shown. - {hasAlternatives && ( -
-
-

- Alternative Travel Options -

-

- These trains run on different dates than requested — - adjust your travel date to book one of them. -

-
-
- {alternativeOutbound.map((schedule) => - renderScheduleCard(schedule, true, true), - )} -
-
- )} - */} @@ -1249,28 +1366,23 @@ export default function ResultsPage() { {outboundSchedules.length === 0 && (
-
-
- - No trains on {searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}. -
- -
- {/* Alternative Outbound Options — commented out for the time being; - only the "No trains" banner above is shown. -
-

- Alternative Outbound Options -

-
-
- {alternativeOutbound.map((schedule: Schedule) => - renderScheduleCard(schedule, true, true), - )} -
- */} + )}
)} @@ -1335,28 +1447,23 @@ export default function ResultsPage() { {inboundSchedules.length === 0 && (
-
-
- - No trains on {searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}. -
- -
- {/* Alternative Return Options — commented out for the time being; - only the "No trains" banner above is shown. -
-

- Alternative Return Options -

-
-
- {alternativeInbound.map((schedule: Schedule) => - renderScheduleCard(schedule, false, true), - )} -
- */} + )}
)} diff --git a/apps/edr-passenger-web/portal/src/components/AlternativeDatesCalendar.tsx b/apps/edr-passenger-web/portal/src/components/AlternativeDatesCalendar.tsx new file mode 100644 index 000000000..2580f5b24 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/components/AlternativeDatesCalendar.tsx @@ -0,0 +1,426 @@ +'use client'; + +import { useState, useMemo, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Globe, X, Star } from 'lucide-react'; +import { + gregorianToEthiopian, + ethiopianToGregorian, + formatEthiopianDate, + getDaysInEthiopianMonth, + ETHIOPIAN_MONTHS, + type EthiopianDate, +} from '@/lib/ethiopian-calendar'; +import { format } from 'date-fns'; +import { formatFare } from '@/utils/fare-utils'; +import { Schedule } from '@/types'; + +interface AlternativeDatesCalendarProps { + // alternativeOutbound / alternativeInbound, as returned by /search — no fetching + // of its own, this component is purely presentational over data the results + // page already has in hand. + alternatives: Schedule[]; + value?: Date; + minDate?: Date; + onChange: (date: Date) => void; + label?: string; +} + +interface DayInfo { + hasAvailability: boolean; + lowestFareMinor: number | null; + displayCurrency: string | null; +} + +const toDateKey = (date: Date) => { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +}; + +export default function AlternativeDatesCalendar({ + value, + minDate, + onChange, + label, + alternatives, +}: AlternativeDatesCalendarProps) { + const [isOpen, setIsOpen] = useState(false); + const [isMobileView, setIsMobileView] = useState(false); + const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian'); + const containerRef = useRef(null); + + // Day-level availability + cheapest fare, derived once from the alternatives + // list — same "cheapest across faresByClass" logic used by the schedule cards + // on the results page. + const dayMap = useMemo(() => { + const map = new Map(); + for (const schedule of alternatives) { + if (!schedule.departureAt) continue; + const key = toDateKey(new Date(schedule.departureAt)); + const fares = (schedule.faresByClass ?? []) + .map((f) => f.displayAmountMinor ?? f.baseFareMinor) + .filter((n): n is number => !!n && n > 0); + const lowestFareMinor = fares.length ? Math.min(...fares) : null; + const displayCurrency = schedule.faresByClass?.[0]?.displayCurrency ?? null; + const hasAvailability = !!schedule.hasAvailability; + + const existing = map.get(key); + if (!existing) { + map.set(key, { hasAvailability, lowestFareMinor: hasAvailability ? lowestFareMinor : null, displayCurrency: hasAvailability ? displayCurrency : null }); + } else { + existing.hasAvailability = existing.hasAvailability || hasAvailability; + if (hasAvailability && lowestFareMinor !== null && (existing.lowestFareMinor === null || lowestFareMinor < existing.lowestFareMinor)) { + existing.lowestFareMinor = lowestFareMinor; + existing.displayCurrency = displayCurrency; + } + } + } + return map; + }, [alternatives]); + + const availableDateKeys = useMemo( + () => Array.from(dayMap.entries()).filter(([, info]) => info.hasAvailability).map(([key]) => key), + [dayMap], + ); + + const bestPriceDateKeys = useMemo(() => { + const fares = availableDateKeys + .map((key) => dayMap.get(key)!.lowestFareMinor) + .filter((n): n is number => n !== null); + if (fares.length === 0) return new Set(); + const min = Math.min(...fares); + return new Set(availableDateKeys.filter((key) => dayMap.get(key)!.lowestFareMinor === min)); + }, [availableDateKeys, dayMap]); + + // Pick the initial month to show: the requested date's month if it actually has + // data, otherwise the month of whichever available date is closest to it — the + // alternatives list has no day-window bound, so it can easily land in a + // different month than the one originally searched. + const initialFocusDate = useMemo(() => { + const base = value ?? new Date(); + const baseMonthKey = `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, '0')}`; + if (availableDateKeys.some((key) => key.startsWith(baseMonthKey))) return base; + + let nearest: string | null = null; + let nearestDiff = Infinity; + for (const key of availableDateKeys) { + const diff = Math.abs(new Date(`${key}T00:00:00`).getTime() - base.getTime()); + if (diff < nearestDiff) { nearestDiff = diff; nearest = key; } + } + return nearest ? new Date(`${nearest}T00:00:00`) : base; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [viewMonth, setViewMonth] = useState(initialFocusDate.getMonth()); + const [viewYear, setViewYear] = useState(initialFocusDate.getFullYear()); + + const initialEthDate = gregorianToEthiopian(initialFocusDate); + const [ethViewMonth, setEthViewMonth] = useState(initialEthDate.month); + const [ethViewYear, setEthViewYear] = useState(initialEthDate.year); + + useEffect(() => { + const check = () => setIsMobileView(window.innerWidth < 768); + check(); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, []); + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { document.body.style.overflow = ''; }; + }, [isOpen]); + + const toggleCalendarType = () => { + const newType = calendarType === 'gregorian' ? 'ethiopian' : 'gregorian'; + const ref = value || new Date(); + if (newType === 'ethiopian') { + const ethDate = gregorianToEthiopian(ref); + setEthViewMonth(ethDate.month); + setEthViewYear(ethDate.year); + } else { + setViewMonth(ref.getMonth()); + setViewYear(ref.getFullYear()); + } + setCalendarType(newType); + }; + + const handleDateSelect = (date: Date) => { + onChange(date); + setIsOpen(false); + }; + + const handleEthiopianDateSelect = (ethDate: EthiopianDate) => { + handleDateSelect(ethiopianToGregorian(ethDate)); + }; + + const isBeforeMin = (date: Date) => + !!minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate()); + + const renderGregorianCalendar = () => { + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); + const firstDay = new Date(viewYear, viewMonth, 1).getDay(); + const days: (number | null)[] = Array(firstDay).fill(null); + for (let d = 1; d <= daysInMonth; d++) days.push(d); + const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']; + + return ( +
+
+ + {monthNames[viewMonth]} {viewYear} + +
+
+ {['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => ( +
{d}
+ ))} +
+
+ {days.map((day, i) => { + if (day === null) return
; + const date = new Date(viewYear, viewMonth, day); + const key = toDateKey(date); + const dayInfo = dayMap.get(key); + const isSelected = value && date.getDate() === value.getDate() && date.getMonth() === value.getMonth() && date.getFullYear() === value.getFullYear(); + const isToday = date.toDateString() === new Date().toDateString(); + const isDisabled = isBeforeMin(date) || !dayInfo?.hasAvailability; + const isBestPrice = bestPriceDateKeys.has(key); + return ( + + ); + })} +
+
+ ); + }; + + const renderEthiopianCalendar = () => { + const daysInMonth = getDaysInEthiopianMonth(ethViewYear, ethViewMonth); + const firstDate = ethiopianToGregorian({ year: ethViewYear, month: ethViewMonth, day: 1 }); + const firstDayOfWeek = firstDate.getDay(); + const daysWithOffset: (number | null)[] = Array(firstDayOfWeek).fill(null); + for (let d = 1; d <= daysInMonth; d++) daysWithOffset.push(d); + const monthName = ETHIOPIAN_MONTHS[ethViewMonth - 1] || `Month ${ethViewMonth}`; + + return ( +
+
+ + {monthName} {ethViewYear} + +
+
+ {['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => ( +
{d}
+ ))} +
+
+ {daysWithOffset.map((day, i) => { + if (day === null) return
; + const ethDate: EthiopianDate = { year: ethViewYear, month: ethViewMonth, day }; + const gregDate = ethiopianToGregorian(ethDate); + const key = toDateKey(gregDate); + const dayInfo = dayMap.get(key); + const isSelected = value && gregDate.getDate() === value.getDate() && gregDate.getMonth() === value.getMonth() && gregDate.getFullYear() === value.getFullYear(); + const todayEth = gregorianToEthiopian(new Date()); + const isToday = ethDate.day === todayEth.day && ethDate.month === todayEth.month && ethDate.year === todayEth.year; + const isDisabled = isBeforeMin(gregDate) || !dayInfo?.hasAvailability; + const isBestPrice = bestPriceDateKeys.has(key); + return ( + + ); + })} +
+
+ ); + }; + + const legend = ( +
+ + Available + + + Unavailable + + + Best price + +
+ ); + + const calendarFooter = value && ( +
+
+ Gregorian: + {format(value, 'MMMM d, yyyy')} +
+
+ Ethiopian: + {formatEthiopianDate(gregorianToEthiopian(value))} +
+
+ ); + + const modalContent = ( +
+
+
+ +

{label ? `Select ${label}` : 'Select a date'}

+
+ +
+ + {legend} + +
+
+ + +
+
+ +
+ {calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()} + {calendarFooter} +
+ + {isMobileView && ( +
+ +
+ )} +
+ ); + + return ( +
+ + + {isOpen && createPortal( + <> + {isMobileView ? ( +
+ {modalContent} +
+ ) : ( + <> +
setIsOpen(false)} /> +
+
+ {modalContent} +
+
+ + )} + + + , + document.body + )} +
+ ); +} From fa7a51741003d1aa306a7fd052205cedbc6b2723 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 16 Jul 2026 14:34:53 +0300 Subject: [PATCH 2/2] Fallback cron job for ticketing if rabbitmq disconnected. --- .../src/modules/payments/payments.module.ts | 2 +- .../src/modules/tasks/tasks.module.ts | 3 +- .../src/modules/tasks/tasks.service.ts | 86 +++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 6e4be1aa0..2a3906398 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -72,6 +72,6 @@ function rabbitMQImport(): DynamicModule[] { PaymentEventsConsumer, ServiceAuthGuard, ], - exports: [PaymentClientService], + exports: [PaymentClientService, PaymentsService], }) export class PaymentsModule {} diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts index fb8a29a6e..335d11f81 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts @@ -2,10 +2,11 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../common/prisma.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { CurrencyModule } from '../currency/currency.module'; +import { PaymentsModule } from '../payments/payments.module'; import { TasksService } from './tasks.service'; @Module({ - imports: [PrismaModule, NotificationsModule, CurrencyModule], + imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule], providers: [TasksService], }) export class TasksModule {} diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 4fb3a4f0f..7a9208cae 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -3,6 +3,9 @@ import { Cron } from '@nestjs/schedule'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CurrencyService } from '../currency/currency.service'; +import { PaymentsService } from '../payments/payments.service'; +import { PaymentClientService } from '../payments/payment-client.service'; +import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types'; import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; // Retention windows @@ -28,6 +31,8 @@ export class TasksService { private readonly prisma: PrismaService, private readonly sms: SmsClientService, private readonly currencyService: CurrencyService, + private readonly paymentsService: PaymentsService, + private readonly paymentClient: PaymentClientService, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -253,6 +258,87 @@ export class TasksService { } } + // ───────────────────────────────────────────────────────────────────────── + // Every 1 min: poll the payment service for any PENDING_PAYMENT bookings + // whose payment intent has moved to SUCCEEDED on the gateway but whose + // confirmation event was never delivered (missed RabbitMQ message, network + // blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running + // it for an already-confirmed booking is safe. + // + // Processes at most 50 bookings per cycle to avoid hammering the payment + // service; the next tick picks up the remainder. + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/1 * * * *') + async syncPaymentStatuses() { + const BATCH_SIZE = 50; + + const bookings = await this.prisma.booking.findMany({ + where: { + status: 'PENDING_PAYMENT', + paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } }, + }, + include: { paymentIntent: true }, + take: BATCH_SIZE, + orderBy: { createdAt: 'asc' }, + }); + + if (bookings.length === 0) return; + + let confirmed = 0; + let failed = 0; + let errored = 0; + + for (const booking of bookings) { + if (!booking.paymentIntent) continue; + + try { + const snapshot = await this.paymentClient.getIntentByReference( + PaymentReferenceType.BOOKING, + booking.id, + ); + + if (!snapshot) continue; + + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + const result = await this.paymentsService.finalizePaymentSuccess({ + intentId: booking.paymentIntent.id, + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + }); + if (!result.alreadyFinalized) { + this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`); + confirmed++; + } + } else if ( + snapshot.status === ProviderPaymentStatus.FAILED || + snapshot.status === ProviderPaymentStatus.CANCELLED + ) { + // The payment deadline enforcer will cancel the booking when its + // window expires; log now so operations can see failed intents early. + this.logger.warn( + `Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` + + `booking will be auto-cancelled at payment deadline`, + ); + failed++; + } + // REQUIRES_ACTION / PROCESSING → still pending, retry next cycle + } catch (err) { + this.logger.error( + `Payment sync error for ${booking.bookingRef}: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + errored++; + } + } + + if (confirmed > 0 || failed > 0 || errored > 0) { + this.logger.log( + `Payment sync run: ${bookings.length} checked, ` + + `${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`, + ); + } + } + // ───────────────────────────────────────────────────────────────────────── // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. // ─────────────────────────────────────────────────────────────────────────