PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / pages / ScheduledPayments.tsx

ScheduledPayments.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.16, at resources/js/pages/ScheduledPayments.tsx

733 lines 24.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Scheduled Payments Page
3 *
4 * Read-only view of the balance payments Yatra has scheduled for bookings,
5 * plus the ability to cancel one that has not run yet.
6 *
7 * Premium module for collecting a booking's remaining balance automatically.
8 * This page is the UI shell in the free plugin; every endpoint it calls is
9 * registered by the Yatra Pro "Scheduled Payments" module and only exists
10 * while that module is enabled — hence the module gate below.
11 *
12 * @package Yatra
13 */
14
15 import React, { useState, useMemo, useEffect } from "react";
16 import { useQuery, useQueryClient } from "@tanstack/react-query";
17 import {
18 CalendarClock,
19 Ban,
20 Link as LinkIcon,
21 CreditCard,
22 AlertCircle,
23 } from "lucide-react";
24 import { __, sprintf } from "../lib/i18n";
25 import { apiService } from "../lib/api-client";
26 import { formatDate, formatDateTime } from "../lib/dateFormat";
27 import { formatYatraMoney } from "../lib/currency-display";
28 import { usePermissions } from "../hooks/usePermissions";
29 import { useNavigate } from "../hooks/useNavigate";
30 import { useToast } from "../components/ui/toast";
31 import { Badge } from "../components/ui/badge";
32 import { Button } from "../components/ui/button";
33 import { PageHeader } from "../components/common/PageHeader";
34 import { Modal } from "../components/ui/modal";
35 import {
36 Pagination,
37 SearchFilterToolbar,
38 Table as SharedTable,
39 } from "../components/shared";
40 import { isModuleActive } from "../lib/plugin-utils";
41 import PremiumUpgradeCard from "./premium-pages/ScheduledPayments";
42
43 interface ScheduledPayment {
44 id: number;
45 booking_id: number;
46 booking_reference: string;
47 booking_status: string;
48 booking_exists: boolean;
49 customer_name: string;
50 customer_email: string;
51 amount: number;
52 currency: string;
53 scheduled_date: string;
54 status: string;
55 payment_type: string;
56 collection_method: "auto" | "link";
57 gateway: string;
58 attempt_count: number;
59 max_attempts: number;
60 last_attempt_at: string;
61 last_error: string;
62 reminder_sent: boolean;
63 paid_at: string;
64 installment_number: number | null;
65 created_at: string;
66 }
67
68 /** A balance still owed on a live booking, with nothing scheduled to collect it. */
69 interface OutstandingBalance {
70 booking_id: number;
71 booking_reference: string;
72 booking_status: string;
73 customer_name: string;
74 customer_email: string;
75 currency: string;
76 total_amount: number;
77 amount_paid: number;
78 amount_due: number;
79 tour_start: string;
80 days_to_tour: number | null;
81 payment_gateway: string;
82 payment_method: string;
83 not_scheduled_reason: string;
84 }
85
86 const PER_PAGE = 20;
87
88 const ScheduledPayments: React.FC = () => {
89 const [searchTerm, setSearchTerm] = useState("");
90 const [statusFilter, setStatusFilter] = useState("all");
91 const [page, setPage] = useState(1);
92 const [toCancel, setToCancel] = useState<ScheduledPayment | null>(null);
93 const [cancelling, setCancelling] = useState(false);
94 // "scheduled" = automated collections; "outstanding" = money still owed that
95 // nothing is collecting. Operators reach for the second far more often.
96 const [view, setView] = useState<"scheduled" | "outstanding">("scheduled");
97 const [sendingLinkFor, setSendingLinkFor] = useState<number | null>(null);
98
99 const { can } = usePermissions();
100 const { navigate } = useNavigate();
101 const { showToast } = useToast();
102 const queryClient = useQueryClient();
103
104 // Held in state, not read inline, so switching the module on or off in
105 // Yatra → Modules updates this page immediately. The Modules screen refreshes
106 // `window.yatraAdmin.scheduledPaymentsEnabled` and fires these events; without
107 // listening, the page would keep its previous shape until a reload.
108 const [moduleAvailable, setModuleAvailable] = useState<boolean>(() =>
109 isModuleActive("scheduled_payments"),
110 );
111
112 useEffect(() => {
113 const sync = () => setModuleAvailable(isModuleActive("scheduled_payments"));
114
115 window.addEventListener("yatra-modules-updated", sync);
116 window.addEventListener("yatra-force-nav-refresh", sync);
117
118 return () => {
119 window.removeEventListener("yatra-modules-updated", sync);
120 window.removeEventListener("yatra-force-nav-refresh", sync);
121 };
122 }, []);
123 // The page sits under Payments in the menu, so a finance role that can see
124 // Payments can open it — the endpoints accept the same pair of capabilities.
125 const canView =
126 can("yatra_view_bookings") || can("yatra_view_financial_reports");
127
128 const queryParams = useMemo(
129 () => ({
130 page,
131 per_page: PER_PAGE,
132 ...(searchTerm ? { search: searchTerm } : {}),
133 ...(statusFilter !== "all" ? { status: statusFilter } : {}),
134 }),
135 [page, searchTerm, statusFilter],
136 );
137
138 const { data, isLoading, error, refetch } = useQuery({
139 queryKey: ["scheduled-payments", queryParams],
140 queryFn: async () => await apiService.getScheduledPayments(queryParams),
141 enabled: moduleAvailable && canView && view === "scheduled",
142 });
143
144 const outstandingParams = useMemo(
145 () => ({
146 page,
147 per_page: PER_PAGE,
148 ...(searchTerm ? { search: searchTerm } : {}),
149 }),
150 [page, searchTerm],
151 );
152
153 const {
154 data: outstandingData,
155 isLoading: outstandingLoading,
156 error: outstandingError,
157 refetch: refetchOutstanding,
158 } = useQuery({
159 queryKey: ["scheduled-payments-outstanding", outstandingParams],
160 queryFn: async () =>
161 await apiService.getOutstandingBalances(outstandingParams),
162 enabled: moduleAvailable && canView && view === "outstanding",
163 });
164
165 const { data: statsRaw } = useQuery({
166 queryKey: ["scheduled-payments-stats"],
167 queryFn: async () => await apiService.getScheduledPaymentsStats(),
168 enabled: moduleAvailable && canView,
169 });
170
171 // Endpoints answer { success, data, meta } and the API client returns the raw
172 // body, so read through `data` rather than the top level.
173 const isOutstandingView = view === "outstanding";
174 const response: any = (isOutstandingView ? outstandingData : data) ?? {};
175 const payments: ScheduledPayment[] = isOutstandingView
176 ? []
177 : (response?.data ?? []);
178 const outstanding: OutstandingBalance[] = isOutstandingView
179 ? (response?.data ?? [])
180 : [];
181 const total: number = Number(response?.meta?.total ?? 0) || 0;
182 const totalPages: number = Number(response?.meta?.total_pages ?? 0) || 0;
183 const listLoading = isOutstandingView ? outstandingLoading : isLoading;
184 const listError = isOutstandingView ? outstandingError : error;
185 const reload = isOutstandingView ? refetchOutstanding : refetch;
186
187 const stats: Record<string, number> = (statsRaw as any)?.data ?? {};
188 const statusCount = (key: string) => Number(stats?.[key] ?? 0) || 0;
189 // Counts bookings, not schedule rows, so the endpoint returns it beside the
190 // status map rather than inside it (where it would break "all = sum").
191 const outstandingCount = Number((statsRaw as any)?.outstanding ?? 0) || 0;
192
193 const formatMoney = (amount: number, currency: string) =>
194 formatYatraMoney(
195 Number(amount) || 0,
196 currency || (window as any)?.yatraAdmin?.currency || "USD",
197 { zeroAsUnknown: false },
198 );
199
200 const getStatusBadge = (status: string) => {
201 const map: Record<string, { className: string; label: string }> = {
202 pending: {
203 className:
204 "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400",
205 label: __("Scheduled", "yatra"),
206 },
207 processing: {
208 className:
209 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
210 label: __("Processing", "yatra"),
211 },
212 completed: {
213 className:
214 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
215 label: __("Paid", "yatra"),
216 },
217 failed: {
218 className:
219 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
220 label: __("Failed", "yatra"),
221 },
222 cancelled: {
223 className:
224 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
225 label: __("Cancelled", "yatra"),
226 },
227 };
228 const info = map[status] || {
229 className:
230 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
231 label: status,
232 };
233
234 return <Badge className={`text-xs ${info.className}`}>{info.label}</Badge>;
235 };
236
237 const columns = [
238 {
239 key: "booking",
240 label: __("Booking", "yatra"),
241 visible: true,
242 render: (item: ScheduledPayment) => (
243 <div className="flex flex-col">
244 <span className="font-medium text-gray-900 dark:text-white">
245 {item.booking_reference ||
246 /* translators: %d: booking ID. */
247 __("Booking #", "yatra") + item.booking_id}
248 </span>
249 {item.customer_name ? (
250 <span className="text-xs text-gray-500 dark:text-gray-400">
251 {item.customer_name}
252 </span>
253 ) : null}
254 {!item.booking_exists ? (
255 <span className="text-xs text-red-600 dark:text-red-400">
256 {__("Booking no longer exists", "yatra")}
257 </span>
258 ) : null}
259 </div>
260 ),
261 },
262 {
263 key: "amount",
264 label: __("Amount", "yatra"),
265 visible: true,
266 render: (item: ScheduledPayment) => (
267 <span className="font-medium">
268 {formatMoney(item.amount, item.currency)}
269 </span>
270 ),
271 },
272 {
273 key: "scheduled_date",
274 label: __("Scheduled for", "yatra"),
275 visible: true,
276 render: (item: ScheduledPayment) => (
277 <span className="text-sm">
278 {item.scheduled_date ? formatDateTime(item.scheduled_date) : "—"}
279 </span>
280 ),
281 },
282 {
283 key: "collection_method",
284 label: __("Collection", "yatra"),
285 visible: true,
286 render: (item: ScheduledPayment) =>
287 item.collection_method === "auto" ? (
288 <span className="inline-flex items-center gap-1.5 text-sm">
289 <CreditCard className="w-3.5 h-3.5" />
290 {__("Auto-charge", "yatra")}
291 </span>
292 ) : (
293 <span className="inline-flex items-center gap-1.5 text-sm">
294 <LinkIcon className="w-3.5 h-3.5" />
295 {__("Payment link", "yatra")}
296 </span>
297 ),
298 },
299 {
300 key: "status",
301 label: __("Status", "yatra"),
302 visible: true,
303 render: (item: ScheduledPayment) => (
304 <div className="flex flex-col gap-1">
305 {getStatusBadge(item.status)}
306 {item.status === "failed" && item.last_error ? (
307 <span
308 className="text-xs text-red-600 dark:text-red-400 truncate max-w-[220px]"
309 title={item.last_error}
310 >
311 {item.last_error}
312 </span>
313 ) : null}
314 {item.attempt_count > 0 && item.status !== "completed" ? (
315 <span className="text-xs text-gray-500 dark:text-gray-400">
316 {__("Attempts:", "yatra")} {item.attempt_count}/
317 {item.max_attempts}
318 </span>
319 ) : null}
320 </div>
321 ),
322 },
323 ];
324
325 /** Why nothing is collecting this balance — plain wording for operators. */
326 const notScheduledLabel = (reason: string): string => {
327 switch (reason) {
328 case "no_payment_yet":
329 return __("No payment received yet", "yatra");
330 case "anchored_to_booking":
331 return __("Balance due is anchored to the booking date", "yatra");
332 case "no_tour_date":
333 return __("Booking has no tour date", "yatra");
334 case "not_due_yet":
335 return __("Reminder starts closer to the tour", "yatra");
336 case "due_next_run":
337 return __("Reminder goes out on the next daily run", "yatra");
338 default:
339 return reason;
340 }
341 };
342
343 const outstandingColumns = [
344 {
345 key: "booking",
346 label: __("Booking", "yatra"),
347 visible: true,
348 render: (item: OutstandingBalance) => (
349 <div className="flex flex-col">
350 <span className="font-medium text-gray-900 dark:text-white">
351 {item.booking_reference || `#${item.booking_id}`}
352 </span>
353 {item.customer_name ? (
354 <span className="text-xs text-gray-500 dark:text-gray-400">
355 {item.customer_name}
356 </span>
357 ) : null}
358 </div>
359 ),
360 },
361 {
362 key: "amount_due",
363 label: __("Outstanding", "yatra"),
364 visible: true,
365 render: (item: OutstandingBalance) => (
366 <div className="flex flex-col">
367 <span className="font-medium">
368 {formatMoney(item.amount_due, item.currency)}
369 </span>
370 <span className="text-xs text-gray-500 dark:text-gray-400">
371 {sprintf(
372 /* translators: 1: amount already paid, 2: booking total. */
373 __("%1$s of %2$s paid", "yatra"),
374 formatMoney(item.amount_paid, item.currency),
375 formatMoney(item.total_amount, item.currency),
376 )}
377 </span>
378 </div>
379 ),
380 },
381 {
382 key: "tour_start",
383 label: __("Tour date", "yatra"),
384 visible: true,
385 render: (item: OutstandingBalance) => (
386 <div className="flex flex-col">
387 <span className="text-sm">
388 {item.tour_start ? formatDate(item.tour_start) : "—"}
389 </span>
390 {typeof item.days_to_tour === "number" && item.days_to_tour >= 0 ? (
391 <span className="text-xs text-gray-500 dark:text-gray-400">
392 {sprintf(
393 /* translators: %d: number of days until the tour starts. */
394 __("in %d days", "yatra"),
395 item.days_to_tour,
396 )}
397 </span>
398 ) : null}
399 </div>
400 ),
401 },
402 {
403 key: "reason",
404 label: __("Not scheduled because", "yatra"),
405 visible: true,
406 render: (item: OutstandingBalance) => (
407 <span className="text-sm text-gray-600 dark:text-gray-300">
408 {notScheduledLabel(item.not_scheduled_reason)}
409 </span>
410 ),
411 },
412 ];
413
414 const sendBalanceLink = async (item: OutstandingBalance) => {
415 if (sendingLinkFor !== null) {
416 return;
417 }
418 setSendingLinkFor(item.booking_id);
419 try {
420 await apiService.sendBalancePaymentLink(item.booking_id);
421 queryClient.invalidateQueries({
422 queryKey: ["scheduled-payments-outstanding"],
423 });
424 queryClient.invalidateQueries({ queryKey: ["scheduled-payments"] });
425 queryClient.invalidateQueries({ queryKey: ["scheduled-payments-stats"] });
426 showToast(__("Balance payment link sent.", "yatra"), "success");
427 } catch (err: any) {
428 showToast(
429 err?.message || __("Failed to send the payment link.", "yatra"),
430 "error",
431 );
432 } finally {
433 setSendingLinkFor(null);
434 }
435 };
436
437 const outstandingActions = [
438 {
439 key: "send_link",
440 label: __("Send balance payment link", "yatra"),
441 icon: <LinkIcon className="w-4 h-4" />,
442 onClick: (item: OutstandingBalance) => sendBalanceLink(item),
443 // Emails the customer a secure link; needs the same capability as
444 // changing a booking, and only makes sense once money is owed.
445 condition: (item: OutstandingBalance) =>
446 can("yatra_edit_bookings") && item.customer_email !== "",
447 },
448 {
449 key: "view_booking",
450 label: __("View booking", "yatra"),
451 icon: <CalendarClock className="w-4 h-4" />,
452 onClick: (item: OutstandingBalance) =>
453 navigate({
454 subpage: "bookings",
455 action: "view",
456 id: item.booking_id,
457 }),
458 condition: () => can("yatra_view_bookings"),
459 },
460 ];
461
462 const actions = [
463 {
464 key: "view_booking",
465 label: __("View booking", "yatra"),
466 icon: <CalendarClock className="w-4 h-4" />,
467 onClick: (item: ScheduledPayment) =>
468 navigate({
469 subpage: "bookings",
470 action: "view",
471 id: item.booking_id,
472 }),
473 condition: (item: ScheduledPayment) =>
474 can("yatra_view_bookings") && item.booking_exists,
475 },
476 {
477 key: "cancel",
478 label: __("Cancel scheduled payment", "yatra"),
479 icon: <Ban className="w-4 h-4" />,
480 onClick: (item: ScheduledPayment) => setToCancel(item),
481 variant: "destructive" as const,
482 // Only a row that has not run yet can be cancelled — the endpoint
483 // enforces the same rule.
484 condition: (item: ScheduledPayment) =>
485 can("yatra_edit_bookings") &&
486 (item.status === "pending" || item.status === "processing"),
487 },
488 ];
489
490 const confirmCancel = async () => {
491 if (!toCancel) {
492 return;
493 }
494 setCancelling(true);
495 try {
496 await apiService.cancelScheduledPayment(toCancel.id);
497 queryClient.invalidateQueries({ queryKey: ["scheduled-payments"] });
498 queryClient.invalidateQueries({ queryKey: ["scheduled-payments-stats"] });
499 showToast(__("Scheduled payment cancelled.", "yatra"), "success");
500 setToCancel(null);
501 } catch (err: any) {
502 showToast(
503 err?.message || __("Failed to cancel scheduled payment.", "yatra"),
504 "error",
505 );
506 } finally {
507 setCancelling(false);
508 }
509 };
510
511 // Module gate lives after the hooks so every hook runs on every render.
512 if (!moduleAvailable) {
513 return <PremiumUpgradeCard />;
514 }
515
516 return (
517 <div className="space-y-6">
518 <PageHeader
519 title={__("Scheduled Payments", "yatra")}
520 description={
521 isOutstandingView
522 ? __(
523 "Money still owed on live bookings that nothing is scheduled to collect.",
524 "yatra",
525 )
526 : __(
527 "Balance payments Yatra has scheduled for bookings that were paid with a deposit.",
528 "yatra",
529 )
530 }
531 />
532
533 {/* Two halves of the same question: what Yatra will collect on its own,
534 and what is still owed with nothing collecting it. */}
535 <div className="flex items-center gap-2">
536 {(
537 [
538 ["scheduled", __("Scheduled", "yatra"), statusCount("all")],
539 ["outstanding", __("Outstanding", "yatra"), outstandingCount],
540 ] as const
541 ).map(([key, label, count]) => (
542 <button
543 key={key}
544 type="button"
545 onClick={() => {
546 setView(key as "scheduled" | "outstanding");
547 setPage(1);
548 }}
549 className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
550 view === key
551 ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium"
552 : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
553 }`}
554 >
555 {label}
556 <span className="ml-1.5 text-xs opacity-70">{count}</span>
557 </button>
558 ))}
559 </div>
560
561 <SearchFilterToolbar
562 searchTerm={searchTerm}
563 onSearchChange={(term) => {
564 setSearchTerm(term);
565 setPage(1);
566 }}
567 statusFilter={statusFilter}
568 onStatusChange={(value) => {
569 setStatusFilter(value);
570 setPage(1);
571 }}
572 // Status belongs to schedule rows; an outstanding balance has none, so
573 // the filter collapses to a single option in that view.
574 statusOptions={
575 isOutstandingView
576 ? [{ value: "all", label: __("All", "yatra") }]
577 : [
578 {
579 value: "all",
580 label: __("All", "yatra") + ` (${statusCount("all")})`,
581 },
582 {
583 value: "pending",
584 label:
585 __("Scheduled", "yatra") + ` (${statusCount("pending")})`,
586 },
587 {
588 value: "completed",
589 label: __("Paid", "yatra") + ` (${statusCount("completed")})`,
590 },
591 {
592 value: "failed",
593 label: __("Failed", "yatra") + ` (${statusCount("failed")})`,
594 },
595 {
596 value: "cancelled",
597 label:
598 __("Cancelled", "yatra") + ` (${statusCount("cancelled")})`,
599 },
600 ]
601 }
602 sortBy="scheduled_date"
603 onSortByChange={() => undefined}
604 sortOrder="desc"
605 onSortOrderChange={() => undefined}
606 sortOptions={[
607 { value: "scheduled_date", label: __("Scheduled date", "yatra") },
608 ]}
609 onResetFilters={() => {
610 setSearchTerm("");
611 setStatusFilter("all");
612 setPage(1);
613 }}
614 hasFilters={
615 searchTerm !== "" || (!isOutstandingView && statusFilter !== "all")
616 }
617 placeholder={__("Search by booking reference or customer…", "yatra")}
618 />
619
620 <SharedTable
621 data={isOutstandingView ? outstanding : payments}
622 columns={isOutstandingView ? outstandingColumns : columns}
623 actions={isOutstandingView ? outstandingActions : actions}
624 isLoading={listLoading}
625 isError={!!listError}
626 errorText={
627 listError
628 ? __("Could not load scheduled payments.", "yatra")
629 : undefined
630 }
631 onRetry={() => reload()}
632 emptyText={
633 isOutstandingView
634 ? __("No outstanding balances", "yatra")
635 : __("No scheduled payments yet", "yatra")
636 }
637 // Says *why* it is empty: operators reasonably expect every deposit
638 // booking to appear here, and the two conditions below are what
639 // actually create a row.
640 emptyDescription={
641 isOutstandingView
642 ? __(
643 "Every confirmed and pending booking is paid up, or already has a scheduled payment collecting the rest.",
644 "yatra",
645 )
646 : __(
647 "Yatra schedules a balance payment when a deposit is paid with a card it can charge again later, or — if Balance due is anchored to the tour date — when it emails a payment link. Deposits paid by bank transfer, Pay Later or recorded by hand don't create one: check the Outstanding tab for those.",
648 "yatra",
649 )
650 }
651 getItemId={(item: ScheduledPayment | OutstandingBalance) =>
652 isOutstandingView
653 ? (item as OutstandingBalance).booking_id
654 : (item as ScheduledPayment).id
655 }
656 />
657
658 {totalPages > 1 ? (
659 <Pagination
660 currentPage={page}
661 totalPages={totalPages}
662 totalItems={total}
663 itemsPerPage={PER_PAGE}
664 onPageChange={setPage}
665 itemName={__("scheduled payments", "yatra")}
666 />
667 ) : null}
668
669 <Modal
670 isOpen={toCancel !== null}
671 onClose={() => {
672 if (!cancelling) {
673 setToCancel(null);
674 }
675 }}
676 title={__("Cancel scheduled payment?", "yatra")}
677 description={__(
678 "Yatra will not collect this balance automatically. The booking keeps its outstanding balance, which you can still collect manually.",
679 "yatra",
680 )}
681 size="sm"
682 panelClassName="yatra-model-ui"
683 footer={
684 <div className="flex gap-2 justify-end">
685 <Button
686 variant="outline"
687 onClick={() => setToCancel(null)}
688 disabled={cancelling}
689 >
690 {__("Keep it", "yatra")}
691 </Button>
692 <Button
693 variant="destructive"
694 onClick={confirmCancel}
695 disabled={cancelling}
696 >
697 {cancelling
698 ? __("Cancelling…", "yatra")
699 : __("Cancel payment", "yatra")}
700 </Button>
701 </div>
702 }
703 >
704 {toCancel ? (
705 <div className="space-y-2">
706 <div className="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-300">
707 <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
708 <span>
709 {formatMoney(toCancel.amount, toCancel.currency)}
710 {toCancel.booking_reference
711 ? ` — ${toCancel.booking_reference}`
712 : ""}
713 </span>
714 </div>
715 {toCancel.status === "processing" ? (
716 // Honest about the race: the charge was already handed to the
717 // gateway, and if it succeeds the row returns to Paid.
718 <p className="text-xs text-amber-700 dark:text-amber-400">
719 {__(
720 "This charge has already started. If the gateway completes it, the payment will still go through and this row will show as Paid.",
721 "yatra",
722 )}
723 </p>
724 ) : null}
725 </div>
726 ) : null}
727 </Modal>
728 </div>
729 );
730 };
731
732 export default ScheduledPayments;
733