From 0babeffb3b8d82563d83b211aac9c4cc5beb4c50 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Thu, 27 Aug 2026 23:09:23 +0530 Subject: [PATCH] feat(booking): acknowledge offline bookings before verification (#388) * feat(booking): acknowledge offline bookings before verification An offline booking is a draft until an Event Manager verifies the payment, but nothing said so. The booker got no email at all until approval, and a guest was shown the generic success panel claiming their tickets had already been sent. Send an acknowledgement when the booking is created: booking reference, event and participant details, the offline method and amount, and a clear statement that the tickets are not confirmed yet and a separate confirmation will follow. The wording is overridable per event via a new Offline Payment Acknowledgement Email Template field; the existing "Send Booking Confirmation Email" check gates both booking emails, so there is no second toggle to keep in sync. The confirmation email's recipient resolution, argument building and send now live in shared methods that both emails call, so the two cannot drift. For guests, the inline panel gains a pending variant carrying the booking reference. Logged-in users already landed on the "Payment Confirmation Pending" booking page and are unchanged, as is everything after approval. Fixes #387 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Gs8LCCtLo5gVEbxaJURWuN * fix(booking): show ticket type titles in booking emails Ticket types autoname to integers, but a Link field hands the value back as a string, so the title map built from `get_all` never matched and every attendee row printed the ticket type's id where its title belongs. Cast both sides. Caught while rendering the new offline acknowledgement against a real booking; the booking confirmation email has printed ids since it shipped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Gs8LCCtLo5gVEbxaJURWuN * chore(booking): tidy offline acknowledgement after review Regenerate Buzz Event's type annotations, which had drifted since the booking confirmation fields landed. Drop a dead branch in the acknowledgement template: a zero-total booking takes the free path, so an offline one is never a free event. Cover the gateway path, which must stay silent until the payment lands. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Gs8LCCtLo5gVEbxaJURWuN --------- Co-authored-by: Claude Opus 5 (cherry picked from commit 013589655dda0705ee5b922e617ed9ada4f34882) --- buzz/api/booking/services.py | 13 + buzz/api/booking/test_booking.py | 49 ++++ .../events/doctype/buzz_event/buzz_event.json | 13 +- buzz/events/doctype/buzz_event/buzz_event.py | 3 + .../offline_booking_acknowledgement.html | 276 ++++++++++++++++++ .../doctype/event_booking/event_booking.py | 103 ++++--- .../event_booking/test_event_booking.py | 165 +++++++++++ dashboard/src/components/BookingForm.vue | 43 ++- .../src/utils/bookingSuccessRedirect.test.ts | 18 +- dashboard/src/utils/bookingSuccessRedirect.ts | 6 +- 10 files changed, 645 insertions(+), 44 deletions(-) create mode 100644 buzz/templates/emails/offline_booking_acknowledgement.html diff --git a/buzz/api/booking/services.py b/buzz/api/booking/services.py index bc860ca8..48889e66 100644 --- a/buzz/api/booking/services.py +++ b/buzz/api/booking/services.py @@ -242,8 +242,21 @@ def offline_booking_response(self, booking: "EventBooking") -> OfflineBookingRes booking.flags.ignore_permissions = True booking.save() self.attach_payment_proof(booking) + self.acknowledge_offline_booking(booking) return OfflineBookingResponse(booking_name=booking.name, offline_payment=True) + def acknowledge_offline_booking(self, booking: "EventBooking") -> None: + """The booking is only a draft until an approval verifies the payment, so this + acknowledgement is the booker's only receipt until then.""" + try: + booking.send_offline_acknowledgement_email() + except Exception: + frappe.log_error( + title="Offline booking acknowledgement email failed", + reference_doctype=booking.doctype, + reference_name=booking.name, + ) + def offline_method(self) -> dict: filters = {"event": self.request.event, "enabled": 1} if self.request.offline_payment_method: diff --git a/buzz/api/booking/test_booking.py b/buzz/api/booking/test_booking.py index e243a831..ed539a4d 100644 --- a/buzz/api/booking/test_booking.py +++ b/buzz/api/booking/test_booking.py @@ -256,6 +256,55 @@ def test_offline_booking_awaits_approval(self): self.assertEqual(booking.status, "Approval Pending") self.assertEqual(booking.payment_status, "Verification Pending") + def test_gateway_booking_is_not_acknowledged(self): + """The acknowledgement belongs to the offline path only: a gateway booking is + still unpaid at this point and gets its confirmation after the payment lands.""" + request = self.make_paid_request() + + with ( + patch("buzz.api.booking.services.get_payment_link_for_booking", return_value="/pay"), + patch("frappe.sendmail") as sendmail, + ): + process_booking(request) + + sendmail.assert_not_called() + + def test_offline_booking_acknowledges_then_confirms(self): + """Offline is a two-stage conversation: an acknowledgement while the payment is + unverified, the existing confirmation only once an approval submits the booking.""" + self.set_event({"send_ticket_email": 0}) + if not frappe.db.exists("User", BOOKER): + frappe.get_doc( + {"doctype": "User", "email": BOOKER, "first_name": "Booking", "send_welcome_email": 0} + ).insert(ignore_permissions=True) + frappe.get_doc( + { + "doctype": "Offline Payment Method", + "event": self.event.name, + "title": f"Bank Transfer {frappe.generate_hash(length=6)}", + "enabled": 1, + } + ).insert(ignore_permissions=True) + request = self.make_paid_request(is_offline=True) + + frappe.set_user(BOOKER) + self.addCleanup(frappe.set_user, "Administrator") + + with patch("frappe.sendmail") as sendmail: + booking_name = process_booking(request).booking_name + + sendmail.assert_called_once() + self.assertEqual(sendmail.call_args[1]["template"], "offline_booking_acknowledgement") + self.assertIn(BOOKER, sendmail.call_args[1]["recipients"]) + self.assertFalse(frappe.db.exists("Event Ticket", {"booking": booking_name})) + + frappe.set_user("Administrator") + frappe.get_doc("Event Booking", booking_name).approve_booking() + + self.assertEqual(sendmail.call_args[1]["template"], "booking_confirmation") + + self.assertTrue(frappe.db.exists("Event Ticket", {"booking": booking_name})) + class TestBookingAddOnPricing(BookingTestCase): """The add-on price is server-authoritative: it comes from the Ticket Add-on catalog, diff --git a/buzz/events/doctype/buzz_event/buzz_event.json b/buzz/events/doctype/buzz_event/buzz_event.json index 14e38bb1..23e4622d 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.json +++ b/buzz/events/doctype/buzz_event/buzz_event.json @@ -75,6 +75,7 @@ "booking_confirmation_email_section", "send_booking_confirmation_email", "booking_confirmation_email_template", + "offline_acknowledgement_email_template", "talks_section", "allow_editing_talks_after_acceptance", "custom_forms_tab", @@ -288,7 +289,7 @@ }, { "default": "1", - "description": "Send a confirmation email with a booking summary to the person who made the booking.", + "description": "Send booking emails to the person who made the booking: an acknowledgement while an offline payment awaits verification, and a confirmation once the booking is confirmed.", "fieldname": "send_booking_confirmation_email", "fieldtype": "Check", "label": "Send Booking Confirmation Email" @@ -300,6 +301,14 @@ "label": "Booking Confirmation Email Template", "options": "Email Template" }, + { + "depends_on": "eval:doc.send_booking_confirmation_email;", + "description": "Sent when a booking is made with an offline payment method, before the payment is verified.", + "fieldname": "offline_acknowledgement_email_template", + "fieldtype": "Link", + "label": "Offline Payment Acknowledgement Email Template", + "options": "Email Template" + }, { "fieldname": "customisations_tab", "fieldtype": "Tab Break", @@ -602,7 +611,7 @@ "link_fieldname": "event" } ], - "modified": "2026-07-23 12:00:00.000000", + "modified": "2026-08-27 12:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Buzz Event", diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index 3761e152..450f5788 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -48,6 +48,7 @@ class BuzzEvent(Document): attach_email_ticket: DF.Check auto_send_pitch_deck: DF.Check banner_image: DF.AttachImage | None + booking_confirmation_email_template: DF.Link | None card_image: DF.AttachImage | None category: DF.Link custom_forms: DF.Table[BuzzEventForm] @@ -63,12 +64,14 @@ class BuzzEvent(Document): medium: DF.Literal["In Person", "Online"] meta_image: DF.AttachImage | None name: DF.Int | None + offline_acknowledgement_email_template: DF.Link | None payment_gateways: DF.Table[EventPaymentGateway] proposal: DF.Link | None registration_url: DF.Data | None registrations_close_at: DF.Datetime | None route: DF.Data | None schedule: DF.Table[ScheduleItem] + send_booking_confirmation_email: DF.Check send_ticket_email: DF.Check short_description: DF.SmallText | None show_sponsorship_section: DF.Check diff --git a/buzz/templates/emails/offline_booking_acknowledgement.html b/buzz/templates/emails/offline_booking_acknowledgement.html new file mode 100644 index 00000000..771adace --- /dev/null +++ b/buzz/templates/emails/offline_booking_acknowledgement.html @@ -0,0 +1,276 @@ + + + + + + + +
+ We received your booking for {{ event_title }}. Your payment is awaiting verification. +
+ + + + + + +
+ + + + + + + +
+

+ โณ Payment Verification Pending +

+

+ We have received your booking and offline payment details. +

+
+ + + + + + + + +
+

+ Your payment is currently awaiting verification, so your tickets are + not confirmed yet. You will receive a separate confirmation + email once the verification is completed. +

+
+ + + + + + + + +
+

+ {{ event_title }} +

+

+ ๐Ÿ“… {{ frappe.format_date(event_doc.start_date) }} + {% if event_doc.start_date != event_doc.end_date %} + - {{ frappe.format_date(event_doc.end_date) }} + {% endif %} + {% if venue %}  ยท  ๐Ÿ“ {{ venue }}{% endif %} +

+

+ Booking ID: + {{ doc.name }} + {% if doc.offline_payment_method %} +   ยท  Paid via: {{ doc.offline_payment_method }} + {% endif %} +

+
+ + + + + + + + +
+

+ Participants ({{ attendee_rows | length }}) +

+ + + {% for attendee in attendee_rows %} + + + + + {% endfor %} + +
+

+ {{ attendee.full_name }} +

+

+ {{ attendee.ticket_type_title }} + {% if attendee.number_of_add_ons %} +  ยท  {{ attendee.number_of_add_ons }} add-on(s) + {% endif %} +

+
+

+ {{ frappe.utils.fmt_money(attendee.amount, currency=doc.currency) }} +

+
+
+ + + + + + + + +
+ + + + + + + {% if doc.discount_amount %} + + + + + {% endif %} + {% if doc.tax_amount %} + + + + + {% endif %} + + + + + +
Subtotal + {{ frappe.utils.fmt_money(doc.net_amount, currency=doc.currency) }} +
+ Discount{% if doc.coupon_code %} ({{ doc.coupon_code }}){% endif %} + + − {{ frappe.utils.fmt_money(doc.discount_amount, currency=doc.currency) }} +
+ {{ doc.tax_label or "Tax" }}{% if doc.tax_percentage %} ({{ doc.tax_percentage }}%){% endif %} + + {{ frappe.utils.fmt_money(doc.tax_amount, currency=doc.currency) }} +
+ Amount to verify + + {{ frappe.utils.fmt_money(doc.total_amount, currency=doc.currency) }} +
+
+ + {% if support_email %} + + + + + + +
+

+ Something look wrong? Contact our support team. +

+ Contact Support +
+ {% endif %} + + + + + + + + +
+

+ Powered by Buzz. Built on Frappe Framework. +

+
+
+ + diff --git a/buzz/ticketing/doctype/event_booking/event_booking.py b/buzz/ticketing/doctype/event_booking/event_booking.py index 50f5c3d2..c59f24b0 100644 --- a/buzz/ticketing/doctype/event_booking/event_booking.py +++ b/buzz/ticketing/doctype/event_booking/event_booking.py @@ -167,32 +167,84 @@ def on_submit(self): ) def send_booking_confirmation_email(self): - # Never email system/placeholder users โ€” they are not real recipients. - if self.user in ("Administrator", "Guest"): - return + event_doc = frappe.get_cached_doc("Buzz Event", self.event) + # Fallback to global setting if event-level not set + self.send_booking_email( + template=( + event_doc.booking_confirmation_email_template + or frappe.db.get_single_value("Buzz Settings", "default_booking_confirmation_email_template") + ), + builtin="booking_confirmation", + subject=_("Your booking for {0} is confirmed โœ…").format(event_doc.title), + ) + def send_offline_acknowledgement_email(self): + """Tell the booker their offline payment is awaiting verification. Sent while the + booking is still a draft, so the confirmation above stays the approval's job.""" event_doc = frappe.get_cached_doc("Buzz Event", self.event) - if not event_doc.send_booking_confirmation_email: - return + self.send_booking_email( + template=event_doc.offline_acknowledgement_email_template, + builtin="offline_booking_acknowledgement", + subject=_("We received your booking for {0} โ€” payment verification pending").format( + event_doc.title + ), + ) - recipient = frappe.db.get_value("User", self.user, "email") or self.user + def send_booking_email(self, template: str | None, builtin: str, subject: str) -> None: + recipient = self.get_booking_email_recipient() if not recipient: return - # Fallback to global setting if event-level not set - booking_template = event_doc.booking_confirmation_email_template or frappe.db.get_single_value( - "Buzz Settings", "default_booking_confirmation_email_template" + args = self.get_booking_email_args() + + content = None + if template: + email_template = render_email_template(template, args) + subject = email_template.get("subject") or subject + content = email_template.get("message") + + frappe.sendmail( + recipients=[recipient], + subject=subject, + content=content, + template=None if template else builtin, + args=args, + reference_doctype=self.doctype, + reference_name=self.name, ) - subject = _("Your booking for {0} is confirmed โœ…").format(event_doc.title) + def get_booking_email_recipient(self) -> str | None: + """Who to email about this booking, or None when nobody should be emailed.""" + # Never email system/placeholder users โ€” they are not real recipients. + if self.user in ("Administrator", "Guest"): + return None + + if not frappe.get_cached_value("Buzz Event", self.event, "send_booking_confirmation_email"): + return None + + return frappe.db.get_value("User", self.user, "email") or self.user + + def get_booking_email_args(self) -> dict: + event_doc = frappe.get_cached_doc("Buzz Event", self.event) + return { + "doc": self, + "event_doc": event_doc, + "event_title": event_doc.title, + "venue": event_doc.venue, + "attendee_rows": self.get_attendee_email_rows(), + "support_email": frappe.db.get_single_value("Buzz Settings", "support_email"), + } + def get_attendee_email_rows(self) -> list[dict]: # Pre-fetch ticket type titles in a single query so the email template # loop stays a pure display operation (no per-attendee DB round-trips). ticket_type_names = list({attendee.ticket_type for attendee in self.attendees}) ticket_type_titles = {} if ticket_type_names: + # Ticket types autoname to integers but arrive off the attendee row as + # strings, so both sides of the lookup are cast before they are compared. ticket_type_titles = { - row.name: row.title + str(row.name): row.title for row in frappe.get_all( "Event Ticket Type", filters={"name": ["in", ticket_type_names]}, @@ -200,42 +252,17 @@ def send_booking_confirmation_email(self): ) } - attendee_rows = [ + return [ { "full_name": attendee.full_name or " ".join(part for part in (attendee.first_name, attendee.last_name) if part), - "ticket_type_title": ticket_type_titles.get(attendee.ticket_type, attendee.ticket_type), + "ticket_type_title": ticket_type_titles.get(str(attendee.ticket_type), attendee.ticket_type), "number_of_add_ons": attendee.number_of_add_ons, "amount": (attendee.amount or 0) + (attendee.add_on_total or 0), } for attendee in self.attendees ] - args = { - "doc": self, - "event_doc": event_doc, - "event_title": event_doc.title, - "venue": event_doc.venue, - "attendee_rows": attendee_rows, - "support_email": frappe.db.get_single_value("Buzz Settings", "support_email"), - } - - content = None - if booking_template: - email_template = render_email_template(booking_template, args) - subject = email_template.get("subject") or subject - content = email_template.get("message") - - frappe.sendmail( - recipients=[recipient], - subject=subject, - content=content, - template=None if booking_template else "booking_confirmation", - args=args, - reference_doctype=self.doctype, - reference_name=self.name, - ) - def validate_coupon_availability(self): """Re-validate coupon with lock to prevent race condition.""" if not self.coupon_code: diff --git a/buzz/ticketing/doctype/event_booking/test_event_booking.py b/buzz/ticketing/doctype/event_booking/test_event_booking.py index beb047fd..7a3c52c6 100644 --- a/buzz/ticketing/doctype/event_booking/test_event_booking.py +++ b/buzz/ticketing/doctype/event_booking/test_event_booking.py @@ -1377,6 +1377,171 @@ def test_event_template_takes_precedence_over_global(self, mock_sendmail): self.assertNotIn("GLOBAL", mock_sendmail.call_args[1]["subject"]) +class TestOfflineAcknowledgementEmail(IntegrationTestCase): + """Acknowledgement sent when an offline booking is created, before verification.""" + + BOOKER_EMAIL = "offline-booker@example.com" + + def setUp(self): + self.test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) + self.test_event.apply_tax = False + self.test_event.send_booking_confirmation_email = 1 + self.test_event.booking_confirmation_email_template = None + self.test_event.offline_acknowledgement_email_template = None + self.test_event.send_ticket_email = 0 + self.test_event.save() + + settings = frappe.get_doc("Buzz Settings") + settings.default_booking_confirmation_email_template = None + settings.save() + + if not frappe.db.exists("User", self.BOOKER_EMAIL): + frappe.get_doc( + { + "doctype": "User", + "email": self.BOOKER_EMAIL, + "first_name": "Offline", + "enabled": 1, + "user_type": "Website User", + "send_welcome_email": 0, + } + ).insert(ignore_permissions=True) + + self.ticket_type = frappe.get_doc( + { + "doctype": "Event Ticket Type", + "event": self.test_event.name, + "title": "Offline Acknowledgement Ticket", + "price": 100, + } + ).insert() + + def tearDown(self): + frappe.delete_doc("Event Ticket Type", self.ticket_type.name, force=True) + + def _make_offline_booking(self, user): + """A booking in the state offline_booking_response leaves behind: a draft + awaiting verification, with no tickets.""" + booking = frappe.get_doc( + { + "doctype": "Event Booking", + "event": self.test_event.name, + "user": user, + "payment_method": "Offline", + "offline_payment_method": "Bank Transfer", + "status": "Approval Pending", + "payment_status": "Verification Pending", + "attendees": [ + { + "ticket_type": self.ticket_type.name, + "first_name": "John", + "email": "john-offline@example.com", + } + ], + } + ).insert() + return booking + + def _create_template(self, name, subject_prefix): + if frappe.db.exists("Email Template", name): + frappe.delete_doc("Email Template", name, force=True) + return frappe.get_doc( + { + "doctype": "Email Template", + "name": name, + "subject": f"{subject_prefix} - {{{{ event_title }}}}", + "response": f"

