feat: added coupon code functionality
This commit is contained in:
0
lms/lms/doctype/lms_coupon/__init__.py
Normal file
0
lms/lms/doctype/lms_coupon/__init__.py
Normal file
8
lms/lms/doctype/lms_coupon/lms_coupon.js
Normal file
8
lms/lms/doctype/lms_coupon/lms_coupon.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, Frappe and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("LMS Coupon", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
166
lms/lms/doctype/lms_coupon/lms_coupon.json
Normal file
166
lms/lms/doctype/lms_coupon/lms_coupon.json
Normal file
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "hash",
|
||||
"creation": "2025-10-11 21:39:11.456420",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"code",
|
||||
"discount_type",
|
||||
"percent_off",
|
||||
"amount_off",
|
||||
"active",
|
||||
"expires_on",
|
||||
"usage_limit",
|
||||
"times_redeemed",
|
||||
"description",
|
||||
"applicable_items"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "code",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Code",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "discount_type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Discount Type",
|
||||
"options": "Percent\nAmount",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.discount_type=='Percent'",
|
||||
"fieldname": "percent_off",
|
||||
"fieldtype": "Percent",
|
||||
"label": "Percent Off",
|
||||
"mandatory_depends_on": "eval:doc.discount_type=='Percent'"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.discount_type=='Amount'",
|
||||
"fieldname": "amount_off",
|
||||
"fieldtype": "Int",
|
||||
"label": "Amount Off",
|
||||
"mandatory_depends_on": "eval:doc.discount_type=='Amount'"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "active",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Active"
|
||||
},
|
||||
{
|
||||
"fieldname": "expires_on",
|
||||
"fieldtype": "Date",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Expires On"
|
||||
},
|
||||
{
|
||||
"fieldname": "usage_limit",
|
||||
"fieldtype": "Int",
|
||||
"label": "Usage Limit"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "times_redeemed",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Times Redeemed",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Description"
|
||||
},
|
||||
{
|
||||
"fieldname": "applicable_items",
|
||||
"fieldtype": "Table",
|
||||
"label": "Applicable Items",
|
||||
"options": "LMS Coupon Item",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-12 18:41:35.034875",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Coupon",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Administrator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Batch Evaluator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Course Creator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Moderator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
56
lms/lms/doctype/lms_coupon/lms_coupon.py
Normal file
56
lms/lms/doctype/lms_coupon/lms_coupon.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# Copyright (c) 2025, Frappe and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe import _
|
||||
from frappe.utils import nowdate
|
||||
|
||||
|
||||
class LMSCoupon(Document):
|
||||
def validate(self):
|
||||
# Normalize code to uppercase and strip spaces
|
||||
if self.code:
|
||||
self.code = self.code.strip().upper()
|
||||
|
||||
if not self.code:
|
||||
frappe.throw(_("Coupon code is required."))
|
||||
|
||||
# Ensure uniqueness of code (case-insensitive)
|
||||
if self.name:
|
||||
existing = frappe.db.exists(
|
||||
"LMS Coupon",
|
||||
{
|
||||
"code": self.code,
|
||||
"name": ["!=", self.name],
|
||||
},
|
||||
)
|
||||
else:
|
||||
existing = frappe.db.exists("LMS Coupon", {"code": self.code})
|
||||
if existing:
|
||||
frappe.throw(_("Coupon code already exists."))
|
||||
|
||||
if not self.discount_type:
|
||||
frappe.throw(_("Discount type is required."))
|
||||
|
||||
if self.discount_type == "Percent":
|
||||
if self.percent_off is None:
|
||||
frappe.throw(_("Percent Off is required for Percent discount type."))
|
||||
if not (0 < float(self.percent_off) <= 100):
|
||||
frappe.throw(_("Percent Off must be between 1 and 100."))
|
||||
# Clear the other field to avoid confusion
|
||||
self.amount_off = None
|
||||
|
||||
if self.discount_type == "Amount":
|
||||
if self.amount_off is None:
|
||||
frappe.throw(_("Amount Off is required for Amount discount type."))
|
||||
if float(self.amount_off) < 0:
|
||||
frappe.throw(_("Amount Off cannot be negative."))
|
||||
# Clear the other field
|
||||
self.percent_off = None
|
||||
|
||||
if self.usage_limit is not None and int(self.usage_limit) < 0:
|
||||
frappe.throw(_("Usage limit cannot be negative."))
|
||||
|
||||
if self.expires_on and str(self.expires_on) < nowdate():
|
||||
frappe.throw(_("Expiry date cannot be in the past."))
|
||||
22
lms/lms/doctype/lms_coupon/test_lms_coupon.py
Normal file
22
lms/lms/doctype/lms_coupon/test_lms_coupon.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Copyright (c) 2025, Frappe and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
|
||||
class IntegrationTestLMSCoupon(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for LMSCoupon.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
0
lms/lms/doctype/lms_coupon_item/__init__.py
Normal file
0
lms/lms/doctype/lms_coupon_item/__init__.py
Normal file
43
lms/lms/doctype/lms_coupon_item/lms_coupon_item.json
Normal file
43
lms/lms/doctype/lms_coupon_item/lms_coupon_item.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-10-11 21:45:00",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"reference_doctype",
|
||||
"reference_name"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Reference DocType",
|
||||
"options": "\nLMS Course\nLMS Batch",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_name",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Reference Name",
|
||||
"options": "reference_doctype",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-12 17:27:14.123811",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Coupon Item",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
9
lms/lms/doctype/lms_coupon_item/lms_coupon_item.py
Normal file
9
lms/lms/doctype/lms_coupon_item/lms_coupon_item.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2025, Frappe and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class LMSCouponItem(Document):
|
||||
pass
|
||||
@@ -18,6 +18,10 @@
|
||||
"payment_details_section",
|
||||
"currency",
|
||||
"amount",
|
||||
"coupon",
|
||||
"discount_type",
|
||||
"discount_percent",
|
||||
"discount_amount",
|
||||
"amount_with_gst",
|
||||
"column_break_yxpl",
|
||||
"order_id",
|
||||
@@ -47,6 +51,29 @@
|
||||
"options": "currency",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "coupon",
|
||||
"fieldtype": "Link",
|
||||
"label": "Coupon",
|
||||
"options": "LMS Coupon"
|
||||
},
|
||||
{
|
||||
"fieldname": "discount_type",
|
||||
"fieldtype": "Select",
|
||||
"label": "Discount Type",
|
||||
"options": "\nPercent\nAmount"
|
||||
},
|
||||
{
|
||||
"fieldname": "discount_percent",
|
||||
"fieldtype": "Percent",
|
||||
"label": "Discount Percent"
|
||||
},
|
||||
{
|
||||
"fieldname": "discount_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Discount Amount",
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
|
||||
@@ -19,23 +19,45 @@ def validate_currency(payment_gateway, currency):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_link(
|
||||
doctype,
|
||||
docname,
|
||||
title,
|
||||
amount,
|
||||
total_amount,
|
||||
currency,
|
||||
address,
|
||||
redirect_to,
|
||||
payment_for_certificate,
|
||||
doctype,
|
||||
docname,
|
||||
title,
|
||||
amount,
|
||||
total_amount,
|
||||
currency,
|
||||
address,
|
||||
redirect_to,
|
||||
payment_for_certificate,
|
||||
coupon_code=None,
|
||||
):
|
||||
payment_gateway = get_payment_gateway()
|
||||
address = frappe._dict(address)
|
||||
amount_with_gst = total_amount if total_amount != amount else 0
|
||||
amount_with_gst = total_amount if total_amount != amount else 0
|
||||
|
||||
payment = record_payment(
|
||||
address, doctype, docname, amount, currency, amount_with_gst, payment_for_certificate
|
||||
)
|
||||
coupon_context = None
|
||||
# Coupon application only for courses/batches
|
||||
if doctype in ["LMS Course", "LMS Batch"] and coupon_code:
|
||||
try:
|
||||
from lms.lms.utils import apply_coupon
|
||||
|
||||
applied = apply_coupon(doctype, docname, coupon_code)
|
||||
# Override total_amount based on validated coupon calculation
|
||||
total_amount = applied.get("amount", total_amount)
|
||||
coupon_context = applied
|
||||
except Exception:
|
||||
# Ignore coupon errors here; frontend handles validation
|
||||
pass
|
||||
|
||||
payment = record_payment(
|
||||
address,
|
||||
doctype,
|
||||
docname,
|
||||
amount,
|
||||
currency,
|
||||
amount_with_gst,
|
||||
payment_for_certificate,
|
||||
coupon_context,
|
||||
)
|
||||
controller = get_controller(payment_gateway)
|
||||
|
||||
payment_details = {
|
||||
@@ -61,13 +83,14 @@ def get_payment_link(
|
||||
|
||||
|
||||
def record_payment(
|
||||
address,
|
||||
doctype,
|
||||
docname,
|
||||
amount,
|
||||
currency,
|
||||
amount_with_gst=0,
|
||||
payment_for_certificate=0,
|
||||
address,
|
||||
doctype,
|
||||
docname,
|
||||
amount,
|
||||
currency,
|
||||
amount_with_gst=0,
|
||||
payment_for_certificate=0,
|
||||
coupon_context=None,
|
||||
):
|
||||
address = frappe._dict(address)
|
||||
address_name = save_address(address)
|
||||
@@ -89,6 +112,15 @@ def record_payment(
|
||||
"payment_for_certificate": payment_for_certificate,
|
||||
}
|
||||
)
|
||||
if coupon_context:
|
||||
payment_doc.update(
|
||||
{
|
||||
"coupon": coupon_context.get("coupon"),
|
||||
"discount_type": coupon_context.get("discount_type"),
|
||||
"discount_percent": coupon_context.get("discount_percent"),
|
||||
"discount_amount": coupon_context.get("discount_amount"),
|
||||
}
|
||||
)
|
||||
payment_doc.save(ignore_permissions=True)
|
||||
return payment_doc
|
||||
|
||||
|
||||
@@ -951,6 +951,77 @@ def get_current_exchange_rate(source, target="USD"):
|
||||
return details["rates"][target]
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def apply_coupon(doctype, docname, code, country=None):
|
||||
# Validate doctype
|
||||
if doctype not in ["LMS Course", "LMS Batch"]:
|
||||
frappe.throw(_("Invalid doctype for coupon application."))
|
||||
|
||||
if not code:
|
||||
frappe.throw(_("Coupon code is required."))
|
||||
|
||||
summary = get_order_summary(doctype, docname, country)
|
||||
|
||||
base_amount = summary.original_amount
|
||||
currency = summary.currency
|
||||
|
||||
# Fetch coupon case-insensitively
|
||||
coupon_name = frappe.db.get_value("LMS Coupon", {"code": code.strip().upper(), "active": 1}, "name")
|
||||
if not coupon_name:
|
||||
frappe.throw(_("Invalid or inactive coupon code."))
|
||||
|
||||
coupon = frappe.get_doc("LMS Coupon", coupon_name)
|
||||
|
||||
# Expiry
|
||||
if coupon.expires_on and getdate(coupon.expires_on) < getdate():
|
||||
frappe.throw(_("This coupon has expired."))
|
||||
|
||||
# Usage limit
|
||||
if coupon.usage_limit and cint(coupon.times_redeemed) >= cint(coupon.usage_limit):
|
||||
frappe.throw(_("This coupon has reached its usage limit."))
|
||||
|
||||
# Applicability (if rows exist, must match; if none, applies to all)
|
||||
applicable = True
|
||||
if len(coupon.applicable_items):
|
||||
applicable = any(
|
||||
(row.reference_doctype == doctype and row.reference_name == docname)
|
||||
for row in coupon.applicable_items
|
||||
)
|
||||
if not applicable:
|
||||
frappe.throw(_("This coupon is not applicable to this item."))
|
||||
|
||||
# Compute discount before tax
|
||||
discount_amount = 0
|
||||
if coupon.discount_type == "Percent":
|
||||
discount_amount = cint(flt(base_amount) * flt(coupon.percent_off) / 100)
|
||||
else:
|
||||
discount_amount = min(flt(coupon.amount_off), flt(base_amount))
|
||||
|
||||
subtotal = max(flt(base_amount) - flt(discount_amount), 0)
|
||||
|
||||
gst_applied = 0
|
||||
final_amount = subtotal
|
||||
if currency == "INR":
|
||||
final_amount, gst_applied = apply_gst(subtotal, country)
|
||||
|
||||
return {
|
||||
"title": summary.title,
|
||||
"name": summary.name,
|
||||
"currency": currency,
|
||||
"original_amount": base_amount,
|
||||
"original_amount_formatted": fmt_money(base_amount, 0, currency),
|
||||
"discount_amount": discount_amount,
|
||||
"discount_amount_formatted": fmt_money(discount_amount, 0, currency),
|
||||
"amount": final_amount,
|
||||
"gst_applied": gst_applied,
|
||||
"gst_amount_formatted": fmt_money(gst_applied, 0, currency) if gst_applied else None,
|
||||
"total_amount_formatted": fmt_money(final_amount, 0, currency),
|
||||
"coupon": coupon.name,
|
||||
"discount_type": coupon.discount_type,
|
||||
"discount_percent": coupon.percent_off if coupon.discount_type == "Percent" else None,
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def change_currency(amount, currency, country=None):
|
||||
amount = cint(amount)
|
||||
@@ -1867,6 +1938,18 @@ def update_payment_record(doctype, docname):
|
||||
"order_id": data.get("order_id"),
|
||||
},
|
||||
)
|
||||
|
||||
# Increment coupon usage if applicable
|
||||
coupon = frappe.db.get_value("LMS Payment", data.payment, "coupon")
|
||||
if coupon:
|
||||
frappe.db.sql(
|
||||
"""
|
||||
UPDATE `tabLMS Coupon`
|
||||
SET times_redeemed = COALESCE(times_redeemed, 0) + 1
|
||||
WHERE name = %s
|
||||
""",
|
||||
(coupon,),
|
||||
)
|
||||
payment_for_certificate = frappe.db.get_value("LMS Payment", data.payment, "payment_for_certificate")
|
||||
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user