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 b1bdedd5..bdf44e25 100644 --- a/buzz/api/booking/test_booking.py +++ b/buzz/api/booking/test_booking.py @@ -260,6 +260,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 47661cc0..543958aa 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.json +++ b/buzz/events/doctype/buzz_event/buzz_event.json @@ -77,6 +77,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", @@ -298,7 +299,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" @@ -310,6 +311,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", @@ -621,7 +630,7 @@ "link_fieldname": "event" } ], - "modified": "2026-08-14 13:08:24.523151", + "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 7da572d0..d24f881a 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -50,6 +50,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,14 +64,17 @@ class BuzzEvent(Document): host: DF.Link is_published: DF.Check medium: DF.Literal["In Person", "Online"] + meeting_link: DF.Data | None 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 4a3da032..b156aff9 100644 --- a/buzz/ticketing/doctype/event_booking/event_booking.py +++ b/buzz/ticketing/doctype/event_booking/event_booking.py @@ -188,34 +188,85 @@ 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) + team_settings = get_event_team_settings(self.event) + # Fallback to the team default if event-level not set + self.send_booking_email( + template=( + event_doc.booking_confirmation_email_template + or team_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 - team_settings = get_event_team_settings(self.event) - # Fallback to the team default if event-level not set - booking_template = ( - event_doc.booking_confirmation_email_template - or team_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": get_event_team_settings(self.event).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]}, @@ -223,42 +274,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": team_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 121b2084..84375174 100644 --- a/buzz/ticketing/doctype/event_booking/test_event_booking.py +++ b/buzz/ticketing/doctype/event_booking/test_event_booking.py @@ -1385,6 +1385,170 @@ def test_event_template_takes_precedence_over_the_team_default(self, mock_sendma self.assertNotIn("TEAM", 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() + + set_team_settings(self.test_event.team, default_booking_confirmation_email_template=None) + self.addCleanup(frappe.clear_document_cache, "Buzz Team Settings", self.test_event.team) + + 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 team-level, must not leak into it.""" + event_template = self._create_template("Offline Acknowledgement Confirmation Template", "EVENT") + team_template = self._create_template("Offline Acknowledgement Team Template", "TEAM") + self.test_event.booking_confirmation_email_template = event_template.name + self.test_event.save() + set_team_settings( + self.test_event.team, default_booking_confirmation_email_template=team_template.name + ) + + 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) {