{subject_prefix} content

", + } + ).insert() + + @patch("frappe.sendmail") + def test_sends_acknowledgement_to_booker(self, mock_sendmail): + booking = self._make_offline_booking(self.BOOKER_EMAIL) + booking.send_offline_acknowledgement_email() + + mock_sendmail.assert_called_once() + self.assertIn(self.BOOKER_EMAIL, mock_sendmail.call_args[1]["recipients"]) + self.assertEqual(mock_sendmail.call_args[1]["reference_doctype"], "Event Booking") + self.assertEqual(mock_sendmail.call_args[1]["reference_name"], booking.name) + + @patch("frappe.sendmail") + def test_uses_inline_template_when_none_configured(self, mock_sendmail): + self._make_offline_booking(self.BOOKER_EMAIL).send_offline_acknowledgement_email() + + self.assertEqual(mock_sendmail.call_args[1]["template"], "offline_booking_acknowledgement") + + @patch("frappe.sendmail") + def test_carries_the_booking_summary(self, mock_sendmail): + booking = self._make_offline_booking(self.BOOKER_EMAIL) + booking.send_offline_acknowledgement_email() + + args = mock_sendmail.call_args[1]["args"] + self.assertEqual(args["doc"].name, booking.name) + self.assertEqual(args["event_title"], self.test_event.title) + self.assertEqual(len(args["attendee_rows"]), 1) + # Ticket types autoname to integers and arrive off the row as strings, so a + # title lookup keyed on the raw value silently prints the id instead. + self.assertEqual(args["attendee_rows"][0]["ticket_type_title"], self.ticket_type.title) + + def test_builtin_template_renders(self): + """The template is only exercised end-to-end here: every other test mocks the + send, so a broken Jinja tag would otherwise reach production silently.""" + booking = self._make_offline_booking(self.BOOKER_EMAIL) + + html = frappe.render_template( + "templates/emails/offline_booking_acknowledgement.html", booking.get_booking_email_args() + ) + + self.assertIn("Payment Verification Pending", html) + self.assertIn(booking.name, html) + self.assertIn(booking.offline_payment_method, html) + self.assertIn(self.ticket_type.title, html) + + @patch("frappe.sendmail") + def test_uses_event_template_when_set(self, mock_sendmail): + template = self._create_template("Offline Acknowledgement Event Template", "OFFLINE") + self.test_event.offline_acknowledgement_email_template = template.name + self.test_event.save() + + self._make_offline_booking(self.BOOKER_EMAIL).send_offline_acknowledgement_email() + + mock_sendmail.assert_called_once() + self.assertIn("OFFLINE", mock_sendmail.call_args[1]["subject"]) + + @patch("frappe.sendmail") + def test_ignores_the_confirmation_template(self, mock_sendmail): + """The acknowledgement has its own template field; the confirmation's, event-level + or global, must not leak into it.""" + event_template = self._create_template("Offline Acknowledgement Confirmation Template", "EVENT") + global_template = self._create_template("Offline Acknowledgement Global Template", "GLOBAL") + self.test_event.booking_confirmation_email_template = event_template.name + self.test_event.save() + settings = frappe.get_doc("Buzz Settings") + settings.default_booking_confirmation_email_template = global_template.name + settings.save() + + self._make_offline_booking(self.BOOKER_EMAIL).send_offline_acknowledgement_email() + + self.assertEqual(mock_sendmail.call_args[1]["template"], "offline_booking_acknowledgement") + + @patch("frappe.sendmail") + def test_respects_event_toggle_off(self, mock_sendmail): + self.test_event.send_booking_confirmation_email = 0 + self.test_event.save() + + self._make_offline_booking(self.BOOKER_EMAIL).send_offline_acknowledgement_email() + + mock_sendmail.assert_not_called() + + @patch("frappe.sendmail") + def test_skips_system_users(self, mock_sendmail): + for user in ("Administrator", "Guest"): + with self.subTest(user=user): + self._make_offline_booking(user).send_offline_acknowledgement_email() + + mock_sendmail.assert_not_called() + + class TestZoomBackedCategoryBooking(IntegrationTestCase): """Zoom needs a last name on every registrant, for meetings as much as webinars.""" diff --git a/dashboard/src/components/BookingForm.vue b/dashboard/src/components/BookingForm.vue index 1a2021a0..5837688e 100644 --- a/dashboard/src/components/BookingForm.vue +++ b/dashboard/src/components/BookingForm.vue @@ -58,8 +58,46 @@ + +
+
+ +

