# data[].attributes.body
# Auto approve nếu chưa

if doc.application_status != "Approved":

doc.application_status = "Approved"

doc.save()

# Kiểm tra đã có Student chưa

if not doc.student:

# Tạo Student

student = frappe.new_doc("Student")

student.first_name = doc.first_name

student.last_name = doc.last_name

student.date_of_birth = doc.date_of_birth

student.gender = doc.gender

student.email = doc.email_id

student.student_batch = doc.student_batch

student.append("guardians", {

"guardian": doc.guardian_name

})

student.save()

# Gán lại student_id vào applicant

doc.student = student.name

doc.save()

# Tự động enroll vào các lớp theo batch

if doc.student_batch:

student_groups = frappe.get_all(

"Student Group",

filters={"student_batch": doc.student_batch},

fields=["name"]

)

for group in student_groups:

group_doc = frappe.get_doc("Student Group", group.name)

if not any(s.student == student.name for s in group_doc.students):

group_doc.append("students", {"student": student.name})

group_doc.save()

# data[].attributes.body
filters = {

"enabled": 1,

"academic_year": academic_year

}

if student_category:

filters["student_category"] = student_category

students = frappe.get_all("Student", filters=filters, fields=["name", "student_name"])

student_list = []

for s in students:

s.update({

"student": s.pop("name"), # Đổi key name thành student

"active": 1

})

student_list.append(s)

return student_list

# data[].attributes.body
# Server Script: Auto apply Fee Discount

student = frappe.get_doc("Student", doc.student)

entry_date = student.enrollment_date

education_level = student.education_level

payment_type = doc.payment_type

entry_time = "After 3/2022" if entry_date > frappe.utils.getdate("2022-03-31") else "Before 3/2022"

policy = frappe.get_all("Fee Discount Policy", filters={

"entry_time": entry_time,

"payment_type": payment_type,

"education_level": education_level,

"valid_from": ["<=", frappe.utils.nowdate()],

"valid_to": [">=", frappe.utils.nowdate()]

}, fields=["name", "discount_percent"], limit=1)

if policy:

discount = policy[0]["discount_percent"]

total = 0

for component in doc.components:

component.amount = component.amount * (1 - discount / 100)

total += component.amount

doc.final_fee_amount = total

doc.discount_policy = policy[0]["name"]

# data[].attributes.body
base_url = frappe.utils.get_url()

accept_url = f"{base_url}/api/method/your.custom.app.accept_applicant?token={doc.parent_response_token}"

reject_url = f"{base_url}/api/method/your.custom.app.reject_applicant?token={doc.parent_response_token}"

# Gửi email

frappe.sendmail(

recipients=[doc.email_id],

subject="Xác nhận nhập học",

message=f"""

Kính gửi quý phụ huynh,<br><br>

Học sinh <b>{doc.student_name}</b> đã hoàn thành bài kiểm tra đầu vào.<br><br>

Quý phụ huynh vui lòng xác nhận:<br><br>

✅ <a href="{accept_url}">Đồng ý nhập học</a><br>

❌ <a href="{reject_url}">Không đồng ý</a><br><br>

Xin cảm ơn!

"""

)

# data[].attributes.body
Kính gửi quý phụ huynh,<br><br>

Học sinh <b>{{ doc.student_applicant.student_name }}</b> đã vượt qua bài kiểm tra đầu vào.<br><br>

Vui lòng xác nhận nhập học:<br><br>

✅ <a href="{{ frappe.utils.get_url() }}/api/method/your_app.api.accept_applicant?token={{ doc.student_applicant.parent_response_token }}">Đồng ý nhập học</a><br>

❌ <a href="{{ frappe.utils.get_url() }}/api/method/your_app.api.reject_applicant?token={{ doc.student_applicant.parent_response_token }}">Không đồng ý nhập học</a><br><br>

Xin cảm ơn!

# data[].attributes.body
import frappe

@frappe.whitelist(allow_guest=True)

def accept_applicant(token):

applicant = frappe.get_all("Student Applicant", filters={"parent_response_token": token}, limit=1)

if not applicant:

return "Invalid or expired token."

doc = frappe.get_doc("Student Applicant", applicant[0].name)

doc.parent_response_status = "Accepted"

doc.application_status = "Approved"

doc.save()

return "✅ Phụ huynh đã đồng ý nhập học. Trạng thái đã được cập nhật."

@frappe.whitelist(allow_guest=True)

def reject_applicant(token):

applicant = frappe.get_all("Student Applicant", filters={"parent_response_token": token}, limit=1)

if not applicant:

return "Invalid or expired token."

doc = frappe.get_doc("Student Applicant", applicant[0].name)

doc.parent_response_status = "Rejected"

doc.application_status = "Rejected"

doc.save()

return "❌ Phụ huynh đã từ chối nhập học. Trạng thái đã được cập nhật."

# data[].attributes.body
# HTML cảm ơn

html = f"""

<html>

<head><title>Đồng ý nhập học</title></head>

<body style="font-family: sans-serif; text-align: center; padding-top: 100px;">

<h1>🎉 Cảm ơn bạn!</h1>

<p>Học sinh <strong>{doc.first_name} {doc.last_name or ""}</strong> đã được đồng ý nhập học.</p>

<p>Chúng tôi sẽ sớm liên hệ lại với bạn.</p>

</body>

</html>

"""

return html

# data[].attributes.body
<a href="{{ frappe.utils.get_url() }}/api/method/education.education.api.accept_applicant?token={{ doc.custom_response_token }}">Đồng ý</a>

<a href="{{ frappe.utils.get_url() }}/api/method/education.education.api.reject_applicant?token={{ doc.custom_response_token }}">Từ chối</a>

# data[].attributes.body
.exceptions.UpdateAfterSubmitError:  Not allowed to change Parent Response status after submission from Pending to Accepted

# data[].attributes.body
@frappe.whitelist(allow_guest=True)

def accept_applicant(token):

applicant_list = frappe.get_all(

"Student Applicant",

filters={"parent_response_token": token},

fields=["name"],

limit=1

)

