Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
"enable_free_follow_ups",
"max_visits",
"valid_days",
"include_registration_fee",
"reg_item",
"reg_fee",
"inpatient_settings_section",
"allow_discharge_despite_unbilled_services",
"allow_discharge_despite_pending_healthcare_services",
Expand Down Expand Up @@ -509,11 +512,36 @@
"label": "Registration Item",
"mandatory_depends_on": "eval:doc.collect_registration_fee == 1",
"options": "Item"
},
{
"default": "0",
"description": "If enabled, new patients will be created in active state and the registration fee will be added together with the consultation fee in a single Sales Invoice instead of creating a separate registration invoice.",
"fieldname": "include_registration_fee",
"fieldtype": "Check",
"label": "Include Registration Fee with Consultation fee"
},
{
"depends_on": "eval:doc.include_registration_fee==1",
"fieldname": "reg_item",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Registration Item",
"mandatory_depends_on": "eval:doc.include_registration_fee==1",
"options": "Item"
},
{
"depends_on": "eval:doc.include_registration_fee==1",
"fieldname": "reg_fee",
"fieldtype": "Currency",
"label": "Registration Fee",
"mandatory_depends_on": "eval:doc.include_registration_fee==1",
"non_negative": 1,
"options": "Currency"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
],
"issingle": 1,
"links": [],
"modified": "2025-12-25 09:36:33.073374",
"modified": "2026-03-09 14:39:26.209372",
"modified_by": "Administrator",
"module": "Healthcare",
"name": "Healthcare Settings",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,30 @@ def validate(self):
if self.registration_fee <= 0:
frappe.throw(_("Registration Fee cannot be negative or zero"))

# registration fee fields: validate required service item and fee.
if (
self.meta.has_field("include_registration_fee")
and self.meta.has_field("reg_fee")
and self.meta.has_field("reg_item")
and self.get("include_registration_fee")
):
if not self.get("reg_item"):
frappe.throw(_("Please set a Registration Item"))

if not frappe.db.exists("Item", self.get("reg_item")):
frappe.throw(_("Registration Item {0} does not exist").format(self.get("reg_item")))

item_meta = frappe.get_meta("Item")
item_fields = ["is_stock_item"]
if item_meta.has_field("has_stock"):
item_fields.append("has_stock")
item_values = frappe.db.get_value("Item", self.get("reg_item"), item_fields, as_dict=True) or {}
if item_values.get("is_stock_item") or item_values.get("has_stock"):
frappe.throw(_("Configure a service Item for {0}").format(self.get("reg_item")))

if self.get("reg_fee") is not None and self.get("reg_fee") <= 0:
frappe.throw(_("Registration Fee cannot be negative or zero"))

if self.inpatient_visit_charge_item:
validate_service_item(self.inpatient_visit_charge_item)
if self.op_consulting_charge_item:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1383,7 +1383,32 @@ let make_payment = function (frm, automate_invoicing) {
}
}

