diff --git a/erpnext/venue/doctype/booking_credit_usage/booking_credit_usage.py b/erpnext/venue/doctype/booking_credit_usage/booking_credit_usage.py index cd547496437eb4fd9963d5eb03dc9535b53b7261..4d1b02d5fe79a4222b27ed55132970c574906532 100644 --- a/erpnext/venue/doctype/booking_credit_usage/booking_credit_usage.py +++ b/erpnext/venue/doctype/booking_credit_usage/booking_credit_usage.py @@ -54,6 +54,9 @@ def add_booking_credit_usage(doc, method): if not doc.deduct_booking_credits: return + + if doc.status == "Cancelled": + return if frappe.db.exists("Booking Credit Usage", {"item_booking": doc.name, "docstatus": 1}): return diff --git a/erpnext/venue/doctype/item_booking/item_booking.py b/erpnext/venue/doctype/item_booking/item_booking.py index ca53055ad2835d25139e8944954dc3f872830625..7a1f507fa460ab403392e1d77f7aa23d3cc7f030 100644 --- a/erpnext/venue/doctype/item_booking/item_booking.py +++ b/erpnext/venue/doctype/item_booking/item_booking.py @@ -496,9 +496,18 @@ class ItemBooking(Document): formatted_duration ) ) + + if self.deduct_booking_credits and frappe.db.get_single_value("Venue Settings", "refund_credit"): + self.refund_credit() self.set_status("Cancelled") + def refund_credit(self): + for bcu in frappe.db.get_all("Booking Credit Usage", filters = {"item_booking": self.name, "docstatus": 1}): + doc = frappe.get_doc("Booking Credit Usage", bcu.name) + doc.flags.ignore_permissions = True + doc.cancel() + @frappe.whitelist() def end_now(self): new_end = now_datetime() diff --git a/erpnext/venue/doctype/item_booking/test_item_booking.py b/erpnext/venue/doctype/item_booking/test_item_booking.py index 4b26252228420f732fa38147519c1e7177436365..dfe0ae5bc3ddd3a2c8f4a5df0c9789f56e330e82 100644 --- a/erpnext/venue/doctype/item_booking/test_item_booking.py +++ b/erpnext/venue/doctype/item_booking/test_item_booking.py @@ -174,6 +174,7 @@ class BaseTestWithBookableItem(FrappeTestCase): all_day=False, uom="Hour", status="Confirmed", + deduct_booking_credits=0, ): booking = frappe.get_doc( { @@ -186,6 +187,7 @@ class BaseTestWithBookableItem(FrappeTestCase): "all_day": all_day, "uom": uom, "sync_with_google_calendar": False, + "deduct_booking_credits": deduct_booking_credits } ) booking.insert() @@ -544,6 +546,171 @@ class TestItemBooking(BaseTestWithBookableItem): frappe.db.rollback() + def _setup_credit_test(self, credit_type_label="Test Credit Type", credits_per_hour=10, initial_credits=20, customer=TEST_CUSTOMER): + from erpnext.venue.doctype.booking_credit.booking_credit import get_balance + + credit_type = frappe.get_doc({ + "doctype": "Booking Credit Type", + "label": credit_type_label, + "credits": credits_per_hour, + "uom": "Hour", + "item": self.ITEM_BOOKABLE_1.name, + "conversion_table": [{ + "item": self.ITEM_BOOKABLE_1.name, + "uom": "Hour", + "credits": credits_per_hour + }] + }) + credit_type.insert(ignore_permissions=True) + + credit_doc = frappe.get_doc({ + "doctype": "Booking Credit", + "date": frappe.utils.nowdate(), + "customer": customer, + "booking_credit_type": credit_type.name, + "quantity": initial_credits, + }) + credit_doc.insert(ignore_permissions=True) + credit_doc.submit() + + # Verify initial balance + initial_balance = get_balance(customer) + self.assertEqual(initial_balance, {credit_type.name: initial_credits}) + + return credit_type + + def _create_booking_with_credits(self, credit_type, duration_hours=1, customer=TEST_CUSTOMER): + from erpnext.venue.doctype.booking_credit_usage.booking_credit_usage import add_booking_credit_usage + from erpnext.venue.doctype.booking_credit.booking_credit import get_balance + + dt_start = add_to_date(getdate(), days=4, hours=7) + dt_end = add_to_date(dt_start, hours=duration_hours) + + booking = self.makeBookingWithAutocleanup( + self.ITEM_BOOKABLE_1.name, dt_start, dt_end, user=TEST_USER_1, deduct_booking_credits=1 + ) + booking.party_type = "Customer" + booking.party_name = customer + booking.save() + + add_booking_credit_usage(booking, None) + + # Calculate expected remaining balance + credits_used = credit_type.credits * duration_hours + expected_balance = frappe.db.get_value("Booking Credit", + {"customer": customer, "booking_credit_type": credit_type.name}, "quantity") - credits_used + + # Verify credits were deducted + balance = get_balance(customer) + self.assertEqual(balance, {credit_type.name: expected_balance}) + + return booking + + @change_settings( + "Venue Settings", { + "minute_uom": "Minute", + "enable_simultaneous_booking": 1, + "allow_event_cancellation": 1, + "cancellation_delay": 0, + "refund_credit": 1 + } + ) + def test_booking_cancellation_with_credit_refund(self): + """Test that credits are refunded when refund_credit setting is enabled""" + from contextlib import contextmanager + + from erpnext.venue.doctype.booking_credit.booking_credit import get_balance + from erpnext.venue.doctype.item_booking.item_booking import cancel_appointment + + @contextmanager + def rollback_test(): + frappe.db.savepoint("test_booking_cancellation_with_credit_refund") + frappe.set_user("Administrator") + try: + yield + finally: + frappe.set_user("Administrator") + frappe.db.rollback(save_point="test_booking_cancellation_with_credit_refund") + + @contextmanager + def user_context(user): + old_user = frappe.session.user + frappe.set_user(user) + try: + yield + finally: + frappe.set_user(old_user) + + with rollback_test(): + credit_type = self._setup_credit_test( + credits_per_hour=10, initial_credits=20 + ) + booking = self._create_booking_with_credits(credit_type, duration_hours=1) + + with user_context(TEST_USER_1): + cancel_appointment(booking.name) + + # Verify credits were refunded (back to initial 20) + balance = get_balance(TEST_CUSTOMER) + self.assertEqual(balance, {credit_type.name: 20}) + + booking.reload() + self.assertEqual(booking.status, "Cancelled") + + @change_settings( + "Venue Settings", { + "minute_uom": "Minute", + "enable_simultaneous_booking": 1, + "allow_event_cancellation": 1, + "cancellation_delay": 0, + "refund_credit": 0 + } + ) + def test_booking_cancellation_without_credit_refund(self): + """Test that credits are NOT refunded when refund_credit setting is disabled""" + from contextlib import contextmanager + + from erpnext.venue.doctype.booking_credit.booking_credit import get_balance + from erpnext.venue.doctype.item_booking.item_booking import cancel_appointment + + @contextmanager + def rollback_test(): + frappe.db.savepoint("test_booking_cancellation_without_credit_refund") + frappe.set_user("Administrator") + try: + yield + finally: + frappe.set_user("Administrator") + frappe.db.rollback(save_point="test_booking_cancellation_without_credit_refund") + + @contextmanager + def user_context(user): + old_user = frappe.session.user + frappe.set_user(user) + try: + yield + finally: + frappe.set_user(old_user) + + with rollback_test(): + credit_type = self._setup_credit_test( + credit_type_label="Test Credit Type No Refund", + credits_per_hour=10, + initial_credits=20 + ) + booking = self._create_booking_with_credits(credit_type, duration_hours=1) + + with user_context(TEST_USER_1): + cancel_appointment(booking.name) + + # Verify credits were NOT refunded (remains at 10 after deduction) + balance = get_balance(TEST_CUSTOMER) + self.assertEqual(balance, {credit_type.name: 10}) + + booking.reload() + self.assertEqual(booking.status, "Cancelled") + + class TestItemBookingWithSubscription(BaseTestWithSubscriptionForBookableItem): @change_settings("Venue Settings", {"minute_uom": "Minute", "enable_simultaneous_booking": 1}) def test_subscription_decreases_availability1(self): diff --git a/erpnext/venue/doctype/venue_settings/venue_settings.json b/erpnext/venue/doctype/venue_settings/venue_settings.json index bfa33cb20f98fa1acfa947eb60578ba9ee8a0d7e..8ad7663b22efc5c497099d405357768f7f0705e0 100644 --- a/erpnext/venue/doctype/venue_settings/venue_settings.json +++ b/erpnext/venue/doctype/venue_settings/venue_settings.json @@ -15,6 +15,7 @@ "allow_event_cancellation", "display_subscriptions_in_calendar", "cancellation_delay", + "refund_credit", "role_allowed_to_skip_cart", "event_registration_section", "registration_item_code", @@ -98,6 +99,13 @@ "fieldtype": "Duration", "label": "Cancellation delay" }, + { + "default": "0", + "depends_on": "eval:doc.allow_event_cancellation", + "fieldname": "refund_credit", + "fieldtype": "Check", + "label": "Refund credit on cancellation" + }, { "default": "0", "description": "If not set, the booking will be considered confirmed when the order is placed", diff --git a/erpnext/venue/doctype/venue_settings/venue_settings.py b/erpnext/venue/doctype/venue_settings/venue_settings.py index 62facbdc1b51bc6392d4bd9859a7535f02a1f4cf..3fd089c80b04cea6bcfde08cd62e9808caac3bab 100644 --- a/erpnext/venue/doctype/venue_settings/venue_settings.py +++ b/erpnext/venue/doctype/venue_settings/venue_settings.py @@ -36,6 +36,7 @@ class VenueSettings(Document): allow_event_cancellation: DF.Check cancellation_delay: DF.Duration | None cart_settings_overrides: DF.Table[VenueCartSettings] + refund_credit: DF.Check clear_item_booking_draft_duration: DF.Int confirm_booking_after_payment: DF.Check day_uom: DF.Link | None