if not applicant_list:

return frappe.respond_as_web_page(

title="Lỗi xác nhận",

html="<h2>🚫 Token không hợp lệ hoặc đã hết hạn.</h2>",

http_status_code=400

)

doc = frappe.get_doc("Student Applicant", applicant_list[0]["name"])

if doc.docstatus != 0:

return frappe.respond_as_web_page(

title="Không thể cập nhật",

html="<h2>⚠️ Hồ sơ đã được xử lý trước đó.</h2>"

)

doc.parent_response_status = "Accepted"

doc.application_status = "Approved"

doc.save()

doc.submit()

return frappe.respond_as_web_page(

title="Xác nhận thành công",

html="<h1>✅ Cảm ơn bạn đã xác nhận!</h1><p>Hồ sơ của bạn đã được duyệt và chuyển sang bước tiếp theo.</p>"

)

# data[].attributes.body
doc.cancel() # Hủy submit

doc.some_field = "New value"

doc.save()

doc.submit()

# data[].attributes.body
if not selected_class:

return frappe.respond_as_web_page(

title="Không còn lớp trống",

html="<h3>Rất tiếc, tất cả các lớp đã đầy. Nhà trường sẽ liên hệ với quý phụ huynh sau.</h3>"

)

# Ghi danh vào lớp

enrollment = frappe.get_doc({

"doctype": "Student Group Student",

"student_group": selected_class,

"student": student.name

})

enrollment.insert(ignore_permissions=True)

# Gửi email xác nhận

frappe.sendmail(

recipients=[doc.email_id],

subject="✅ Xác nhận ghi danh thành công",

message=f"""

<h2>🎉 Xin chúc mừng!</h2>

<p>Học sinh <strong>{student.student_name}</strong> đã được ghi danh thành công.</p>

<ul>

<li><b>Mã học sinh:</b> {student.name}</li>

<li><b>Khối:</b> {student.student_batch}</li>

<li><b>Lớp:</b> {selected_class}</li>

</ul>

<p>Nhà trường sẽ sớm liên hệ để hướng dẫn các bước tiếp theo.</p>

"""

)

# data[].attributes.headline
# 📌 Cách đúng: Mở Student Group và append vào child table students

# data[].attributes.subject
# 📌 Cách đúng: Mở Student Group và append vào child table students

# data[].attributes.body
student_group = frappe.get_doc("Student Group", selected_class)

student_group.append("students", {

"student": student.name,

"student_name": student.student_name

})

student_group.save(ignore_permissions=True)

# data[].attributes.body
# 5. Enroll Student vào Student Batch
    student_batch_student = frappe.get_doc({
        "doctype": "Student Batch Student",
        "student_batch": doc.student_batch,
        "student": student.name,
        "student_name": student.student_name
    })
    student_batch_student.insert(ignore_permissions=True)

    # 6. Tìm lớp phù hợp (Student Group)
    class_list = frappe.get_all(
        "Student Group",
        filters={"student_batch": doc.student_batch},
        fields=["name"],
        order_by="creation asc"
    )

# data[].attributes.body
@frappe.whitelist(allow_guest=True)

def accept_applicant(token):

applicant_list = frappe.get_all("Student Applicant",

filters={"response_token": token},

fields=["name"],

limit=1

)

if not applicant_list:

return frappe.respond_as_web_page(

title="Lỗi xác nhận",

html="<h2>🚫 Token không hợp lệ hoặc đã hết hạn.</h2>",

http_status_code=400

)

doc = frappe.get_doc("Student Applicant", applicant_list[0].name)

if doc.docstatus == 1:

doc.flags.ignore_submit = True

doc.db_set("parent_response_status", "Accepted", update_modified=False)

doc.db_set("application_status","Approved",update_modified=False)

doc.save(ignore_permissions = True)

frappe.db.commit()

#doc.submit()

student = frappe.get_doc({

"doctype" : "Student" ,

"first_name" : doc.first_name,

"student_applicant" : doc.name ,

"batch" : doc.student_batch,

"enabled" : 1

})

student.insert(ignore_permissions= True)

student.save()

program_enrollment = frappe.get_all(

"Program Enrollment",

filters={"student": student.name},

limit= 1

)

program_name = doc.program or "Default Program"

if not program_enrollment:

program_enrollment= frappe.get_doc({

"doctype": "Program Enrollment",

"student": student.name,

"student_batch_name" : doc.student_batch,

"program" : program_name,

"academic_year" : doc.academic_year,

"status": "Active"

})

program_enrollment.insert(ignore_permissions=True)

program_enrollment.submit()

class_list = frappe.get_all("Student Group",

filters ={"batch": doc.student_batch},

fields=["name"],

order_by ="creation asc")

selected_class = None

for group in class_list:

current_count = frappe.db.count("Student Group Student",

{"parent": group["name"],

"parenttype": "Student Group" })

max_limit = frappe.db.get_value("Student Group", group["name"], "max_strength") or 30

if current_count < max_limit:

selected_class = group["name"]

break

if not selected_class:

return frappe.respond_as_web_page(

title="Không còn lớp trống",

html="<h3>Rất tiếc, tất cả các lớp đã đầy. Nhà trường sẽ liên hệ với quý phụ huynh sau.</h3>"

)

student_group = frappe.get_doc("Student Group", selected_class)

student_group.append("students", {

"student": student.name,

"student_name": student.student_name,

})

# student_group.add_student(student.name)

student_group.save(ignore_permissions=True)

# # Gửi email xác nhận

frappe.sendmail(

recipients=[doc.student_email_id],

subject="✅ Xác nhận ghi danh thành công",

message=f"""

<h2>🎉 Xin chúc mừng!</h2>

<p>Học sinh <strong>{student.first_name}</strong> đã được ghi danh thành công.</p>

<ul>

<li><b>Mã học sinh:</b> {student.name}</li>

<li><b>Khối:</b> {student.batch}</li>

<li><b>Lớp:</b> {selected_class}</li>

</ul>

<p>Nhà trường sẽ sớm liên hệ để hướng dẫn các bước tiếp theo.</p>

"""

)