function show_payment_dialog(frm, fields) {
async function show_payment_dialog(frm, fields) {
let registration_fee = 0;
let registration_data = (
await frappe.call({
method: "healthcare.healthcare.doctype.patient_appointment.patient_appointment.get_registration_fee_details",
args: {
appointment_name: frm.doc.name,
},
})
).message;

if (registration_data?.apply_registration_fee) {
registration_fee = flt(registration_data.registration_fee);
let total_payable_index = fields.findIndex(
field => field.fieldname === "total_payable",
);
if (total_payable_index > -1) {
fields.splice(total_payable_index, 0, {
label: "Registration Fee",
fieldname: "registration_fee",
fieldtype: "Currency",
read_only: true,
});
}
}

let d = new frappe.ui.Dialog({
title: "Enter Payment Details",
fields: fields,
Expand Down Expand Up @@ -1439,11 +1464,15 @@ let make_payment = function (frm, automate_invoicing) {
}
};
d.get_secondary_btn().attr("disabled", true);
d.set_values({
let dialog_values = {
patient: frm.doc.patient_name,
consultation_charge: frm.doc.paid_amount,
total_payable: frm.doc.paid_amount,
});
total_payable: flt(frm.doc.paid_amount) + registration_fee,
};
if (d.get_field("registration_fee")) {
dialog_values.registration_fee = registration_fee;
}
d.set_values(dialog_values);

if (frm.doc.appointment_for == "Practitioner") {
d.set_value("practitioner", frm.doc.practitioner_name);
Expand All @@ -1467,7 +1496,9 @@ let make_payment = function (frm, automate_invoicing) {
let message = "";
let discount_percentage = d.get_value("discount_percentage");
let discount_amount = d.get_value("discount_amount");
let consultation_charge = d.get_value("consultation_charge");
let consultation_charge = flt(d.get_value("consultation_charge"));
let registration_fee = flt(d.get_value("registration_fee")) || 0;
let total_charge = consultation_charge + registration_fee;

if (field === "discount_percentage") {
if (discount_percentage > 100 || discount_percentage < 0) {
Expand All @@ -1479,26 +1510,26 @@ let make_payment = function (frm, automate_invoicing) {
if (discount_percentage && discount_amount) {
d.set_value("discount_amount", 0);
}
discount_amount = consultation_charge * (discount_percentage / 100);
discount_amount = total_charge * (discount_percentage / 100);

d.set_values({
discount_amount: discount_amount,
total_payable: consultation_charge - discount_amount,
total_payable: total_charge - discount_amount,
}).then(() => delete frm.via_discount_percentage);
}
} else if (field === "discount_amount") {
if (consultation_charge < discount_amount || discount_amount < 0) {
if (total_charge < discount_amount || discount_amount < 0) {
d.get_primary_btn().attr("disabled", true);
message =
"Discount amount should not be more than Consultation Charge";
message = "Discount amount should not be more than Total Charge";
} else {
d.get_primary_btn().attr("disabled", false);
if (!frm.via_discount_percentage) {
discount_percentage =
(discount_amount / consultation_charge) * 100;
discount_percentage = total_charge
? (discount_amount / total_charge) * 100
: 0;
d.set_values({
discount_percentage: discount_percentage,
total_payable: consultation_charge - discount_amount,
total_payable: total_charge - discount_amount,
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from frappe.model.mapper import get_mapped_doc
from frappe.utils import (
add_to_date,
cint,
flt,
format_date,
get_datetime,
Expand Down Expand Up @@ -544,6 +545,16 @@ def invoice_appointment(appointment_name, discount_percentage=0, discount_amount
update_fee_validity(appointment_doc)


@frappe.whitelist()
def get_registration_fee_details(appointment_name: str) -> dict:
appointment_doc = frappe.get_doc("Patient Appointment", appointment_name)
registration_context = get_registration_fee_context(appointment_doc)
return {
"apply_registration_fee": registration_context.get("apply_registration_fee"),
"registration_fee": registration_context.get("registration_fee"),
}


def create_sales_invoice(appointment_doc, discount_percentage=0, discount_amount=0):
sales_invoice = frappe.new_doc("Sales Invoice")
sales_invoice.patient = appointment_doc.patient
Expand All @@ -556,19 +567,25 @@ def create_sales_invoice(appointment_doc, discount_percentage=0, discount_amount
item = sales_invoice.append("items", {})
item = get_appointment_item(appointment_doc, item)

paid_amount = flt(appointment_doc.paid_amount)
registration_context = get_registration_fee_context(appointment_doc)
registration_fee = flt(registration_context.get("registration_fee"))
paid_amount = flt(appointment_doc.paid_amount) + registration_fee

if registration_context.get("apply_registration_fee"):
reg_item = sales_invoice.append("items", {})
reg_item = get_registration_item(appointment_doc, reg_item, registration_context)
# Set discount amount and percentage if entered in payment popup
if flt(discount_percentage):
sales_invoice.additional_discount_percentage = flt(discount_percentage)
paid_amount = flt(appointment_doc.paid_amount) - (
flt(appointment_doc.paid_amount) * (flt(discount_percentage) / 100)
)
paid_amount = paid_amount - (paid_amount * (flt(discount_percentage) / 100))
paid_amount = max(0, paid_amount)
if flt(discount_amount):
sales_invoice.discount_amount = flt(discount_amount)
paid_amount = flt(appointment_doc.paid_amount) - flt(discount_amount)
paid_amount = paid_amount - flt(discount_amount)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
paid_amount = max(0, paid_amount)

# Add payments if payment details are supplied else proceed to create invoice as Unpaid
if appointment_doc.mode_of_payment and appointment_doc.paid_amount:
if appointment_doc.mode_of_payment and paid_amount:
sales_invoice.is_pos = 1
payment = sales_invoice.append("payments", {})
payment.mode_of_payment = appointment_doc.mode_of_payment
Expand Down Expand Up @@ -631,6 +648,66 @@ def get_appointment_item(appointment_doc, item):
return item


def get_registration_item(appointment_doc, item, registration_context):
item.item_code = registration_context.get("registration_item")
item.description = _("Registration Charges")
item.income_account = get_income_account(appointment_doc.practitioner, appointment_doc.company)
item.cost_center = frappe.get_cached_value("Company", appointment_doc.company, "cost_center")
item.rate = flt(registration_context.get("registration_fee"))
item.amount = flt(registration_context.get("registration_fee"))
item.qty = 1
item.reference_dt = "Patient"
item.reference_dn = appointment_doc.patient
return item
Comment on lines +651 to +661

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Use an appointment-scoped reference before auto-canceling the invoice.

The registration row is linked as ("Patient", patient), and the cancellation path treats those rows as safe to discard. That makes unrelated patient-scoped charges indistinguishable from the registration fee, and the current has_other_references check also ignores extra unreferenced rows entirely. This can cancel invoices that still contain other billable items.

Suggested fix
 def get_registration_item(appointment_doc, item, registration_context):
 	item.item_code = registration_context.get("registration_item")
 	item.description = _("Registration Charges")
 	item.income_account = get_income_account(appointment_doc.practitioner, appointment_doc.company)
 	item.cost_center = frappe.get_cached_value("Company", appointment_doc.company, "cost_center")
 	item.rate = flt(registration_context.get("registration_fee"))
 	item.amount = flt(registration_context.get("registration_fee"))
 	item.qty = 1
-	item.reference_dt = "Patient"
-	item.reference_dn = appointment_doc.patient
+	item.reference_dt = "Patient Appointment"
+	item.reference_dn = appointment_doc.name
 	return item
 def cancel_sales_invoice(sales_invoice):
 	if frappe.db.get_single_value("Healthcare Settings", "show_payment_popup"):
 		has_appointment_reference_item = any(
 			item.reference_dt == "Patient Appointment" and item.reference_dn == sales_invoice.appointment
 			for item in sales_invoice.items
 		)
-		has_other_references = any(
-			(item.reference_dt or item.reference_dn)
-			and not (
-				(item.reference_dt == "Patient Appointment" and item.reference_dn == sales_invoice.appointment)
-				or (item.reference_dt == "Patient" and item.reference_dn == sales_invoice.patient)
-			)
-			for item in sales_invoice.items
-		)
+		has_other_items = any(
+			not (
+				item.reference_dt == "Patient Appointment"
+				and item.reference_dn == sales_invoice.appointment
+			)
+			for item in sales_invoice.items
+		)
 
-		if has_appointment_reference_item and not has_other_references:
+		if has_appointment_reference_item and not has_other_items:
 			if sales_invoice.docstatus.is_submitted():
 				sales_invoice.cancel()
 			return True
 	return False

Also applies to: 749-763

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@healthcare/healthcare/doctype/patient_appointment/patient_appointment.py`
around lines 650 - 660, The registration item currently sets
reference_dt/reference_dn to ("Patient", patient), which makes patient-scoped
rows indistinguishable from other patient charges when auto-canceling invoices;
change get_registration_item to set reference_dt = "Appointment" and
reference_dn = appointment_doc.name so the registration row is scoped to the
appointment. Also update the invoice cancellation logic (the code that uses
has_other_references) to treat rows with no appointment-scoped reference as
billable (i.e., do not ignore unreferenced rows) and ensure has_other_references
checks for any non-appointment reference_dt/reference_dn or empty references
before allowing auto-cancel; apply the same change for the other registration
helper at the 749-763 region.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The registration fee item usually has reference to Patient Doctype and patient name instead of patient appointment in Sales Invoice. This is also in the case of Collect Registration Fee Option



def get_registration_fee_context(appointment_doc):
settings = get_registration_fee_settings()
if not settings.get("include_registration_fee"):
return {"apply_registration_fee": False, "registration_fee": 0, "registration_item": None}

reg_fee = flt(settings.get("reg_fee"))
if not settings.get("reg_item") or reg_fee <= 0:
return {"apply_registration_fee": False, "registration_fee": 0, "registration_item": None}

is_new_patient = check_is_new_patient_by_invoice(appointment_doc.patient)
if not is_new_patient:
return {"apply_registration_fee": False, "registration_fee": 0, "registration_item": None}

return {
"apply_registration_fee": True,
"registration_fee": reg_fee,
"registration_item": settings.get("reg_item"),
}


def get_registration_fee_settings():
settings = {"include_registration_fee": 0, "reg_item": None, "reg_fee": 0}
meta = frappe.get_meta("Healthcare Settings")
required_fields = ("include_registration_fee", "reg_item", "reg_fee")
if not all(meta.has_field(fieldname) for fieldname in required_fields):
# Custom fields may not exist in all sites.
return settings

settings["include_registration_fee"] = cint(
frappe.db.get_single_value("Healthcare Settings", "include_registration_fee")
)
settings["reg_item"] = frappe.db.get_single_value("Healthcare Settings", "reg_item")
settings["reg_fee"] = flt(frappe.db.get_single_value("Healthcare Settings", "reg_fee"))
return settings
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def check_is_new_patient_by_invoice(patient):
has_submitted_invoice = frappe.db.exists(
"Sales Invoice",
{
"docstatus": 1,
"patient": patient,
},
)
return not bool(has_submitted_invoice)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def cancel_appointment(appointment_id):
appointment = frappe.get_doc("Patient Appointment", appointment_id)

Expand All @@ -646,7 +723,7 @@ def cancel_appointment(appointment_id):

if appointment.invoiced:
sales_invoice = check_sales_invoice_exists(appointment)
if sales_invoice and cancel_sales_invoice(sales_invoice):
if sales_invoice and cancel_sales_invoice(sales_invoice, appointment.name):
msg = _("Appointment {0} and Sales Invoice {1} cancelled").format(
appointment.name, sales_invoice.name
)
Expand All @@ -672,12 +749,39 @@ def cancel_appointment(appointment_id):
frappe.msgprint(msg)


def cancel_sales_invoice(sales_invoice):
if frappe.db.get_single_value("Healthcare Settings", "show_payment_popup"):
if len(sales_invoice.items) == 1:
if sales_invoice.docstatus.is_submitted():
sales_invoice.cancel()
return True
def cancel_sales_invoice(sales_invoice, appointment_name=None):
if not frappe.db.get_single_value("Healthcare Settings", "show_payment_popup"):
return False

if not appointment_name:
appointment_name = next(
(
item.reference_dn
for item in sales_invoice.items
if item.reference_dt == "Patient Appointment" and item.reference_dn
),
None,
)

if not appointment_name:
return False

has_appointment_reference_item = any(
item.reference_dt == "Patient Appointment" and item.reference_dn == appointment_name
for item in sales_invoice.items
)
has_other_references = any(
not (
(item.reference_dt == "Patient Appointment" and item.reference_dn == appointment_name)
or (item.reference_dt == "Patient" and item.reference_dn == sales_invoice.patient)
)
for item in sales_invoice.items
)

if has_appointment_reference_item and not has_other_references:
if sales_invoice.docstatus.is_submitted():
sales_invoice.cancel()
return True
return False
Comment thread
coderabbitai[bot] marked this conversation as resolved.


Expand Down
Loading