+ {{ __("Booking Received!") }} +

+

+ {{ + __( + "We have received your booking and offline payment details. Your payment is awaiting verification.", + ) + }} +

+

+ {{ + isZoomEvent + ? __("Your registration is not confirmed yet.") + : __("Your tickets are not confirmed yet.") + }} + {{ __("We will email") }} + {{ guestEmail }} + {{ __("once the payment is verified.") }} +

+

+ {{ __("Booking reference") }}: {{ successBookingName }} +

+
+

+ {{ __("Want to manage your bookings?") }} +

+ +
+
+
+ -
+

@@ -374,6 +412,7 @@ import { useRoute, useRouter } from "vue-router" import LucideAlertCircle from "~icons/lucide/alert-circle" import LucideCheck from "~icons/lucide/check" import LucideCheckCircle from "~icons/lucide/check-circle" +import LucideClock from "~icons/lucide/clock" import LucideGift from "~icons/lucide/gift" import LucideX from "~icons/lucide/x" @@ -521,6 +560,7 @@ const couponData = ref(null) // Success state for guest bookings const bookingSuccess = ref(false) const successBookingName = ref("") +const bookingPendingVerification = ref(false) // OTP verification state for guest bookings const showOtpModal = ref(false) @@ -1287,6 +1327,7 @@ function submitBooking( } else if (action.type === "guest-inline") { bookingSuccess.value = true successBookingName.value = action.bookingName + bookingPendingVerification.value = action.pendingVerification } else { router.replace(action.path) } diff --git a/dashboard/src/utils/bookingSuccessRedirect.test.ts b/dashboard/src/utils/bookingSuccessRedirect.test.ts index a7c88352..a448aa0d 100644 --- a/dashboard/src/utils/bookingSuccessRedirect.test.ts +++ b/dashboard/src/utils/bookingSuccessRedirect.test.ts @@ -29,7 +29,23 @@ test("redirect_to routes to booking-success for a logged-in user", () => { test("guest with no redirect_to and no payment_link falls back to inline confirmation", () => { const action = resolveBookingSuccessAction({ booking_name: "B-0003" }, { isGuestMode: true }) - assert.deepEqual(action, { type: "guest-inline", bookingName: "B-0003" }) + assert.deepEqual(action, { + type: "guest-inline", + bookingName: "B-0003", + pendingVerification: false, + }) +}) + +test("guest offline booking stays inline but is pending verification", () => { + const action = resolveBookingSuccessAction( + { booking_name: "B-0006", offline_payment: true }, + { isGuestMode: true }, + ) + assert.deepEqual(action, { + type: "guest-inline", + bookingName: "B-0006", + pendingVerification: true, + }) }) test("logged-in offline booking routes to bookings page with offline flag", () => { diff --git a/dashboard/src/utils/bookingSuccessRedirect.ts b/dashboard/src/utils/bookingSuccessRedirect.ts index df52022f..97847a4d 100644 --- a/dashboard/src/utils/bookingSuccessRedirect.ts +++ b/dashboard/src/utils/bookingSuccessRedirect.ts @@ -14,7 +14,7 @@ export interface BookingSubmitResponse { export type BookingSuccessAction = | { type: "external"; url: string } | { type: "route"; path: string } - | { type: "guest-inline"; bookingName: string } + | { type: "guest-inline"; bookingName: string; pendingVerification: boolean } export function resolveBookingSuccessAction( data: BookingSubmitResponse, @@ -35,8 +35,10 @@ export function resolveBookingSuccessAction( throw new Error("Booking response carried neither a payment link nor a booking name") } + // A guest has no session to read /bookings/ with, so an offline booking stays + // inline and says so instead of claiming tickets were sent. if (isGuestMode) { - return { type: "guest-inline", bookingName } + return { type: "guest-inline", bookingName, pendingVerification: !!data.offline_payment } } if (data.offline_payment) {