return frappe.respond_as_web_page(

title="Không thể cập nhật",

html="<h2>⚠️ Hồ sơ đã được xử lý trước đó.</h2>"

)

# data[].attributes.body
def generate_password():

chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

password = ""

from random import randint

for i in range(8):

password += chars[randint(0, len(chars) - 1)]

return password

# data[].attributes.body
for s in students:

if not s.student_email_id:

continue

if not frappe.db.exists("User Permission", {

"user": s.student_email_id,

"allow": "Student",

"for_value": s.name

}):

frappe.get_doc({

"doctype": "User Permission",

"user": s.student_email_id,

"allow": "Student",

"for_value": s.name

}).insert(ignore_permissions=True)

# data[].attributes.body
# Kiểm tra nếu Fee được tạo từ Fee Schedule

if not doc.flags.created_via_fee_schedule:

return

# Lấy thông tin học sinh

student = frappe.get_doc("Student", doc.student)

admission_date = student.admission_date

education_level = student.education_level # VD: "Mầm non", "Tiểu học"

payment_mode = student.payment_mode # Custom field: "thang", "ky", "nam"

# Ngày mốc so sánh

cutoff_date = frappe.utils.getdate("2022-03-01")

# Khởi tạo mức giảm

discount = 0

# Xác định chính sách

if admission_date > cutoff_date:

# Nhập sau 3/2022 → bắt buộc đóng theo kỳ

if payment_mode == "nam":

if education_level == "Tiểu học":

discount = 0.03

elif education_level == "Mầm non":

discount = 0.04

else:

# Nhập trước 3/2022 → được đóng tháng, kỳ, năm

if payment_mode == "ky":

if education_level == "Mầm non":

discount = 0.03

elif education_level == "Tiểu học":

discount = 0.04

elif payment_mode == "nam":

discount = 0.08

# Áp dụng giảm vào từng component

for comp in doc.components:

original = comp.amount

comp.amount = original * (1 - discount)

# data[].attributes.body
if doc.flags.created_via_fee_schedule and not doc.discount_policy:

fee_schedule = frappe.get_doc("Fee Schedule", doc.fee_schedule)

if fee_schedule.discount_policy:

doc.discount_policy = fee_schedule.discount_policy

# Nếu có chính sách, áp dụng giảm học phí

if doc.discount_policy:

policy = frappe.get_doc("Tuition Discount Policy", doc.discount_policy)

discount = 0

if policy.discount_type == "percent":

discount = policy.discount_value / 100

elif policy.discount_type == "fixed":

discount = -policy.discount_value

# Áp dụng giảm vào components

for comp in doc.components:

if comp.amount:

if discount >= 0 and discount < 1: # percent

comp.amount = round(comp.amount * (1 - discount), 0)

elif discount < 0: # fixed giảm trực tiếp dòng đầu tiên

if comp.idx == 1:

comp.amount = round(comp.amount + discount, 0) # discount < 0 nên dùng +

# data[].attributes.body
def apply_discount_policy_from_schedule(doc):

if not doc.fee_schedule:

return

fee_schedule = frappe.get_doc("Fee Schedule", doc.fee_schedule)

if not fee_schedule.discount_policy:

return

policy = frappe.get_doc("Tuition Fee Discount Policy", fee_schedule.discount_policy)

if not policy.is_active:

return

# Thông tin học sinh

student = frappe.get_doc("Student", doc.student)

admission_date = student.get("admission_date")

student_level = student.get("student_group") # hoặc ánh xạ theo cấp học bạn định nghĩa

payment_method = doc.get("payment_method")

# Kiểm tra cấp học

applicable_levels = [lvl.education_level for lvl in policy.levels]

if student_level not in applicable_levels:

return

# Kiểm tra ngày nhập học (nếu có điều kiện)

if policy.admission_before:

is_after = admission_date >= policy.admission_before

if "sau" in policy.policy_name.lower() and not is_after:

return

if "trước" in policy.policy_name.lower() and is_after:

return

# Kiểm tra phương thức thanh toán (nếu có)

if policy.payment_method and policy.payment_method != payment_method:

return

# Áp dụng giảm giá

total = 0

for row in doc.fee_structure_details:

if not row.is_tuition_fee:

row.discount_applied = 0

row.final_amount = row.amount

total += row.amount

continue

# Giảm học phí

if policy.discount_type == "Percent":

discount = row.amount * policy.discount_value / 100

else:

discount = min(row.amount, policy.discount_value)

row.discount_applied = discount

row.final_amount = row.amount - discount

total += row.final_amount

doc.grand_total = total

apply_discount_policy_from_schedule(doc)

# data[].attributes.body
def calculate_discounted_fee(doc, method):

if not doc.fee_structure or not doc.discount_policy or not doc.student_group:

return

fee_structure = frappe.get_doc("Fee Structure", doc.fee_structure)

policy = frappe.get_doc("Tuition Fee Discount Policy", doc.discount_policy)

tuition_fee = 0

for row in fee_structure.components:

if row.is_tuition_fee:

tuition_fee += row.amount or 0

if policy.discount_type == "Percent":

discount = tuition_fee * (policy.discount_value or 0) / 100

else:

discount = min(tuition_fee, policy.discount_value or 0)

final_per_student = tuition_fee - discount

student_count = frappe.db.count("Student", {"student_group": doc.student_group})

grand_total = final_per_student * student_count

# Tách riêng gán ra

doc.total_amount_per_student = final_per_student

doc.grand_total = grand_total

# data[].attributes.body
frappe.ui.form.on('Fee Component', {
  amount: function (frm, cdt, cdn) {
    let d = locals[cdt][cdn]
    d.total = d.amount
    refresh_field('components')
    if (d.discount) {
      d.total = d.amount - d.amount * (d.discount / 100)
      refresh_field('components')
    }
  },
  discount: function (frm, cdt, cdn) {
    let d = locals[cdt][cdn]
    if (d.discount <= 100) {
      d.total = d.amount - d.amount * (d.discount / 100)
    }
    refresh_field('components')
  },
})

