diff --git a/buzz/api.py b/buzz/api.py index c6e840f6..2b138f9a 100644 --- a/buzz/api.py +++ b/buzz/api.py @@ -12,6 +12,9 @@ from buzz.payments import get_payment_gateways_for_event, get_payment_link_for_booking from buzz.utils import is_app_installed +OFFLINE_PAYMENT_METHOD = "Offline" +OFFLINE_PAYMENT_DEFAULT_LABEL = "Offline Payment" + @frappe.whitelist(allow_guest=True) @rate_limit(key="identifier", limit=5, seconds=3600) @@ -307,12 +310,28 @@ def get_event_booking_data(event_route: str) -> dict: data.custom_fields = custom_fields # Payment Gateways - data.payment_gateways = get_payment_gateways_for_event(event_doc.name) + payment_gateways = get_payment_gateways_for_event(event_doc.name) + + # If offline payment is enabled, add it to the payment gateways list + if event_doc.enable_offline_payments: + offline_label = event_doc.offline_payment_label or OFFLINE_PAYMENT_DEFAULT_LABEL + payment_gateways.append(offline_label) + + data.payment_gateways = payment_gateways + + # Offline Payment Settings + data.offline_payment_enabled = event_doc.enable_offline_payments + if event_doc.enable_offline_payments: + data.offline_settings = { + "payment_details": event_doc.offline_payment_details, + "collect_payment_proof": event_doc.collect_payment_proof, + "label": event_doc.offline_payment_label or OFFLINE_PAYMENT_DEFAULT_LABEL, + } return data -@frappe.whitelist(allow_guest=True) +@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method def process_booking( attendees: list[dict], event: str, @@ -324,6 +343,8 @@ def process_booking( guest_full_name: str | None = None, otp: str | None = None, guest_phone: str | None = None, + payment_proof: str | None = None, + is_offline: bool = False, ) -> dict: event_doc = frappe.get_cached_doc("Buzz Event", event) is_guest = frappe.session.user == "Guest" @@ -423,6 +444,45 @@ def process_booking( booking.submit() return {"booking_name": booking.name} + # Check if offline payment is explicitly requested and enabled + if is_offline: + if not event_doc.enable_offline_payments: + frappe.throw(_("Offline payment is not enabled for this event")) + + booking.append( + "additional_fields", + { + "fieldname": "payment_method", + "value": OFFLINE_PAYMENT_METHOD, + "label": "Payment Method", + "fieldtype": "Data", + }, + ) + + # Keep booking in draft until approved — don't submit + booking.status = "Approval Pending" + booking.payment_status = "Verification Pending" + booking.flags.ignore_permissions = True + booking.save() + + # Attach payment proof if provided + if payment_proof: + try: + file_doc = frappe.get_doc( + { + "doctype": "File", + "file_url": payment_proof, + "attached_to_doctype": "Event Booking", + "attached_to_name": booking.name, + "is_private": 1, + } + ) + file_doc.insert(ignore_permissions=True) + except Exception as e: + frappe.log_error(f"Failed to attach payment proof: {e}") + + return {"booking_name": booking.name, "offline_payment": True} + return { "payment_link": get_payment_link_for_booking( booking.name, diff --git a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json index cc18331f..e7f03511 100644 --- a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json +++ b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json @@ -32,13 +32,6 @@ "fieldname": "column_break_fpgn", "fieldtype": "Column Break" }, - { - "default": "Booking", - "fieldname": "applied_to", - "fieldtype": "Select", - "label": "Applied To", - "options": "Booking\nTicket" - }, { "fieldname": "label", "fieldtype": "Data", @@ -93,6 +86,13 @@ "fieldname": "default_value", "fieldtype": "Data", "label": "Default Value" + }, + { + "default": "Booking", + "fieldname": "applied_to", + "fieldtype": "Select", + "label": "Applied To", + "options": "Booking\nTicket\nOffline Payment Form" } ], "grid_page_length": 50, diff --git a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py index d00b5848..fa2fae76 100644 --- a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py +++ b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py @@ -14,7 +14,7 @@ class BuzzCustomField(Document): if TYPE_CHECKING: from frappe.types import DF - applied_to: DF.Literal["Booking", "Ticket"] + applied_to: DF.Literal["Booking", "Ticket", "Offline Payment Form"] default_value: DF.Data | None enabled: DF.Check event: DF.Link diff --git a/buzz/events/doctype/buzz_event/buzz_event.json b/buzz/events/doctype/buzz_event/buzz_event.json index efc96dae..2963a99c 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.json +++ b/buzz/events/doctype/buzz_event/buzz_event.json @@ -37,13 +37,18 @@ "card_image", "section_break_rmtj", "allow_guest_booking", - "column_break_mijz", "guest_verification_method", "section_break_kwlt", "featured_speakers", "payments_tab", "section_break_owtc", "payment_gateways", + "offline_payment_section", + "enable_offline_payments", + "offline_payment_label", + "collect_payment_proof", + "column_break_xgh", + "offline_payment_details", "tax_settings_section", "apply_tax", "tax_inclusive", @@ -377,6 +382,40 @@ "fieldtype": "Section Break", "label": "Tax Settings" }, + { + "description": "Allow attendees to pay offline (e.g. bank transfer, UPI) and submit proof for verification", + "fieldname": "offline_payment_section", + "fieldtype": "Section Break", + "label": "Offline Payment Settings" + }, + { + "default": "0", + "fieldname": "enable_offline_payments", + "fieldtype": "Check", + "label": "Enable Offline Payments" + }, + { + "depends_on": "eval:doc.enable_offline_payments==1", + "description": "Add payment details, instructions, QR codes, bank details, etc. Users can paste images and format content as needed.", + "fieldname": "offline_payment_details", + "fieldtype": "Text Editor", + "label": "Payment Details" + }, + { + "default": "Offline Payment", + "depends_on": "eval:doc.enable_offline_payments==1", + "description": "Label displayed to users when selecting payment method (e.g., UPI Payment, Bank Transfer, etc.)", + "fieldname": "offline_payment_label", + "fieldtype": "Data", + "label": "Payment Method Label" + }, + { + "default": "0", + "depends_on": "eval:doc.enable_offline_payments==1", + "fieldname": "collect_payment_proof", + "fieldtype": "Check", + "label": "Collect Proof of Payment" + }, { "default": "0", "fieldname": "apply_tax", @@ -458,7 +497,7 @@ "fieldtype": "Section Break" }, { - "fieldname": "column_break_mijz", + "fieldname": "column_break_xgh", "fieldtype": "Column Break" } ], diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index dd63ec4b..30a1fae0 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -34,7 +34,9 @@ class BuzzEvent(Document): banner_image: DF.AttachImage | None card_image: DF.AttachImage | None category: DF.Link + collect_payment_proof: DF.Check default_ticket_type: DF.Link | None + enable_offline_payments: DF.Check end_date: DF.Date | None end_time: DF.Time external_registration_page: DF.Check @@ -46,6 +48,8 @@ class BuzzEvent(Document): medium: DF.Literal["In Person", "Online"] meta_image: DF.AttachImage | None name: DF.Int | None + offline_payment_details: DF.TextEditor | None + offline_payment_label: DF.Data | None payment_gateways: DF.Table[EventPaymentGateway] proposal: DF.Link | None registration_url: DF.Data | None diff --git a/buzz/patches.txt b/buzz/patches.txt index b109bd73..1cffbc8f 100644 --- a/buzz/patches.txt +++ b/buzz/patches.txt @@ -8,4 +8,5 @@ buzz.patches.migrate_to_multi_payment_gateway [post_model_sync] # Patches added in this section will be executed after doctypes are migrated buzz.patches.populate_slug_in_event_category -buzz.patches.set_applies_to_for_existing_coupons \ No newline at end of file +buzz.patches.set_applies_to_for_existing_coupons +buzz.patches.set_payment_status_for_existing_bookings \ No newline at end of file diff --git a/buzz/patches/set_payment_status_for_existing_bookings.py b/buzz/patches/set_payment_status_for_existing_bookings.py new file mode 100644 index 00000000..e398cf3d --- /dev/null +++ b/buzz/patches/set_payment_status_for_existing_bookings.py @@ -0,0 +1,13 @@ +import frappe + + +def execute(): + EventBooking = frappe.qb.DocType("Event Booking") + + # Set payment_status to "Paid" and status to "Confirmed" for all submitted bookings + ( + frappe.qb.update(EventBooking) + .set(EventBooking.payment_status, "Paid") + .set(EventBooking.status, "Confirmed") + .where(EventBooking.docstatus == 1) + ).run() diff --git a/buzz/ticketing/doctype/event_booking/event_booking.js b/buzz/ticketing/doctype/event_booking/event_booking.js index f062d6f1..25a49be2 100644 --- a/buzz/ticketing/doctype/event_booking/event_booking.js +++ b/buzz/ticketing/doctype/event_booking/event_booking.js @@ -10,5 +10,24 @@ frappe.ui.form.on("Event Booking", { }, }; }); + + // Add Approve/Reject buttons for pending bookings + if (frappe.user.has_role("Event Manager") && frm.doc.status === "Approval Pending") { + frm.add_custom_button(__("Approve and Submit"), function () { + frappe.confirm("Are you sure you want to approve this booking?", function () { + frm.call("approve_booking").then(() => { + frm.refresh(); + }); + }); + }); + + frm.add_custom_button(__("Reject"), function () { + frappe.confirm("Are you sure you want to reject this booking?", function () { + frm.call("reject_booking").then(() => { + frm.refresh(); + }); + }); + }); + } }, }); diff --git a/buzz/ticketing/doctype/event_booking/event_booking.json b/buzz/ticketing/doctype/event_booking/event_booking.json index 916c1015..8a8010a6 100644 --- a/buzz/ticketing/doctype/event_booking/event_booking.json +++ b/buzz/ticketing/doctype/event_booking/event_booking.json @@ -10,6 +10,10 @@ "column_break_cjxu", "user", "naming_series", + "section_break_status", + "payment_status", + "column_break_status", + "status", "section_break_xvkp", "attendees", "section_break_suav", @@ -167,6 +171,33 @@ "label": "Discount Amount", "options": "currency", "read_only": 1 + }, + { + "fieldname": "section_break_status", + "fieldtype": "Section Break", + "label": "Status" + }, + { + "default": "Unpaid", + "fieldname": "payment_status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Payment Status", + "options": "Unpaid\nPaid\nVerification Pending", + "read_only": 1 + }, + { + "fieldname": "column_break_status", + "fieldtype": "Column Break" + }, + { + "default": "Approval Pending", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Confirmed\nApproval Pending\nApproved\nRejected", + "read_only": 1 } ], "grid_page_length": 50, @@ -182,7 +213,7 @@ "link_fieldname": "reference_docname" } ], - "modified": "2026-01-03 17:29:18.102192", + "modified": "2026-02-05 09:10:22.542973", "modified_by": "Administrator", "module": "Ticketing", "name": "Event Booking", diff --git a/buzz/ticketing/doctype/event_booking/event_booking.py b/buzz/ticketing/doctype/event_booking/event_booking.py index 24558f8d..465a310c 100644 --- a/buzz/ticketing/doctype/event_booking/event_booking.py +++ b/buzz/ticketing/doctype/event_booking/event_booking.py @@ -6,6 +6,7 @@ from frappe import _ from frappe.model.document import Document +from buzz.api import OFFLINE_PAYMENT_METHOD from buzz.payments import mark_payment_as_received @@ -31,6 +32,8 @@ class EventBooking(Document): event: DF.Link naming_series: DF.Literal["B.###"] net_amount: DF.Currency + payment_status: DF.Literal["Unpaid", "Paid", "Verification Pending"] + status: DF.Literal["Confirmed", "Approval Pending", "Approved", "Rejected"] tax_amount: DF.Currency tax_label: DF.Data | None tax_percentage: DF.Percent @@ -47,6 +50,28 @@ def validate(self): self.apply_coupon_if_applicable() self.apply_taxes_if_applicable() + def before_submit(self): + """Set status before submit based on payment method.""" + # Skip if already approved (submission triggered by approve_booking) + if self.status == "Approved": + return + + payment_method = None + for field in self.additional_fields or []: + if field.fieldname == "payment_method": + payment_method = field.value + break + + if payment_method == OFFLINE_PAYMENT_METHOD: + frappe.throw( + _( + "This booking requires offline payment verification. Please use the Approve or Reject button instead." + ) + ) + elif self.payment_status != "Paid": + self.payment_status = "Unpaid" + self.status = "Approval Pending" + def set_currency(self): self.currency = self.attendees[0].currency @@ -209,6 +234,8 @@ def generate_tickets(self): def on_payment_authorized(self, payment_status: str): if payment_status in ("Authorized", "Completed"): # payment success, submit the booking + self.payment_status = "Paid" + self.status = "Confirmed" self.update_payment_record() def update_payment_record(self): @@ -229,6 +256,29 @@ def cancel_all_tickets(self): for ticket in tickets: frappe.get_cached_doc("Event Ticket", ticket).cancel() + @frappe.whitelist() + def approve_booking(self): + """Approve the booking and submit it to generate tickets.""" + frappe.only_for("Event Manager") + + self.status = "Approved" + if self.payment_status == "Verification Pending": + self.payment_status = "Paid" + + self.flags.ignore_permissions = True + self.submit() + frappe.msgprint(_("Booking has been approved!")) + + @frappe.whitelist() + def reject_booking(self): + """Reject and discard the booking.""" + frappe.only_for("Event Manager") + + self.flags.ignore_permissions = True + self.discard() + self.db_set("status", "Rejected") + frappe.msgprint(_("Booking has been rejected!")) + def apply_coupon_if_applicable(self): self.discount_amount = 0 diff --git a/buzz/ticketing/doctype/event_booking/test_event_booking.py b/buzz/ticketing/doctype/event_booking/test_event_booking.py index 1cfafc3d..5a5976b4 100644 --- a/buzz/ticketing/doctype/event_booking/test_event_booking.py +++ b/buzz/ticketing/doctype/event_booking/test_event_booking.py @@ -520,6 +520,346 @@ def test_process_booking_with_utm_parameters(self): self.assertEqual(utm_dict["utm_content"], "banner_ad") self.assertEqual(utm_dict["utm_term"], "event tickets") + # ==================== Offline Payment Tests ==================== + + def test_offline_booking_cannot_be_submitted_directly(self): + """Test that offline bookings cannot be submitted directly — must use approve/reject.""" + test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) + test_event.enable_offline_payments = True + test_event.save() + + test_ticket_type = frappe.get_doc( + { + "doctype": "Event Ticket Type", + "event": test_event.name, + "title": "Offline Ticket", + "price": 500, + } + ).insert() + + booking = frappe.get_doc( + { + "doctype": "Event Booking", + "event": test_event.name, + "user": "Administrator", + "attendees": [ + {"ticket_type": test_ticket_type.name, "full_name": "Test", "email": "test@test.com"} + ], + "additional_fields": [{"fieldname": "payment_method", "value": "Offline"}], + } + ).insert() + + with self.assertRaises(frappe.ValidationError): + booking.submit() + + def test_approve_offline_booking(self): + """Test approving an offline booking submits it and generates tickets.""" + test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) + test_event.enable_offline_payments = True + test_event.save() + + test_ticket_type = frappe.get_doc( + { + "doctype": "Event Ticket Type", + "event": test_event.name, + "title": "Approval Test Ticket", + "price": 500, + } + ).insert() + + booking = frappe.get_doc( + { + "doctype": "Event Booking", + "event": test_event.name, + "user": "Administrator", + "attendees": [ + {"ticket_type": test_ticket_type.name, "full_name": "Test", "email": "test@test.com"} + ], + "additional_fields": [{"fieldname": "payment_method", "value": "Offline"}], + "status": "Approval Pending", + "payment_status": "Verification Pending", + } + ).insert() + + # Booking should be in draft with no tickets + self.assertEqual(booking.docstatus, 0) + tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name}) + self.assertEqual(len(tickets), 0) + + booking.approve_booking() + booking.reload() + + # After approval, booking should be submitted with tickets generated + self.assertEqual(booking.docstatus, 1) + self.assertEqual(booking.status, "Approved") + self.assertEqual(booking.payment_status, "Paid") + + tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name}) + self.assertEqual(len(tickets), 1) + + def test_reject_offline_booking(self): + """Test rejecting an offline booking keeps it in draft with no tickets.""" + test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) + test_event.enable_offline_payments = True + test_event.save() + + test_ticket_type = frappe.get_doc( + { + "doctype": "Event Ticket Type", + "event": test_event.name, + "title": "Rejection Test Ticket", + "price": 500, + } + ).insert() + + booking = frappe.get_doc( + { + "doctype": "Event Booking", + "event": test_event.name, + "user": "Administrator", + "attendees": [ + {"ticket_type": test_ticket_type.name, "full_name": "Test", "email": "test@test.com"} + ], + "additional_fields": [{"fieldname": "payment_method", "value": "Offline"}], + "status": "Approval Pending", + "payment_status": "Verification Pending", + } + ).insert() + + self.assertEqual(booking.docstatus, 0) + + booking.reject_booking() + booking.reload() + + # Discarded (docstatus=2) and marked as Rejected + self.assertEqual(booking.docstatus, 2) + self.assertEqual(booking.status, "Rejected") + + # No tickets should be generated + tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name}) + self.assertEqual(len(tickets), 0) + + def test_offline_with_coupon_code(self): + """Test offline payment with coupon code discount.""" + test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) + test_event.enable_offline_payments = True + test_event.save() + + test_ticket_type = frappe.get_doc( + { + "doctype": "Event Ticket Type", + "event": test_event.name, + "title": "Coupon Test Ticket", + "price": 500, + } + ).insert() + + coupon = frappe.get_doc( + { + "doctype": "Buzz Coupon Code", + "code": f"OFFLINE10-{frappe.generate_hash(length=4)}", + "coupon_type": "Discount", + "discount_type": "Percentage", + "discount_value": 10, + "is_active": True, + } + ).insert() + + booking = frappe.get_doc( + { + "doctype": "Event Booking", + "event": test_event.name, + "user": "Administrator", + "coupon_code": coupon.name, + "attendees": [ + {"ticket_type": test_ticket_type.name, "full_name": "Test", "email": "test@test.com"} + ], + "additional_fields": [{"fieldname": "payment_method", "value": "Offline"}], + } + ).insert() + + self.assertEqual(booking.net_amount, 500) + self.assertEqual(booking.discount_amount, 50) + self.assertEqual(booking.total_amount, 450) + + def test_offline_with_tax(self): + """Test offline payment with tax calculation.""" + test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) + test_event.enable_offline_payments = True + test_event.apply_tax = True + test_event.tax_percentage = 18 + test_event.tax_label = "GST" + test_event.save() + + test_ticket_type = frappe.get_doc( + { + "doctype": "Event Ticket Type", + "event": test_event.name, + "title": "Tax Test Ticket", + "price": 500, + } + ).insert() + + booking = frappe.get_doc( + { + "doctype": "Event Booking", + "event": test_event.name, + "user": "Administrator", + "attendees": [ + {"ticket_type": test_ticket_type.name, "full_name": "Test", "email": "test@test.com"} + ], + "additional_fields": [{"fieldname": "payment_method", "value": "Offline"}], + } + ).insert() + + self.assertEqual(booking.net_amount, 500) + self.assertEqual(booking.tax_percentage, 18) + self.assertEqual(booking.tax_amount, 90) + self.assertEqual(booking.total_amount, 590) + + def test_offline_booking_requires_payment_method_field(self): + """Test that offline booking requires payment_method in additional_fields.""" + test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) + test_event.enable_offline_payments = True + test_event.save() + + test_ticket_type = frappe.get_doc( + { + "doctype": "Event Ticket Type", + "event": test_event.name, + "title": "Payment Method Test", + "price": 500, + } + ).insert() + + # Booking without payment_method field should default to normal flow + booking_without_method = frappe.get_doc( + { + "doctype": "Event Booking", + "event": test_event.name, + "user": "Administrator", + "attendees": [ + {"ticket_type": test_ticket_type.name, "full_name": "Test", "email": "test@test.com"} + ], + } + ).insert() + + booking_without_method.submit() + # Without payment_method field, it should go to normal payment flow + self.assertEqual(booking_without_method.payment_status, "Unpaid") + + # Booking with payment_method = "Offline" should block direct submission + booking_with_method = frappe.get_doc( + { + "doctype": "Event Booking", + "event": test_event.name, + "user": "Administrator", + "attendees": [ + {"ticket_type": test_ticket_type.name, "full_name": "Test2", "email": "test2@test.com"} + ], + "additional_fields": [{"fieldname": "payment_method", "value": "Offline"}], + } + ).insert() + + with self.assertRaises(frappe.ValidationError): + booking_with_method.submit() + + def test_process_booking_offline_stays_in_draft(self): + """Test that offline bookings via process_booking stay in draft with no tickets.""" + from buzz.api import process_booking + + test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) + test_event.enable_offline_payments = True + test_event.apply_tax = False + test_event.save() + + test_ticket_type = frappe.get_doc( + { + "doctype": "Event Ticket Type", + "event": test_event.name, + "title": "Offline Draft Test", + "price": 500, + "is_published": True, + } + ).insert() + + result = process_booking( + attendees=[ + { + "full_name": "Offline User", + "email": "offline@email.com", + "ticket_type": str(test_ticket_type.name), + "add_ons": [], + } + ], + event=str(test_event.name), + is_offline=True, + ) + + self.assertIn("booking_name", result) + self.assertTrue(result.get("offline_payment")) + + booking = frappe.get_doc("Event Booking", result["booking_name"]) + + # Booking must be in draft (not submitted) + self.assertEqual(booking.docstatus, 0) + self.assertEqual(booking.status, "Approval Pending") + self.assertEqual(booking.payment_status, "Verification Pending") + + # No tickets should exist + tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name}) + self.assertEqual(len(tickets), 0) + + def test_process_booking_offline_generates_tickets_on_approval(self): + """Test that approving an offline booking created via API generates tickets.""" + from buzz.api import process_booking + + test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) + test_event.enable_offline_payments = True + test_event.apply_tax = False + test_event.save() + + test_ticket_type = frappe.get_doc( + { + "doctype": "Event Ticket Type", + "event": test_event.name, + "title": "Offline Approval Test", + "price": 500, + "is_published": True, + } + ).insert() + + result = process_booking( + attendees=[ + { + "full_name": "Approval User", + "email": "approval@email.com", + "ticket_type": str(test_ticket_type.name), + "add_ons": [], + } + ], + event=str(test_event.name), + is_offline=True, + ) + + booking = frappe.get_doc("Event Booking", result["booking_name"]) + + # No tickets before approval + tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name}) + self.assertEqual(len(tickets), 0) + + # Approve the booking + booking.approve_booking() + booking.reload() + + # After approval: submitted, approved, tickets generated + self.assertEqual(booking.docstatus, 1) + self.assertEqual(booking.status, "Approved") + self.assertEqual(booking.payment_status, "Paid") + + tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name}) + self.assertEqual(len(tickets), 1) + def test_process_booking_without_utm_parameters(self): """Test that process_booking API works without UTM parameters.""" from buzz.api import process_booking diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index 5708600e..77a7e241 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -25,7 +25,9 @@ declare module 'vue' { EventSelector: typeof import('./src/components/EventSelector.vue')['default'] EventSponsorForm: typeof import('./src/components/EventSponsorForm.vue')['default'] LanguageSwitcher: typeof import('./src/components/LanguageSwitcher.vue')['default'] + LucideCheckCircle: typeof import('~icons/lucide/check-circle')['default'] Navbar: typeof import('./src/components/Navbar.vue')['default'] + OfflinePaymentDialog: typeof import('./src/components/OfflinePaymentDialog.vue')['default'] PaymentGatewayDialog: typeof import('./src/components/PaymentGatewayDialog.vue')['default'] ProfileView: typeof import('./src/components/ProfileView.vue')['default'] ProposalEditDialog: typeof import('./src/components/ProposalEditDialog.vue')['default'] diff --git a/dashboard/src/components/BookingFinancialSummary.vue b/dashboard/src/components/BookingFinancialSummary.vue index 6ec03bb6..51e26e22 100644 --- a/dashboard/src/components/BookingFinancialSummary.vue +++ b/dashboard/src/components/BookingFinancialSummary.vue @@ -2,11 +2,16 @@

