+
+
+ {{ __('Payment for ') }} {{ type }}:
+
+
+ {{ orderSummary.data.title }}
+
-
- {{ orderSummary.data.title }}
+
+
+ {{ __('Original Amount') }}:
+
+
+ {{ orderSummary.data.original_amount_formatted }}
+
+
+
+
{{ __('Discount') }}:
+
- {{ orderSummary.data.discount_amount_formatted }}
+
+
+
+ {{ __('GST Amount') }}:
+
+
+ {{ orderSummary.data.gst_amount_formatted }}
+
+
+
+
+ {{ __('Total') }}:
+
+
+ {{ orderSummary.data.total_amount_formatted }}
+
-
-
- {{ __('Original Amount') }}
-
-
- {{ orderSummary.data.original_amount_formatted }}
-
-
-
-
- {{ __('GST Amount') }}
-
-
- {{ orderSummary.data.gst_amount_formatted }}
-
-
-
-
- {{ __('Total') }}
-
-
- {{ orderSummary.data.total_amount_formatted }}
+
+
+
+ {{ __('Enter a Coupon Code') }}:
+
+
+
+
+
@@ -112,7 +147,7 @@
/>
@@ -157,11 +192,13 @@ import {
Breadcrumbs,
usePageMeta,
toast,
+ call,
} from 'frappe-ui'
-import { reactive, inject, onMounted, computed } from 'vue'
+import { reactive, inject, onMounted, computed, ref } from 'vue'
import { sessionStore } from '../stores/session'
import Link from '@/components/Controls/Link.vue'
import NotPermitted from '@/components/NotPermitted.vue'
+import { X } from 'lucide-vue-next'
const user = inject('$user')
const { brand } = sessionStore()
@@ -205,6 +242,7 @@ const orderSummary = createResource({
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
docname: props.name,
country: billingDetails.country,
+ coupon: appliedCoupon.value,
}
},
onError(err) {
@@ -212,6 +250,7 @@ const orderSummary = createResource({
},
})
+const appliedCoupon = ref(null)
const billingDetails = reactive({})
const setBillingDetails = (data) => {
@@ -231,17 +270,21 @@ const setBillingDetails = (data) => {
const paymentLink = createResource({
url: 'lms.lms.payments.get_payment_link',
makeParams(values) {
- return {
+ let data = {
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
docname: props.name,
title: orderSummary.data.title,
amount: orderSummary.data.original_amount,
- total_amount: orderSummary.data.amount,
+ discount_amount: orderSummary.data.discount_amount || 0,
+ gst_amount: orderSummary.data.gst_applied || 0,
currency: orderSummary.data.currency,
address: billingDetails,
redirect_to: redirectTo.value,
payment_for_certificate: props.type == 'certificate',
+ coupon_code: appliedCoupon.value,
+ coupon: orderSummary.data.coupon,
}
+ return data
},
})
@@ -265,6 +308,19 @@ const generatePaymentLink = () => {
)
}
+function applyCouponCode() {
+ if (!appliedCoupon.value) {
+ toast.error(__('Please enter a coupon code'))
+ return
+ }
+ orderSummary.reload()
+}
+
+function removeCoupon() {
+ appliedCoupon.value = null
+ orderSummary.reload()
+}
+
const validateAddress = () => {
let mandatoryFields = [
'billing_name',
@@ -329,8 +385,6 @@ const validateAddress = () => {
!states.includes(billingDetails.state)
)
return 'Please enter a valid state with correct spelling and the first letter capitalized.'
-
- console.log('validation address')
}
const showError = (err) => {
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index da293473..19ac95c4 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -2099,15 +2099,6 @@
"@vueuse/shared" "12.8.2"
vue "^3.5.13"
-"@vueuse/core@^14.0.0":
- version "14.0.0"
- resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-14.0.0.tgz#a3d9520935a191b167cb91e08f698545e46bf0a6"
- integrity sha512-d6tKRWkZE8IQElX2aHBxXOMD478fHIYV+Dzm2y9Ag122ICBpNKtGICiXKOhWU3L1kKdttDD9dCMS4bGP3jhCTQ==
- dependencies:
- "@types/web-bluetooth" "^0.0.21"
- "@vueuse/metadata" "14.0.0"
- "@vueuse/shared" "14.0.0"
-
"@vueuse/metadata@10.11.1":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.11.1.tgz#209db7bb5915aa172a87510b6de2ca01cadbd2a7"
@@ -2118,11 +2109,6 @@
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-12.8.2.tgz#6cb3a4e97cdcf528329eebc1bda73cd7f64318d3"
integrity sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==
-"@vueuse/metadata@14.0.0":
- version "14.0.0"
- resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-14.0.0.tgz#139231dc8503f172a7a45ce1ceaa7a415befbf3c"
- integrity sha512-6yoGqbJcMldVCevkFiHDBTB1V5Hq+G/haPlGIuaFZHpXC0HADB0EN1ryQAAceiW+ryS3niUwvdFbGiqHqBrfVA==
-
"@vueuse/router@^12.7.0":
version "12.8.2"
resolved "https://registry.yarnpkg.com/@vueuse/router/-/router-12.8.2.tgz#3792eab50493e50a79767592a52f6c5bb441ef33"
@@ -2145,11 +2131,6 @@
dependencies:
vue "^3.5.13"
-"@vueuse/shared@14.0.0":
- version "14.0.0"
- resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-14.0.0.tgz#15a424285fd6d453d1a99d1caba8cc293992868d"
- integrity sha512-mTCA0uczBgurRlwVaQHfG0Ja7UdGe4g9mwffiJmvLiTtp1G4AQyIjej6si/k8c8pUwTfVpNufck+23gXptPAkw==
-
"@yr/monotone-cubic-spline@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz#7272d89f8e4f6fb7a1600c28c378cc18d3b577b9"
@@ -2329,9 +2310,9 @@ base64-js@^1.3.1:
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.8.25:
- version "2.8.25"
- resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz#947dc6f81778e0fa0424a2ab9ea09a3033e71109"
- integrity sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==
+ version "2.8.26"
+ resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.26.tgz#2c7e2f840f0ae4d83782bdfe795229a43dfc3e59"
+ integrity sha512-73lC1ugzwoaWCLJ1LvOgrR5xsMLTqSKIEoMHVtL9E/HNk0PXtTM76ZIm84856/SF7Nv8mPZxKoBsgpm0tR1u1Q==
binary-extensions@^2.0.0:
version "2.3.0"
@@ -4274,9 +4255,9 @@ prosemirror-trailing-node@^3.0.0:
escape-string-regexp "^4.0.0"
prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.10.3, prosemirror-transform@^1.7.3:
- version "1.10.4"
- resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz#56419eac14f9f56612c806ae46f9238648f3f02e"
- integrity sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==
+ version "1.10.5"
+ resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz#4cf9fe5dcbdbfebd62499f24386e7cec9bc9979b"
+ integrity sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==
dependencies:
prosemirror-model "^1.21.0"
diff --git a/lms/lms/doctype/lms_coupon/__init__.py b/lms/lms/doctype/lms_coupon/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lms/lms/doctype/lms_coupon/lms_coupon.js b/lms/lms/doctype/lms_coupon/lms_coupon.js
new file mode 100644
index 00000000..4e406d49
--- /dev/null
+++ b/lms/lms/doctype/lms_coupon/lms_coupon.js
@@ -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) {
+
+// },
+// });
diff --git a/lms/lms/doctype/lms_coupon/lms_coupon.json b/lms/lms/doctype/lms_coupon/lms_coupon.json
new file mode 100644
index 00000000..26dcb5a9
--- /dev/null
+++ b/lms/lms/doctype/lms_coupon/lms_coupon.json
@@ -0,0 +1,190 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "hash",
+ "creation": "2025-10-11 21:39:11.456420",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "enabled",
+ "section_break_spfj",
+ "code",
+ "expires_on",
+ "column_break_mptc",
+ "discount_type",
+ "percentage_discount",
+ "fixed_amount_discount",
+ "section_break_ixxu",
+ "usage_limit",
+ "column_break_dcvj",
+ "redemption_count",
+ "section_break_ophm",
+ "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,
+ "in_standard_filter": 1,
+ "label": "Discount Type",
+ "options": "Percentage\nFixed Amount",
+ "reqd": 1
+ },
+ {
+ "fieldname": "expires_on",
+ "fieldtype": "Date",
+ "in_standard_filter": 1,
+ "label": "Expires On"
+ },
+ {
+ "fieldname": "usage_limit",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "Usage Limit"
+ },
+ {
+ "fieldname": "applicable_items",
+ "fieldtype": "Table",
+ "label": "Applicable Items",
+ "options": "LMS Coupon Item",
+ "reqd": 1
+ },
+ {
+ "default": "1",
+ "fieldname": "enabled",
+ "fieldtype": "Check",
+ "in_standard_filter": 1,
+ "label": "Enabled"
+ },
+ {
+ "fieldname": "column_break_mptc",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_ixxu",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_dcvj",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_ophm",
+ "fieldtype": "Section Break"
+ },
+ {
+ "depends_on": "eval:doc.discount_type=='Percentage'",
+ "fieldname": "percentage_discount",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "Percentage Discount",
+ "mandatory_depends_on": "eval:doc.discount_type=='Percentage'"
+ },
+ {
+ "depends_on": "eval:doc.discount_type=='Fixed Amount'",
+ "fieldname": "fixed_amount_discount",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "Fixed Amount Discount",
+ "mandatory_depends_on": "eval:doc.discount_type=='Fixed Amount'"
+ },
+ {
+ "default": "0",
+ "fieldname": "redemption_count",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "Redemption Count",
+ "read_only": 1
+ },
+ {
+ "fieldname": "section_break_spfj",
+ "fieldtype": "Section Break"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-10-27 19:52:11.835042",
+ "modified_by": "sayali@frappe.io",
+ "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": [],
+ "title_field": "code"
+}
diff --git a/lms/lms/doctype/lms_coupon/lms_coupon.py b/lms/lms/doctype/lms_coupon/lms_coupon.py
new file mode 100644
index 00000000..1054692b
--- /dev/null
+++ b/lms/lms/doctype/lms_coupon/lms_coupon.py
@@ -0,0 +1,31 @@
+# Copyright (c) 2025, Frappe and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.utils import cint, nowdate
+
+
+class LMSCoupon(Document):
+ def validate(self):
+ self.convert_to_uppercase()
+ self.validate_expiry_date()
+ self.validate_applicable_items()
+ self.validate_usage_limit()
+
+ def convert_to_uppercase(self):
+ if self.code:
+ self.code = self.code.strip().upper()
+
+ def validate_expiry_date(self):
+ if self.expires_on and str(self.expires_on) < nowdate():
+ frappe.throw(_("Expiry date cannot be in the past"))
+
+ def validate_applicable_items(self):
+ if not self.get("applicable_items") or len(self.get("applicable_items")) == 0:
+ frappe.throw(_("At least one applicable item is required"))
+
+ def validate_usage_limit(self):
+ if self.usage_limit is not None and cint(self.usage_limit) < 0:
+ frappe.throw(_("Usage limit cannot be negative"))
diff --git a/lms/lms/doctype/lms_coupon/test_lms_coupon.py b/lms/lms/doctype/lms_coupon/test_lms_coupon.py
new file mode 100644
index 00000000..58e26892
--- /dev/null
+++ b/lms/lms/doctype/lms_coupon/test_lms_coupon.py
@@ -0,0 +1,20 @@
+# 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
diff --git a/lms/lms/doctype/lms_coupon_item/__init__.py b/lms/lms/doctype/lms_coupon_item/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lms/lms/doctype/lms_coupon_item/lms_coupon_item.json b/lms/lms/doctype/lms_coupon_item/lms_coupon_item.json
new file mode 100644
index 00000000..359b3910
--- /dev/null
+++ b/lms/lms/doctype/lms_coupon_item/lms_coupon_item.json
@@ -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": []
+}
diff --git a/lms/lms/doctype/lms_coupon_item/lms_coupon_item.py b/lms/lms/doctype/lms_coupon_item/lms_coupon_item.py
new file mode 100644
index 00000000..ca44dbef
--- /dev/null
+++ b/lms/lms/doctype/lms_coupon_item/lms_coupon_item.py
@@ -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
diff --git a/lms/lms/doctype/lms_payment/lms_payment.json b/lms/lms/doctype/lms_payment/lms_payment.json
index 662aa6e1..b587b694 100644
--- a/lms/lms/doctype/lms_payment/lms_payment.json
+++ b/lms/lms/doctype/lms_payment/lms_payment.json
@@ -16,14 +16,18 @@
"payment_received",
"payment_for_certificate",
"payment_details_section",
- "currency",
+ "original_amount",
+ "discount_amount",
"amount",
"amount_with_gst",
"column_break_yxpl",
- "order_id",
- "payment_id",
+ "currency",
+ "coupon",
+ "coupon_code",
"billing_details_section",
"address",
+ "payment_id",
+ "order_id",
"column_break_monu",
"gstin",
"pan"
@@ -47,6 +51,19 @@
"options": "currency",
"reqd": 1
},
+ {
+ "fieldname": "coupon",
+ "fieldtype": "Link",
+ "label": "Coupon",
+ "options": "LMS Coupon"
+ },
+ {
+ "depends_on": "coupon",
+ "fieldname": "discount_amount",
+ "fieldtype": "Currency",
+ "label": "Discount Amount",
+ "options": "currency"
+ },
{
"fieldname": "currency",
"fieldtype": "Link",
@@ -117,12 +134,6 @@
"options": "User",
"reqd": 1
},
- {
- "depends_on": "eval:doc.currency == \"INR\";",
- "fieldname": "amount_with_gst",
- "fieldtype": "Currency",
- "label": "Amount with GST"
- },
{
"fieldname": "payment_for_document_type",
"fieldtype": "Select",
@@ -149,6 +160,27 @@
"fieldtype": "Check",
"in_standard_filter": 1,
"label": "Payment for Certificate"
+ },
+ {
+ "depends_on": "coupon",
+ "fetch_from": "coupon.code",
+ "fieldname": "coupon_code",
+ "fieldtype": "Data",
+ "label": "Coupon Code",
+ "read_only": 1
+ },
+ {
+ "depends_on": "eval:doc.currency == \"INR\"",
+ "fieldname": "amount_with_gst",
+ "fieldtype": "Currency",
+ "label": "Amount with GST"
+ },
+ {
+ "depends_on": "coupon",
+ "fieldname": "original_amount",
+ "fieldtype": "Currency",
+ "label": "Original Amount",
+ "options": "currency"
}
],
"index_web_pages_for_search": 1,
@@ -162,7 +194,7 @@
"link_fieldname": "payment"
}
],
- "modified": "2025-09-23 11:04:00.462274",
+ "modified": "2025-11-12 12:39:52.466297",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Payment",
diff --git a/lms/lms/doctype/lms_payment/lms_payment.py b/lms/lms/doctype/lms_payment/lms_payment.py
index 6cec4170..f55982e6 100644
--- a/lms/lms/doctype/lms_payment/lms_payment.py
+++ b/lms/lms/doctype/lms_payment/lms_payment.py
@@ -5,7 +5,7 @@ import frappe
from frappe import _
from frappe.email.doctype.email_template.email_template import get_email_template
from frappe.model.document import Document
-from frappe.utils import add_days, nowdate
+from frappe.utils import add_days, flt, nowdate
class LMSPayment(Document):
diff --git a/lms/lms/payments.py b/lms/lms/payments.py
index 9f4e31fe..e97b6834 100644
--- a/lms/lms/payments.py
+++ b/lms/lms/payments.py
@@ -23,23 +23,38 @@ def get_payment_link(
docname,
title,
amount,
- total_amount,
+ discount_amount,
+ gst_amount,
currency,
address,
redirect_to,
payment_for_certificate,
+ coupon_code=None,
+ coupon=None,
):
payment_gateway = get_payment_gateway()
address = frappe._dict(address)
- amount_with_gst = total_amount if total_amount != amount else 0
+ original_amount = amount
+ amount -= discount_amount
+ amount_with_gst = get_amount_with_gst(amount, gst_amount)
payment = record_payment(
- address, doctype, docname, amount, currency, amount_with_gst, payment_for_certificate
+ address,
+ doctype,
+ docname,
+ amount,
+ original_amount,
+ currency,
+ amount_with_gst,
+ discount_amount,
+ payment_for_certificate,
+ coupon_code,
+ coupon,
)
controller = get_controller(payment_gateway)
payment_details = {
- "amount": total_amount,
+ "amount": amount_with_gst if amount_with_gst else amount,
"title": f"Payment for {doctype} {title} {docname}",
"description": f"{address.billing_name}'s payment for {title}",
"reference_doctype": doctype,
@@ -51,23 +66,41 @@ def get_payment_link(
"redirect_to": redirect_to,
"payment": payment.name,
}
- if payment_gateway == "Razorpay":
- order = controller.create_order(**payment_details)
- payment_details.update({"order_id": order.get("id")})
+ create_order(payment_gateway, payment_details, controller)
url = controller.get_payment_url(**payment_details)
return url
+def create_order(payment_gateway, payment_details, controller):
+ if payment_gateway != "Razorpay":
+ return
+
+ order = controller.create_order(**payment_details)
+ payment_details.update({"order_id": order.get("id")})
+
+
+def get_amount_with_gst(amount, gst_amount):
+ amount_with_gst = 0
+ if gst_amount:
+ amount_with_gst = amount + gst_amount
+
+ return amount_with_gst
+
+
def record_payment(
address,
doctype,
docname,
amount,
+ original_amount,
currency,
amount_with_gst=0,
+ discount_amount=0,
payment_for_certificate=0,
+ coupon_code=None,
+ coupon=None,
):
address = frappe._dict(address)
address_name = save_address(address)
@@ -80,6 +113,7 @@ def record_payment(
"address": address_name,
"amount": amount,
"currency": currency,
+ "discount_amount": discount_amount,
"amount_with_gst": amount_with_gst,
"gstin": address.gstin,
"pan": address.pan,
@@ -89,6 +123,16 @@ def record_payment(
"payment_for_certificate": payment_for_certificate,
}
)
+ if coupon_code:
+ payment_doc.update(
+ {
+ "coupon": coupon,
+ "coupon_code": coupon_code,
+ "discount_amount": discount_amount,
+ "original_amount": original_amount,
+ }
+ )
+
payment_doc.save(ignore_permissions=True)
return payment_doc
diff --git a/lms/lms/utils.py b/lms/lms/utils.py
index 0ef57451..c46307a3 100644
--- a/lms/lms/utils.py
+++ b/lms/lms/utils.py
@@ -1752,51 +1752,140 @@ def get_discussion_replies(topic):
@frappe.whitelist()
-def get_order_summary(doctype, docname, country=None):
- if doctype == "LMS Course":
- details = frappe.db.get_value(
- "LMS Course",
- docname,
- [
- "title",
- "name",
- "paid_course",
- "paid_certificate",
- "course_price as amount",
- "currency",
- "amount_usd",
- ],
- as_dict=True,
- )
-
- if not details.paid_course and not details.paid_certificate:
- raise frappe.throw(_("This course is free."))
-
- else:
- details = frappe.db.get_value(
- "LMS Batch",
- docname,
- ["title", "name", "paid_batch", "amount", "currency", "amount_usd"],
- as_dict=True,
- )
-
- if not details.paid_batch:
- raise frappe.throw(_("To join this batch, please contact the Administrator."))
+def get_order_summary(doctype, docname, coupon=None, country=None):
+ details = get_paid_course_details(docname) if doctype == "LMS Course" else get_paid_batch_details(docname)
details.amount, details.currency = check_multicurrency(
details.amount, details.currency, country, details.amount_usd
)
+
details.original_amount = details.amount
details.original_amount_formatted = fmt_money(details.amount, 0, details.currency)
- if details.currency == "INR":
- details.amount, details.gst_applied = apply_gst(details.amount, country)
- details.gst_amount_formatted = fmt_money(details.gst_applied, 0, details.currency)
+ adjust_amount_for_coupon(details, coupon, doctype, docname)
+ get_gst_details(details, country)
+ details.total_amount = details.amount
details.total_amount_formatted = fmt_money(details.amount, 0, details.currency)
+
return details
+def get_paid_course_details(docname):
+ details = frappe.db.get_value(
+ "LMS Course",
+ docname,
+ [
+ "title",
+ "name",
+ "paid_course",
+ "paid_certificate",
+ "course_price as amount",
+ "currency",
+ "amount_usd",
+ ],
+ as_dict=True,
+ )
+
+ if not details.paid_course and not details.paid_certificate:
+ raise frappe.throw(_("This course is free."))
+
+ return details
+
+
+def get_paid_batch_details(docname):
+ details = frappe.db.get_value(
+ "LMS Batch",
+ docname,
+ ["title", "name", "paid_batch", "amount", "currency", "amount_usd"],
+ as_dict=True,
+ )
+
+ if not details.paid_batch:
+ raise frappe.throw(_("To join this batch, please contact the Administrator."))
+
+ return details
+
+
+def adjust_amount_for_coupon(details, coupon, doctype, docname):
+ if not coupon:
+ return
+ discount_amount, subtotal, coupon_name = apply_coupon(doctype, docname, coupon, details.amount)
+ details.amount = subtotal
+ details.discount_amount = discount_amount
+ details.discount_amount_formatted = fmt_money(discount_amount, 0, details.currency)
+ details.coupon = coupon_name
+
+
+def get_gst_details(details, country):
+ if details.currency != "INR":
+ return
+
+ details.amount, details.gst_applied = apply_gst(details.amount, country)
+ details.gst_amount_formatted = fmt_money(details.gst_applied, 0, details.currency)
+
+
+def apply_coupon(doctype, docname, code, base_amount):
+ coupon_name = frappe.db.exists("LMS Coupon", {"code": code, "enabled": 1})
+ if not coupon_name:
+ frappe.throw(_("The coupon code '{0}' is invalid.").format(code))
+
+ coupon = frappe.db.get_value(
+ "LMS Coupon",
+ coupon_name,
+ [
+ "expires_on",
+ "usage_limit",
+ "redemption_count",
+ "discount_type",
+ "percentage_discount",
+ "fixed_amount_discount",
+ "name",
+ "code",
+ ],
+ as_dict=True,
+ )
+
+ validate_coupon(code, coupon)
+ validate_coupon_applicability(doctype, docname, coupon_name)
+
+ discount_amount = calculate_discount_amount(base_amount, coupon)
+ subtotal = max(flt(base_amount) - flt(discount_amount), 0)
+
+ return discount_amount, subtotal, coupon_name
+
+
+def validate_coupon(code, coupon):
+ if coupon.expires_on and getdate(coupon.expires_on) < getdate():
+ frappe.throw(_("This coupon has expired."))
+
+ if coupon.usage_limit and cint(coupon.redemption_count) >= cint(coupon.usage_limit):
+ frappe.throw(_("This coupon has reached its maximum usage limit."))
+
+
+def validate_coupon_applicability(doctype, docname, coupon_name):
+ applicable_item = frappe.db.exists(
+ "LMS Coupon Item", {"parent": coupon_name, "reference_doctype": doctype, "reference_name": docname}
+ )
+ if not applicable_item:
+ frappe.throw(
+ _("This coupon is not applicable to this {0}.").format(
+ "Course" if doctype == "LMS Course" else "Batch"
+ )
+ )
+
+
+def calculate_discount_amount(base_amount, coupon):
+ discount_amount = 0
+
+ if coupon.discount_type == "Percentage":
+ discount_amount = (base_amount * coupon.percentage_discount) / 100
+ elif coupon.discount_type == "Fixed Amount":
+ discount_amount = base_amount - coupon.fixed_amount_discount
+
+ return discount_amount
+
+
@frappe.whitelist()
def get_lesson_creation_details(course, chapter, lesson):
chapter_name = frappe.db.get_value("Chapter Reference", {"parent": course, "idx": chapter}, "chapter")
@@ -1843,49 +1932,79 @@ def publish_notifications(doc, method):
def update_payment_record(doctype, docname):
- request = frappe.get_all(
+ request = get_integration_requests(doctype, docname)
+
+ if len(request):
+ data = request[0].data
+ data = frappe._dict(json.loads(data))
+ payment_doc = get_payment_doc(data.payment)
+
+ update_payment_details(data)
+ update_coupon_redemption(payment_doc)
+
+ if payment_doc.payment_for_certificate:
+ update_certificate_purchase(docname, data.payment)
+ elif doctype == "LMS Course":
+ enroll_in_course(docname, data.payment)
+ else:
+ enroll_in_batch(docname, data.payment)
+
+
+def get_integration_requests(doctype, docname):
+ return frappe.get_all(
"Integration Request",
{
"reference_doctype": doctype,
"reference_docname": docname,
"owner": frappe.session.user,
},
+ ["data"],
order_by="creation desc",
limit=1,
)
- if len(request):
- data = frappe.db.get_value("Integration Request", request[0].name, "data")
- data = frappe._dict(json.loads(data))
- payment_gateway = data.get("payment_gateway")
- if payment_gateway == "Razorpay":
- payment_id = "razorpay_payment_id"
- elif "Stripe" in payment_gateway:
- payment_id = "stripe_token_id"
- else:
- payment_id = "order_id"
+def get_payment_doc(payment_name):
+ return frappe.db.get_value(
+ "LMS Payment", payment_name, ["name", "coupon", "payment_for_certificate"], as_dict=True
+ )
+
+
+def update_payment_details(data):
+ payment_id = get_payment_id(data)
+
+ frappe.db.set_value(
+ "LMS Payment",
+ data.payment,
+ {
+ "payment_received": 1,
+ "payment_id": data.get(payment_id),
+ "order_id": data.get("order_id"),
+ },
+ )
+
+
+def get_payment_id(data):
+ payment_gateway = data.get("payment_gateway")
+ if payment_gateway == "Razorpay":
+ payment_id = "razorpay_payment_id"
+ elif "Stripe" in payment_gateway:
+ payment_id = "stripe_token_id"
+ else:
+ payment_id = "order_id"
+ return payment_id
+
+
+def update_coupon_redemption(payment_doc):
+ if payment_doc.coupon:
+ redemption_count = frappe.db.get_value("LMS Coupon", payment_doc.coupon, "redemption_count") or 0
frappe.db.set_value(
- "LMS Payment",
- data.payment,
- {
- "payment_received": 1,
- "payment_id": data.get(payment_id),
- "order_id": data.get("order_id"),
- },
+ "LMS Coupon",
+ payment_doc.coupon,
+ "redemption_count",
+ redemption_count + 1,
)
- payment_for_certificate = frappe.db.get_value("LMS Payment", data.payment, "payment_for_certificate")
-
- try:
- if payment_for_certificate:
- update_certificate_purchase(docname, data.payment)
- elif doctype == "LMS Course":
- enroll_in_course(docname, data.payment)
- else:
- enroll_in_batch(docname, data.payment)
- except Exception as e:
- frappe.log_error(frappe.get_traceback(), _("Enrollment Failed, {0}").format(e))
def enroll_in_course(course, payment_name):
diff --git a/yarn.lock b/yarn.lock
index aa352cc6..73fa27a7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -35,9 +35,9 @@
lodash.once "^4.1.1"
"@types/node@*":
- version "24.10.0"
- resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.0.tgz#6b79086b0dfc54e775a34ba8114dcc4e0221f31f"
- integrity sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==
+ version "24.10.1"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.1.tgz#91e92182c93db8bd6224fca031e2370cef9a8f01"
+ integrity sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==
dependencies:
undici-types "~7.16.0"