# data[].attributes.body
def validate_fee_components(self):
		fee_schedule_components = [d.fees_category for d in self.components]

		fee_structure_components = frappe.get_all(
			"Fee Component",
			pluck="fees_category",
			filters={"parent": self.fee_structure},
		)

		for component in fee_schedule_components:
			if component not in fee_structure_components:
				frappe.msgprint(
					_("Fee Component {0} is not part of Fee Structure {1}").format(
						component, frappe.bold(getlink("Fee Structure", self.fee_structure))
					),
					alert=True,
				)

	def validate_total_against_fee_strucuture(self):
		fee_schedules_total = (
			frappe.db.get_all(
				"Fee Schedule",
				filters={"fee_structure": self.fee_structure},
				fields=["sum(total_amount) as total"],
			)[0]["total"]
			or 0
		)
		fee_structure_total = (
			frappe.db.get_value("Fee Structure", self.fee_structure, "total_amount") or 0
		)

		if fee_schedules_total > fee_structure_total:
			frappe.msgprint(
				_("Total amount of Fee Schedules exceeds the Total Amount of Fee Structure"),
				alert=True,
			)

# data[].attributes.body
frappe.ready(function () {

toggle_grade_fields();

$('[data-fieldname="program"]').on('change', function () {

toggle_grade_fields();

});

function toggle_grade_fields() {

const program = $('[data-fieldname="program"] select').val();

const show = program === "THPT";

const fields_to_toggle = [

"grade_6_score",

"grade_7_score",

"grade_8_score",

"grade_9_score"

];

fields_to_toggle.forEach(fieldname => {

const field = $(`[data-fieldname="${fieldname}"]`);

if (show) {

field.show();

} else {

field.hide();

}

});

}

});

# data[].attributes.body
# Lấy instructor cũ trước khi lưu

old_doc = frappe.get_doc("Student Group", doc.name)

old_instructors = {i.instructor for i in old_doc.instructors}

# Lấy instructor hiện tại sau khi lưu

new_instructors = {i.instructor for i in doc.instructors}

# Xác định instructor mới được thêm

added_instructors = new_instructors - old_instructors

# Gửi email cho instructor mới

for instructor_id in added_instructors:

instructor = frappe.get_doc("Instructor", instructor_id)

if instructor.email:

subject = "Thông báo: Bạn đã được thêm vào nhóm học sinh"

message = f"""

Kính gửi {instructor.instructor_name or instructor.name},<br><br>

Bạn đã được thêm vào nhóm học sinh <b>{doc.name}</b> trong hệ thống ERP.<br><br>

Trân trọng,<br>

Phòng đào tạo

"""

frappe.sendmail(

recipients=instructor.email,

subject=subject,

message=message

)

# data[].attributes.body
@frappe.whitelist(allow_guest=True)

def accept_applicant(token):

placement_doctypes = [

"Kindergarten Entrance Test",

"Primary Entrance Test",

"Highschool Admission Evaluation"

]

doc = None

for doctype in placement_doctypes:

result = frappe.db.get_value(doctype,

{"response_token": token , "result" : "Pass"},

["name", "student_fullname"],

as_dict=True)

if result:

doc = frappe.get_doc("Student Applicant" , result.name)

break

if not doc:

return frappe.respond_as_web_page(

title="Lỗi xác nhận",

html="<h2>🚫 Not found student applicant.</h2>",

http_status_code=400

)

# doc = frappe.get_doc("Student Applicant", applicant_list[0].name)

if doc.docstatus == 1:

doc.flags.ignore_submit = True

doc.db_set("parent_response_status", "Accepted", update_modified=False)

doc.db_set("application_status","Approved",update_modified=False)

doc.save(ignore_permissions = True)

frappe.db.commit()

#doc.submit()

student = frappe.get_doc({

"doctype" : "Student" ,

"first_name" : doc.first_name,

"student_email_id" : doc.student_email_id,

"student_applicant" : doc.name ,

"program" : doc.program,

"batch" : doc.student_batch,

"payment_method" : doc.payment_mode,

"enabled" : 1

})

student.insert(ignore_permissions= True)

student.save()

student.submit()

frappe.db.commit()

program_enrollment = frappe.get_all(

"Program Enrollment",

filters={"student": student.name},

limit= 1

)

program_name = doc.program or "Default Program"

if not program_enrollment:

program_enrollment= frappe.get_doc({

"doctype": "Program Enrollment",

"student": student.name,

"student_batch_name" : doc.student_batch,

"program" : program_name,

"academic_year" : doc.academic_year,

"status": "Active"

})

program_enrollment.insert(ignore_permissions=True)

program_enrollment.submit()

frappe.db.commit()

class_list = frappe.get_all("Student Group",

filters ={"batch": doc.student_batch},

fields=["name"],

order_by ="creation asc")

selected_class = None

for group in class_list:

current_count = frappe.db.count("Student Group Student",

{"parent": group["name"],

"parenttype": "Student Group" })

max_limit = frappe.db.get_value("Student Group", group["name"], "max_strength")

if current_count < max_limit:

selected_class = group["name"]

break

if not selected_class:

return frappe.respond_as_web_page(

title="Không còn lớp trống",

html="<h3>Rất tiếc, tất cả các lớp đã đầy. Nhà trường sẽ liên hệ với quý phụ huynh sau.</h3>"

)

student_group = frappe.get_doc("Student Group", selected_class)

student_group.append("students", {

"student": student.name,

"student_name": student.first_name,

})

# student_group.add_student(student.name)

student_group.save(ignore_permissions=True)

frappe.db.commit()

return frappe.respond_as_web_page(

title="Cập nhật",

html="<h2>✅ Xác nhận ghi danh thành công</h2>"

)

# data[].attributes.body
vnp_Url = "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html"

vnp_TmnCode = frappe.db.get_single_value("Education Settings", "vnp_tmn_code") # mã Terminal