{{ __("Payment Summary") }}

- + - {{ __("Paid") }} + {{ paymentBadge.label }}
@@ -59,8 +64,8 @@
- {{ __("Total Paid") }} - {{ + {{ isPaid ? __("Total Paid") : __("Total") }} + {{ formatPrice(booking.total_amount || 0, booking.currency || "INR") }}
@@ -84,6 +89,8 @@ import { formatPrice } from "@/utils/currency"; import { Badge } from "frappe-ui"; import { computed } from "vue"; import LucideCheck from "~icons/lucide/check"; +import LucideClock from "~icons/lucide/clock"; +import LucideX from "~icons/lucide/x"; const props = defineProps({ booking: { @@ -103,6 +110,18 @@ const hasDiscount = computed(() => { return (props.booking.discount_amount || 0) > 0; }); +const isPaid = computed(() => props.booking.payment_status === "Paid"); + +const paymentBadge = computed(() => { + const status = props.booking.payment_status; + if (status === "Paid") { + return { label: __("Paid"), theme: "green", icon: LucideCheck }; + } else if (status === "Verification Pending") { + return { label: __("Verification Pending"), theme: "orange", icon: LucideClock }; + } + return { label: __(status || "Unpaid"), theme: "red", icon: LucideX }; +}); + const isTaxInclusive = computed(() => { // Tax-inclusive: total_amount equals net_amount minus discount (tax not added on top) if (!hasTax.value) return false; diff --git a/dashboard/src/components/BookingForm.vue b/dashboard/src/components/BookingForm.vue index 43d1c8ac..702b2d92 100644 --- a/dashboard/src/components/BookingForm.vue +++ b/dashboard/src/components/BookingForm.vue @@ -101,6 +101,17 @@
+ +
@@ -394,6 +405,7 @@ import BookingSummary from "./BookingSummary.vue"; import CustomFieldsSection from "./CustomFieldsSection.vue"; import EventDetailsHeader from "./EventDetailsHeader.vue"; import PaymentGatewayDialog from "./PaymentGatewayDialog.vue"; +import OfflinePaymentDialog from "./OfflinePaymentDialog.vue"; const router = useRouter(); const route = useRoute(); @@ -449,6 +461,14 @@ const props = defineProps({ type: Boolean, default: false, }, + offlinePaymentEnabled: { + type: Boolean, + default: false, + }, + offlineSettings: { + type: Object, + default: () => ({}), + }, }); // --- STATE --- @@ -467,9 +487,13 @@ const bookingCustomFieldsData = storedBookingCustomFields; // Payment gateway dialog state const showGatewayDialog = ref(false); +const showOfflineDialog = ref(false); const pendingPayload = ref(null); const selectedGateway = ref(null); +const isOfflineGateway = (gateway) => + props.offlinePaymentEnabled && gateway === props.offlineSettings?.label; + // Coupon state const couponCode = ref(""); const couponApplied = ref(false); @@ -1104,27 +1128,46 @@ async function submit() { } pendingBookingPayload.value = final_payload; - if (finalTotal.value > 0 && props.paymentGateways.length > 1) { - pendingPayload.value = final_payload; - showGatewayDialog.value = true; + // OTP verification must happen before payment gateway selection + if (props.eventDetails.guest_verification_method !== "None") { + sendOtpForVerification(); return; } - selectedGateway.value = props.paymentGateways[0] || null; - - if (props.eventDetails.guest_verification_method === "None") { - submitBooking(final_payload, selectedGateway.value); - return; + // No OTP required - proceed with payment gateway selection + if (finalTotal.value > 0) { + if (props.paymentGateways.length > 1) { + pendingPayload.value = final_payload; + showGatewayDialog.value = true; + return; + } else if (props.paymentGateways.length === 1) { + const singleGateway = props.paymentGateways[0]; + if (isOfflineGateway(singleGateway)) { + pendingPayload.value = final_payload; + showOfflineDialog.value = true; + return; + } + } } - sendOtpForVerification(); + selectedGateway.value = props.paymentGateways[0] || null; + submitBooking(final_payload, selectedGateway.value); return; } - if (finalTotal.value > 0 && props.paymentGateways.length > 1) { - pendingPayload.value = final_payload; - showGatewayDialog.value = true; - return; + if (finalTotal.value > 0) { + if (props.paymentGateways.length > 1) { + pendingPayload.value = final_payload; + showGatewayDialog.value = true; + return; + } else if (props.paymentGateways.length === 1) { + const singleGateway = props.paymentGateways[0]; + if (isOfflineGateway(singleGateway)) { + pendingPayload.value = final_payload; + showOfflineDialog.value = true; + return; + } + } } submitBooking(final_payload, props.paymentGateways[0] || null); @@ -1149,6 +1192,9 @@ function submitBooking(payload, paymentGateway, { isOtpFlow = false } = {}) { } else if (props.isGuestMode) { bookingSuccess.value = true; successBookingName.value = data.booking_name; + } else if (data.offline_payment) { + // Offline payment submitted - redirect to booking details + router.replace(`/bookings/${data.booking_name}?success=true&offline=true`); } else { // free event router.replace(`/bookings/${data.booking_name}?success=true`); @@ -1174,23 +1220,30 @@ function submitBooking(payload, paymentGateway, { isOtpFlow = false } = {}) { ); } -function onGatewaySelected(gateway) { - if (props.isGuestMode) { - selectedGateway.value = gateway; - showGatewayDialog.value = false; - - if (props.eventDetails.guest_verification_method === "None") { - submitBooking(pendingBookingPayload.value, gateway); - return; - } - - sendOtpForVerification(); - return; +function onOfflinePaymentSubmit(data) { + if (pendingPayload.value) { + const payloadWithProof = { + ...pendingPayload.value, + payment_proof: data?.payment_proof?.file_url || null, + is_offline: true, + }; + submitBooking(payloadWithProof, null); + pendingPayload.value = null; + showOfflineDialog.value = false; } +} + +function onGatewaySelected(gateway) { + showGatewayDialog.value = false; + selectedGateway.value = gateway; if (pendingPayload.value) { - submitBooking(pendingPayload.value, gateway); - pendingPayload.value = null; + if (isOfflineGateway(gateway)) { + showOfflineDialog.value = true; + } else { + submitBooking(pendingPayload.value, gateway); + pendingPayload.value = null; + } } } @@ -1205,7 +1258,26 @@ function submitWithOtp() { otp: otpCode.value.trim(), }; - submitBooking(payloadWithOtp, selectedGateway.value, { isOtpFlow: true }); + // After OTP verification, check payment gateway selection + if (finalTotal.value > 0) { + if (props.paymentGateways.length > 1) { + pendingPayload.value = payloadWithOtp; + showOtpModal.value = false; + showGatewayDialog.value = true; + return; + } else if (props.paymentGateways.length === 1) { + const singleGateway = props.paymentGateways[0]; + if (isOfflineGateway(singleGateway)) { + pendingPayload.value = payloadWithOtp; + showOtpModal.value = false; + showOfflineDialog.value = true; + return; + } + selectedGateway.value = singleGateway; + } + } + + submitBooking(payloadWithOtp, selectedGateway.value || null, { isOtpFlow: true }); } function resendOtp() { diff --git a/dashboard/src/components/OfflinePaymentDialog.vue b/dashboard/src/components/OfflinePaymentDialog.vue new file mode 100644 index 00000000..710981db --- /dev/null +++ b/dashboard/src/components/OfflinePaymentDialog.vue @@ -0,0 +1,184 @@ + + + diff --git a/dashboard/src/pages/BookTickets.vue b/dashboard/src/pages/BookTickets.vue index 4e355c8a..b8c65d88 100644 --- a/dashboard/src/pages/BookTickets.vue +++ b/dashboard/src/pages/BookTickets.vue @@ -50,6 +50,8 @@ :eventRoute="eventRoute" :paymentGateways="eventBookingData.paymentGateways" :isGuestMode="isGuest" + :offlinePaymentEnabled="eventBookingData.offlinePaymentEnabled" + :offlineSettings="eventBookingData.offlineSettings" />
@@ -69,6 +71,8 @@ const eventBookingData = reactive({ eventDetails: null, customFields: null, paymentGateways: [], + offlinePaymentEnabled: false, + offlineSettings: {}, }); const eventNotFound = ref(false); @@ -104,6 +108,8 @@ const eventBookingResource = createResource({ eventBookingData.eventDetails = data.event_details || {}; eventBookingData.customFields = data.custom_fields || []; eventBookingData.paymentGateways = data.payment_gateways || []; + eventBookingData.offlinePaymentEnabled = data.offline_payment_enabled || false; + eventBookingData.offlineSettings = data.offline_settings || {}; }, onError: (error) => { if (error.message?.includes("DoesNotExistError")) { diff --git a/dashboard/src/pages/BookingDetails.vue b/dashboard/src/pages/BookingDetails.vue index 02f64fe8..50dd7292 100644 --- a/dashboard/src/pages/BookingDetails.vue +++ b/dashboard/src/pages/BookingDetails.vue @@ -6,6 +6,54 @@
+ +
+
+
+
+ +
+
+

+ {{ __("Payment Confirmation Pending") }} +

+

+ {{ + __( + "Your booking is confirmed subject to verifying the offline payment details. You will be notified once payment is verified." + ) + }} +

+
+
+
+
+ + +
+
+
+
+ +
+
+

+ {{ __("Booking Rejected") }} +

+

+ {{ + __( + "Your booking has been rejected. Please contact the event organizer for more information." + ) + }} +

+
+
+
+
+
@@ -24,7 +72,9 @@ @@ -39,7 +89,7 @@ { + return bookingDetails.data?.doc?.status === "Approval Pending"; +}); + +const isBookingRejected = computed(() => { + return bookingDetails.data?.doc?.status === "Rejected"; +}); + // Check if this is a successful payment redirect (check URL immediately) const isPaymentSuccess = route.query.success === "true"; +const isConfirmationPending = route.query.offline === "true"; // Use payment success composable for UI effects (confetti, message, URL cleanup) -const { showSuccessMessage } = usePaymentSuccess(); +// Disable confetti when booking is pending approval (e.g. offline payments) +const { showSuccessMessage } = usePaymentSuccess({ + enableConfetti: !isConfirmationPending, +}); const showCancellationDialog = ref(false); diff --git a/dashboard/src/pages/BookingsList.vue b/dashboard/src/pages/BookingsList.vue index 4c677dc2..b9c86076 100644 --- a/dashboard/src/pages/BookingsList.vue +++ b/dashboard/src/pages/BookingsList.vue @@ -20,7 +20,13 @@