vnp_HashSecret = frappe.db.get_single_value("Education Settings", "vnp_hash_secret") # secret để ký

vnp_ReturnUrl = frappe.utils.get_url("/api/method/education.education.billing.handle_vnpay_callback")

vnp_Params = {

"vnp_Version": "2.1.0",

"vnp_Command": "pay",

"vnp_TmnCode": vnp_TmnCode,

"vnp_Amount": int(amount * 100), # x100 theo yêu cầu VNPAY

"vnp_CurrCode": "VND",

"vnp_TxnRef": order_id,

"vnp_OrderInfo": f"Payment for invoice {docname}",

"vnp_OrderType": "other",

"vnp_Locale": "vn",

"vnp_ReturnUrl": vnp_ReturnUrl,

"vnp_IpAddr": frappe.local.request_ip or "127.0.0.1",

"vnp_CreateDate": datetime.now().strftime("%Y%m%d%H%M%S"),

}

sorted_params = sorted(vnp_Params.items())

query_string = '&'.join([f"{k}={urllib.parse.quote_plus(str(v))}" for k, v in sorted_params])

# Ký SHA256

hash_data = '&'.join([f"{k}={v}" for k, v in sorted_params])

secure_hash = hmac.new(vnp_HashSecret.encode(), hash_data.encode(), hashlib.sha256).hexdigest()

payment_url = f"{vnp_Url}?{query_string}&vnp_SecureHashType=SHA256&vnp_SecureHash={secure_hash}"

return {

"payment_url": payment_url

}

# data[].attributes.body
@frappe.whitelist(allow_guest=True)

def get_payment_options(doctype, docname, phone, currency=None):

if not frappe.db.exists(doctype, docname):

frappe.throw(_("Invalid document provided."))

validate_phone_number(phone_number=phone, throw=True)

details = get_details(docname)

vnp_Url = "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html"

# vnp_TmnCode = frappe.db.get_single_value("Education Settings", "vnp_tmn_code") # mã Terminal

# vnp_HashSecret = frappe.db.get_single_value("Education Settings", "vnp_hash_secret") # secret để ký

vnp_TmnCode = "NJJ0R8FS"

vnp_HashSecret = "BYKJBHPPZKQMKBIBGGXIYKWYFAYSJXCM"

vnp_ReturnUrl = frappe.utils.get_url("/api/method/education.education.billing.handle_vnpay_callback")

vnp_Params = {

"vnp_Version": "2.1.0",

"vnp_Command": "pay",

"vnp_TmnCode": vnp_TmnCode,

"vnp_Amount": int(details.outstanding_amount * 100), # x100 theo yêu cầu VNPAY

"vnp_CurrCode": "VND",

"vnp_TxnRef": details["name"], # Mã đơn hàng, có thể là ID của invoice

"vnp_OrderInfo": f"INV-{docname}",

"vnp_OrderType": "other",

"vnp_Locale": "vn",

"vnp_CreateDate": datetime.now().strftime("%Y%m%d%H%M%S"),

"vnp_IpAddr": frappe.local.request_ip or "127.0.0.1",

"vnp_ReturnUrl": vnp_ReturnUrl,

}

# if not vnp_Params.get("vnp_Bankcode"):

# vnp_Params["vnp_BankCode"].set("VNPAYQR") # Mã ngân hàng, nếu không có thì dùng mã QR

sorted_params = sorted(vnp_Params.items())

query_string = '&'.join([f"{k}={urllib.parse.quote_plus(str(v))}" for k, v in sorted_params])

print("Query String:", query_string)

# Ký SHA256

hash_data = '&'.join([f"{k}={str(v)}" for k, v in sorted_params])

print("Hash Data:", hash_data)

secure_hash = hmac.new(vnp_HashSecret.encode(), hash_data.encode(), hashlib.sha512).hexdigest()

print("Secure Hash:", secure_hash)

payment_url = f"{vnp_Url}?{query_string}&vnp_SecureHashType=SHA512&vnp_SecureHash={secure_hash}"

return {

"payment_url": payment_url

}

# data[].attributes.headline
Terminal ID / Mã Website (vnp_TmnCode): NCVDVNZ5

# data[].attributes.subject
Terminal ID / Mã Website (vnp_TmnCode): NCVDVNZ5

# data[].attributes.body
Secret Key / Chuỗi bí mật tạo checksum (vnp_HashSecret): V1VFO5UWJ97BIFH9IFX683WJYU5WB51W

# data[].attributes.body
@frappe.whitelist(allow_guest=True)

def handle_vnpay_callback(**kwargs):

import hmac, hashlib

vnp_HashSecret = frappe.db.get_single_value("Education Settings", "vnp_hash_secret")

vnp_data = {k: v for k, v in frappe.local.form_dict.items() if k.startswith("vnp_")}

vnp_secure_hash = vnp_data.pop("vnp_SecureHash", None)

vnp_secure_hash_type = vnp_data.pop("vnp_SecureHashType", None)

sorted_data = sorted(vnp_data.items())

hash_data = '&'.join(f"{k}={v}" for k, v in sorted_data)

computed_hash = hmac.new(

vnp_HashSecret.encode(),

hash_data.encode(),

hashlib.sha512

).hexdigest()

if computed_hash == vnp_secure_hash:

# Success → xử lý update trạng thái, ghi log, v.v.

frappe.logger().info("✅ VNPAY callback verified successfully")

return "Confirm Success"

else:

frappe.logger().error("❌ Invalid VNPAY secure hash")

return "Invalid Signature"

# data[].attributes.body
@frappe.whitelist(allow_guest=True)

def get_payment_options(doctype, docname, phone, currency=None):

if not frappe.db.exists(doctype, docname):

frappe.throw(_("Invalid document provided."))

validate_phone_number(phone_number=phone, throw=True)

details = get_details(docname)

vnp_Url = "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html"

# vnp_TmnCode = frappe.db.get_single_value("Education Settings", "vnp_tmn_code") # mã Terminal

# vnp_HashSecret = frappe.db.get_single_value("Education Settings", "vnp_hash_secret") # secret để ký

vnp_TmnCode = "NCVDVNZ5"

vnp_HashSecret = "V1VFO5UWJ97BIFH9IFX683WJYU5WB51W"

vnp_ReturnUrl = frappe.utils.get_url("/api/method/education.education.billing.handle_vnpay_callback")

vnp_Params = {

"vnp_Version": "2.1.0",

"vnp_Command": "pay",

"vnp_TmnCode": vnp_TmnCode,

"vnp_Amount": int(details.outstanding_amount * 100), # x100 theo yêu cầu VNPAY

"vnp_CurrCode": "VND",

"vnp_TxnRef": details.name, # Mã đơn hàng, có thể là ID của invoice

"vnp_OrderInfo": f"Test-{docname}",

"vnp_OrderType": "other",

"vnp_Locale": "vn",

"vnp_CreateDate": datetime.now().strftime("%Y%m%d%H%M%S"),

"vnp_ExpireDate": (datetime.now() + timedelta(minutes=3600)).strftime("%Y%m%d%H%M%S"),

"vnp_IpAddr": frappe.get_request_header("X-Forwarded-For") or frappe.local.request_ip ,

"vnp_ReturnUrl": vnp_ReturnUrl,

}

sorted_params = sorted(vnp_Params.items())

# Ký SHA512

print("Sorted Params:", sorted_params)

hash_data = '&'.join([f"{k}={str(v)}" for k, v in sorted_params])

print("Hash Data:", hash_data)

secure_hash = hmac.new(vnp_HashSecret.encode(), hash_data.encode(), hashlib.sha512).hexdigest()

print("Secure Hash:", secure_hash)

query_string = '&'.join([f"{k}={urllib.parse.quote_plus(str(v))}" for k, v in sorted_params])

print("Query String:", query_string)

payment_url = f"{vnp_Url}?{query_string}&vnp_SecureHash={secure_hash}"

print ("Payment URL:", payment_url)

return {

"payment_url": payment_url

}

# data[].attributes.body
@frappe.whitelist(allow_guest=True)

def get_payment_options(doctype, docname, phone, currency=None):

if not frappe.db.exists(doctype, docname):

frappe.throw(_("Invalid document provided."))

validate_phone_number(phone_number=phone, throw=True)

details = get_details(docname)

vnp_Url = "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html"

# vnp_TmnCode = frappe.db.get_single_value("Education Settings", "vnp_tmn_code") # mã Terminal

# vnp_HashSecret = frappe.db.get_single_value("Education Settings", "vnp_hash_secret") # secret để ký

vnp_TmnCode = "NCVDVNZ5"

vnp_HashSecret = "V1VFO5UWJ97BIFH9IFX683WJYU5WB51W"

vnp_ReturnUrl = frappe.utils.get_url("/api/method/education.education.billing.handle_vnpay_callback")

vnp_Params = {

"vnp_Version": "2.1.0",

"vnp_Command": "pay",

"vnp_TmnCode": vnp_TmnCode,

"vnp_Amount": int(details.outstanding_amount * 100), # x100 theo yêu cầu VNPAY

"vnp_CurrCode": "VND",

"vnp_TxnRef": details.name, # Mã đơn hàng, có thể là ID của invoice

"vnp_OrderInfo": f"Test-{docname}",

"vnp_OrderType": "other",

"vnp_Locale": "vn",

"vnp_CreateDate": datetime.now().strftime("%Y%m%d%H%M%S"),

"vnp_ExpireDate": (datetime.now() + timedelta(minutes=3600)).strftime("%Y%m%d%H%M%S"),

"vnp_IpAddr": frappe.get_request_header("X-Forwarded-For") or frappe.local.request_ip ,

"vnp_ReturnUrl": vnp_ReturnUrl,

}

sorted_params = sorted(vnp_Params.items())

# Ký SHA512

print("Sorted Params:", sorted_params)

hash_data = '&'.join([f"{k}={str(v)}" for k, v in sorted_params])

print("Hash Data:", hash_data)

secure_hash = hmac.new(vnp_HashSecret.encode(), hash_data.encode(), hashlib.sha512).hexdigest()

print("Secure Hash:", secure_hash)

query_string = '&'.join([f"{k}={urllib.parse.quote_plus(str(v))}" for k, v in sorted_params])

print("Query String:", query_string)

payment_url = f"{vnp_Url}?{query_string}&vnp_SecureHash={secure_hash}"

print ("Payment URL:", payment_url)

return {

"payment_url": payment_url

}

# data[].attributes.body
// Ví dụ với Axios

import axios from 'axios'

async function handleMomoPayment(doctype, docname, phone) {

try {

const response = await axios.post('/api/method/education.education.billing.get_payment_options', {

doctype: doctype,

docname: docname,

phone: phone

});

const paymentUrl = response.data.message.payment_url;

if (paymentUrl) {

// Redirect người dùng đến link thanh toán MOMO

window.location.href = paymentUrl;

} else {

alert("Không thể tạo link thanh toán.");

}

} catch (error) {

console.error("Payment error:", error);

alert("Có lỗi xảy ra khi tạo thanh toán MOMO.");

}

}

# data[].attributes.body
import requests

import hmac

import hashlib

import json

import time

from datetime import datetime

import uuid

import frappe

from frappe import _

@frappe.whitelist(allow_guest=True)

def get_payment_options(doctype, docname, phone, currency=None):

if not frappe.db.exists(doctype, docname):

frappe.throw(_("Invalid document provided."))

validate_phone_number(phone_number=phone, throw=True)

details = get_details(docname)

# MOMO CONFIG

partner_code = "MOMOXNXX20210701"

access_key = "F8BBA842ECF85"

secret_key = "K951B6PE1waDMi640xX08PD3vg6EkVlz"

endpoint = "https://test-payment.momo.vn/v2/gateway/api/create"

redirect_url = frappe.utils.get_url("/api/method/education.education.billing.handle_momo_callback")

ipn_url = frappe.utils.get_url("/api/method/education.education.billing.handle_momo_ipn")

order_id = str(uuid.uuid4())

request_id = str(uuid.uuid4())

amount = str(int(details.outstanding_amount))

order_info = f"Thanh toán hóa đơn {docname}"

extra_data = ""

raw_signature = f"accessKey={access_key}&amount={amount}&extraData={extra_data}&ipnUrl={ipn_url}" \

f"&orderId={order_id}&orderInfo={order_info}&partnerCode={partner_code}" \

f"&redirectUrl={redirect_url}&requestId={request_id}&requestType=captureWallet"

# Tạo chữ ký SHA256

signature = hmac.new(secret_key.encode(), raw_signature.encode(), hashlib.sha256).hexdigest()

payload = {

"partnerCode": partner_code,

"accessKey": access_key,

"requestId": request_id,

"amount": amount,

"orderId": order_id,

"orderInfo": order_info,

"redirectUrl": redirect_url,

"ipnUrl": ipn_url,

"extraData": extra_data,

"requestType": "captureWallet",

"signature": signature,

"lang": "vi"

}

# Gửi request đến MOMO

response = requests.post(endpoint, json=payload, headers={"Content-Type": "application/json"})

if response.status_code != 200:

frappe.throw(_("Momo Payment Gateway Error"))

res_json = response.json()

if res_json.get("payUrl"):

return {"payment_url": res_json["payUrl"]}

else:

frappe.throw(_("Unable to create payment link. Please try again."))

# data[].attributes.body
function openPaymentGateway(close) {

if (!billingDetails.mobile_number || !billingDetails.email) {

validateFields()

return

}

paymentOptions.submit(

{},

{

onSuccess(data) {

if(data.payment_url){

window.location.href = data.payment_url

}else{

alert('Payment URL not found')

}

},

onError(err) {

showError(err)

},

}

)

}

# data[].attributes.body
import requests

import base64

import json

PAYPAL_CLIENT_ID = frappe.conf.paypal_client_id

PAYPAL_SECRET = frappe.conf.paypal_secret

BASE_URL = "https://api-m.sandbox.paypal.com" # Chuyển thành api-m.paypal.com khi live

def get_access_token():

auth = base64.b64encode(f"{PAYPAL_CLIENT_ID}:{PAYPAL_SECRET}".encode()).decode()

res = requests.post(f"{BASE_URL}/v1/oauth2/token",

headers={"Authorization": f"Basic {auth}"},

data={"grant_type": "client_credentials"}

)

return res.json()["access_token"]

@frappe.whitelist(allow_guest=True)

def create_order(amount, currency="USD"):

token = get_access_token()

res = requests.post(f"{BASE_URL}/v2/checkout/orders",

headers={

"Authorization": f"Bearer {token}",

"Content-Type": "application/json",

},

json={

"intent": "CAPTURE",

"purchase_units": [{

"amount": {

"currency_code": currency,

"value": f"{int(amount)/100:.2f}"

}

}],

"application_context": {

"return_url": "https://your-site.com/paypal-success",

"cancel_url": "https://your-site.com/paypal-cancel"

}

}

)

return res.json()

# data[].attributes.body
AaQ_mVsRRWtq0EEHuvWDU9TB88SjQQ2bM3QUS29eRUIPvnc_F6ipz3MeEe6ZhjZk_A-sl-sm7nYPGUZe

EOh27KYnQgW4hSqD41SgO2jqpnL8d_rwoOLg0jprpQT8YJZ71SUxr1gGgRcUfH3M20aTqYWBs-mm1-er

# data[].attributes.body
@frappe.whitelist(allow_guest=True)

def get_payment_options(doctype, docname, phone, currency="USD"):

if not frappe.db.exists(doctype, docname):

frappe.throw(_("Invalid document provided."))

validate_phone_number(phone_number=phone, throw=True)

details = get_details(docname)

# Convert amount to USD or use your logic

amount = round(float(details.outstanding_amount), 2)

# Tạo access token

access_token = get_paypal_access_token()

# Tạo order

payload = {

"intent": "CAPTURE",

"purchase_units": [{

"reference_id": docname,

"amount": {

"currency_code": currency,

"value": f"{amount:.2f}"

},

"description": f"Payment for {docname}"

}],

"application_context": {

"brand_name": "Your Brand",

"landing_page": "LOGIN",

"user_action": "PAY_NOW",

"return_url": frappe.utils.get_url("/paypal/success"),

"cancel_url": frappe.utils.get_url("/paypal/cancel")

}

}

headers = {

"Content-Type": "application/json",

"Authorization": f"Bearer {access_token}"

}

res = requests.post(f"{PAYPAL_BASE_URL}/v2/checkout/orders", headers=headers, json=payload)

res.raise_for_status()

paypal_data = res.json()

# Lấy approve URL

approval_url = next(

(link["href"] for link in paypal_data.get("links", []) if link["rel"] == "approve"),

None

)

if not approval_url:

frappe.throw(_("Could not get approval link from PayPal."))

return {

"payment_url": approval_url

}

# data[].attributes.body
sb-ud9kd30222426@personal.example.com

lsE]3T>_

# data[].attributes.body
import frappe

import requests

import base64

import json

PAYPAL_CLIENT_ID = frappe.conf.paypal_client_id

PAYPAL_SECRET = frappe.conf.paypal_secret

PAYPAL_BASE_URL = "https://api-m.sandbox.paypal.com" # Sandbox; đổi thành live URL khi lên production.

def get_paypal_access_token():

auth = base64.b64encode(f"{PAYPAL_CLIENT_ID}:{PAYPAL_SECRET}".encode()).decode()

r = requests.post(

f"{PAYPAL_BASE_URL}/v1/oauth2/token",

headers={ "Authorization": f"Basic {auth}" },

data={ "grant_type": "client_credentials" }

)

r.raise_for_status()

return r.json().get("access_token")

@frappe.whitelist(allow_guest=True)

def paypal_return():

data = frappe.local.form_dict

order_id = data.get("token")

payer_id = data.get("PayerID")

if not order_id or not payer_id:

frappe.logger().error(f"Missing token or PayerID: {data}")

frappe.respond_as_web_page(

title="Payment Error",

html="<p>Không tìm thấy token hoặc PayerID từ PayPal.</p>",

indicator_color="red",

http_status_code=400,

)

return

# 1. Lấy access token

try:

access_token = get_paypal_access_token()

except Exception as e:

frappe.logger().error(f"Could not retrieve PayPal access token: {e}")

frappe.respond_as_web_page(

title="Payment Error",

html="<p>Lỗi khi kết nối PayPal. Vui lòng thử lại sau.</p>",

indicator_color="red",

http_status_code=500,

)

return

# 2. Gọi capture

capture_url = f"{PAYPAL_BASE_URL}/v2/checkout/orders/{order_id}/capture"

headers = {

"Content-Type": "application/json",

"Authorization": f"Bearer {access_token}"

}

try:

resp = requests.post(capture_url, headers=headers)

resp.raise_for_status()

except requests.HTTPError as http_err:

frappe.logger().error(f"PayPal capture HTTP error: {http_err} | Response: {resp.text}")

frappe.respond_as_web_page(

title="Payment Failed",

html=f"<p>Thanh toán không thành công: {resp.text}</p>",

indicator_color="red",

http_status_code=402,

)

return

except Exception as ex:

frappe.logger().error(f"PayPal capture exception: {ex}")

frappe.respond_as_web_page(

title="Payment Error",

html="<p>Đã xảy ra lỗi khi xác nhận giao dịch.</p>",

indicator_color="red",

http_status_code=500,

)

return

capture_data = resp.json()

frappe.logger().info(f"PayPal Capture Response: {json.dumps(capture_data)}")

# 3. Kiểm tra kết quả capture

status = capture_data.get("status")

if status == "COMPLETED":

# Ví dụ: Lấy transaction ID, amount, invoice (nếu có)

purchase_units = capture_data.get("purchase_units", [])

# Thông thường ta sẽ có purchase_units[0].payments.captures[0].id, .status, .amount

capture_info = purchase_units[0].get("payments", {}).get("captures", [])[0]

transaction_id = capture_info.get("id")

payment_amount = capture_info.get("amount", {}).get("value")

currency = capture_info.get("amount", {}).get("currency_code")

# TODO: Ở đây bạn sẽ update trong ERPNext, ví dụ:

# - Ghi nhận Payment Entry (API của ERPNext)

# - Đánh dấu Invoice/Order là đã thanh toán

# - Gửi email xác nhận cho phụ huynh

frappe.logger().info(f"Payment completed. Transaction ID: {transaction_id}, Amount: {payment_amount} {currency}")

return frappe.respond_as_web_page(

title="Payment Successful",

html="<p>Thanh toán thành công! Cảm ơn bạn đã sử dụng dịch vụ.</p>",

indicator_color="green"

)

else:

# Nếu không hoàn thành (ví dụ: PAYER_ACTION_REQUIRED, etc)

frappe.logger().error(f"PayPal capture not completed: {capture_data}")

return frappe.respond_as_web_page(

title="Payment Not Completed",

html="<p>Giao dịch chưa hoàn thành. Vui lòng kiểm tra lại.</p>",

indicator_color="orange"

)

# data[].attributes.body
sb-ud9kd30222426@personal.example.com

lsE]3T>_

# data[].attributes.body
frappe.ui.form.on('Sales Invoice', {

currency(frm) {

if (frm.doc.currency && frm.doc.currency !== frappe.boot.sysdefaults.currency) {

frappe.call({

method: "erpnext.setup.utils.get_exchange_rate",

args: {

from_currency: frappe.boot.sysdefaults.currency,

to_currency: frm.doc.currency,

transaction_date: frm.doc.posting_date || frappe.datetime.get_today()

},

callback(r) {

if (!r.exc) {

let rate = r.message;

frm.doc.items.forEach(item => {

item.rate = flt(item.rate / rate, 2);

item.price_list_rate = item.rate;

item.amount = flt(item.rate * item.qty, 2);

});

frm.doc.conversion_rate = rate;

frm.refresh_field("items");

frm.refresh_field("conversion_rate");

}

}

});

}

}

});

# data[].attributes.body
<table class="table table-bordered">

<thead>

<tr>

<th>Item</th>

<th>Qty</th>

<th>Rate (VND)</th>

<th>Amount (VND)</th>

{% if doc.print_exchange_rate %}

<th>Rate (USD)</th>

<th>Amount (USD)</th>

{% endif %}

</tr>

</thead>

<tbody>

{% for row in doc.items %}

<tr>

<td>{{ row.item_name }}</td>

<td>{{ row.qty }}</td>

<td class="text-right">{{ row.rate | round(0) }}</td>

<td class="text-right">{{ row.amount | round(0) }}</td>

{% if doc.print_exchange_rate %}

<td class="text-right">{{ (row.rate / doc.print_exchange_rate) | round(2) }}</td>

<td class="text-right">{{ (row.amount / doc.print_exchange_rate) | round(2) }}</td>

{% endif %}

</tr>

{% endfor %}

</tbody>

</table>

{% if doc.print_exchange_rate %}

<hr>

<p><strong>Tổng tiền bằng USD:</strong> {{ (doc.grand_total / doc.print_exchange_rate) | round(2) }} USD</p>

<p><em>(Tỷ giá quy đổi: 1 USD = {{ doc.print_exchange_rate | round(2) }} VND)</em></p>

{% endif %}

# data[].attributes.body
url = "https://api.vnappmob.com/api/v2/exchange_rate/tcb"

response = requests.get(url)

<api_key|scope=exchange_rate|permission=0>

https://api.exchangerate.host/latest?base=USD&symbols=VND

https://api.exchangerate.host/convert?from=USD&to=VND

dac5d4f1c4d2e5cd85d7400ae45d2849