From bf36890bd3f65d7c1d464382022d3addcded16c6 Mon Sep 17 00:00:00 2001 From: Joedeep Singh Date: Sun, 12 Oct 2025 17:07:42 +0000 Subject: [PATCH 01/53] feat: added coupon code functionality --- frontend/components.d.ts | 2 + .../src/components/Settings/CouponDetails.vue | 157 +++++++++++++++++ frontend/src/components/Settings/Coupons.vue | 92 ++++++++++ frontend/src/components/Settings/Settings.vue | 7 + .../Settings/TransactionDetails.vue | 52 +++++- frontend/src/pages/Billing.vue | 48 ++++- lms/lms/doctype/lms_coupon/__init__.py | 0 lms/lms/doctype/lms_coupon/lms_coupon.js | 8 + lms/lms/doctype/lms_coupon/lms_coupon.json | 166 ++++++++++++++++++ lms/lms/doctype/lms_coupon/lms_coupon.py | 56 ++++++ lms/lms/doctype/lms_coupon/test_lms_coupon.py | 22 +++ lms/lms/doctype/lms_coupon_item/__init__.py | 0 .../lms_coupon_item/lms_coupon_item.json | 43 +++++ .../lms_coupon_item/lms_coupon_item.py | 9 + lms/lms/doctype/lms_payment/lms_payment.json | 27 +++ lms/lms/payments.py | 72 +++++--- lms/lms/utils.py | 83 +++++++++ 17 files changed, 821 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/Settings/CouponDetails.vue create mode 100644 frontend/src/components/Settings/Coupons.vue create mode 100644 lms/lms/doctype/lms_coupon/__init__.py create mode 100644 lms/lms/doctype/lms_coupon/lms_coupon.js create mode 100644 lms/lms/doctype/lms_coupon/lms_coupon.json create mode 100644 lms/lms/doctype/lms_coupon/lms_coupon.py create mode 100644 lms/lms/doctype/lms_coupon/test_lms_coupon.py create mode 100644 lms/lms/doctype/lms_coupon_item/__init__.py create mode 100644 lms/lms/doctype/lms_coupon_item/lms_coupon_item.json create mode 100644 lms/lms/doctype/lms_coupon_item/lms_coupon_item.py diff --git a/frontend/components.d.ts b/frontend/components.d.ts index b9411e86..c205bb11 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -42,6 +42,8 @@ declare module 'vue' { CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default'] CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default'] ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default'] + CouponDetails: typeof import('./src/components/Settings/CouponDetails.vue')['default'] + Coupons: typeof import('./src/components/Settings/Coupons.vue')['default'] CourseCard: typeof import('./src/components/CourseCard.vue')['default'] CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default'] CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default'] diff --git a/frontend/src/components/Settings/CouponDetails.vue b/frontend/src/components/Settings/CouponDetails.vue new file mode 100644 index 00000000..3e3fff36 --- /dev/null +++ b/frontend/src/components/Settings/CouponDetails.vue @@ -0,0 +1,157 @@ + + + diff --git a/frontend/src/components/Settings/Coupons.vue b/frontend/src/components/Settings/Coupons.vue new file mode 100644 index 00000000..078f4846 --- /dev/null +++ b/frontend/src/components/Settings/Coupons.vue @@ -0,0 +1,92 @@ + + + diff --git a/frontend/src/components/Settings/Settings.vue b/frontend/src/components/Settings/Settings.vue index 57c40f3e..f660fab4 100644 --- a/frontend/src/components/Settings/Settings.vue +++ b/frontend/src/components/Settings/Settings.vue @@ -81,6 +81,7 @@ import Categories from '@/components/Settings/Categories.vue' import EmailTemplates from '@/components/Settings/EmailTemplates.vue' import BrandSettings from '@/components/Settings/BrandSettings.vue' import PaymentGateways from '@/components/Settings/PaymentGateways.vue' +import Coupons from '@/components/Settings/Coupons.vue' import Transactions from '@/components/Settings/Transactions.vue' import ZoomSettings from '@/components/Settings/ZoomSettings.vue' import Badges from '@/components/Settings/Badges.vue' @@ -230,6 +231,12 @@ const tabsStructure = computed(() => { template: markRaw(PaymentGateways), description: 'Add and manage all your payment gateways', }, + { + label: 'Coupons', + icon: 'Tag', + template: markRaw(Coupons), + description: 'Create and manage coupon codes', + }, { label: 'Transactions', icon: 'Landmark', diff --git a/frontend/src/components/Settings/TransactionDetails.vue b/frontend/src/components/Settings/TransactionDetails.vue index b996b02d..a3e38933 100644 --- a/frontend/src/components/Settings/TransactionDetails.vue +++ b/frontend/src/components/Settings/TransactionDetails.vue @@ -72,6 +72,40 @@ /> +
+
+ {{ __('Coupon (if applied)') }} +
+
+ + + + +
+
+
@@ -100,7 +134,7 @@ diff --git a/frontend/src/components/Settings/TransactionDetails.vue b/frontend/src/components/Settings/TransactionDetails.vue index a3e38933..ac5b8e1d 100644 --- a/frontend/src/components/Settings/TransactionDetails.vue +++ b/frontend/src/components/Settings/TransactionDetails.vue @@ -67,43 +67,32 @@ />
-
-
- {{ __('Coupon (if applied)') }} -
-
- - - - -
+
+ + +
@@ -114,6 +103,13 @@ v-model="transactionData.payment_id" />
+ +
+ +
- diff --git a/frontend/src/components/Settings/TransactionDetails.vue b/frontend/src/components/Settings/TransactionDetails.vue index 07d97e90..d9aa5e5e 100644 --- a/frontend/src/components/Settings/TransactionDetails.vue +++ b/frontend/src/components/Settings/TransactionDetails.vue @@ -88,7 +88,10 @@ :disabled="true" />
- {{ getCurrencySymbol(row['currency']) }} {{ row['total_amount'] }} + {{ getCurrencySymbol(row['currency']) }} + {{ row['total_amount'] }}
{{ row[column.key] }} diff --git a/frontend/src/pages/Billing.vue b/frontend/src/pages/Billing.vue index bf7ff3cd..8e6c8320 100644 --- a/frontend/src/pages/Billing.vue +++ b/frontend/src/pages/Billing.vue @@ -35,7 +35,10 @@ {{ orderSummary.data.original_amount_formatted }}
-
+
{{ __('Discount') }}
-{{ orderSummary.data.discount_amount_formatted }}
@@ -61,11 +64,32 @@
-
- {{ __('Coupon') }} - - - +
+ {{ + __('Coupon') + }} + + +
@@ -268,7 +292,7 @@ const setBillingDetails = (data) => { const paymentLink = createResource({ url: 'lms.lms.payments.get_payment_link', makeParams(values) { - let data={ + let data = { doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course', docname: props.name, title: orderSummary.data.title, diff --git a/lms/lms/doctype/lms_coupon/lms_coupon.py b/lms/lms/doctype/lms_coupon/lms_coupon.py index abb0ee55..d5328d84 100644 --- a/lms/lms/doctype/lms_coupon/lms_coupon.py +++ b/lms/lms/doctype/lms_coupon/lms_coupon.py @@ -2,75 +2,75 @@ # For license information, please see license.txt import frappe -from frappe.model.document import Document from frappe import _ +from frappe.model.document import Document from frappe.utils import nowdate class LMSCoupon(Document): - def validate(self): - if self.code: - self.code = self.code.strip().upper() + def validate(self): + if self.code: + self.code = self.code.strip().upper() - if not self.code: - frappe.throw(_("Coupon code is required")) + if not self.code: + frappe.throw(_("Coupon code is required")) - if len(self.code) < 6: - frappe.throw(_("Coupon code must be atleast 6 characters")) + if len(self.code) < 6: + frappe.throw(_("Coupon code must be atleast 6 characters")) - 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 is already taken. Use a different one")) + 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 not self.discount_type: - frappe.throw(_("Discount type is required")) + if existing: + frappe.throw(_("Coupon code is already taken. Use a different one")) - if self.discount_type == "Percent": - if not self.percent_off or self.percent_off == "": - frappe.throw(_("Discount percentage is required")) - try: - percent_value = float(self.percent_off) - if not (0 < percent_value <= 100): - frappe.throw(_("Discount percentage must be between 1 and 100")) - except (ValueError, TypeError): - frappe.throw(_("Discount percentage must be a valid number")) - self.amount_off = None + if not self.discount_type: + frappe.throw(_("Discount type is required")) - if self.discount_type == "Amount": - if not self.amount_off or self.amount_off == "": - frappe.throw(_("Discount amount is required")) - try: - amount_value = float(self.amount_off) - if amount_value < 0: - frappe.throw(_("Discount amount cannot be negative")) - except (ValueError, TypeError): - frappe.throw(_("Discount amount must be a valid number")) - self.percent_off = None + if self.discount_type == "Percent": + if not self.percent_off or self.percent_off == "": + frappe.throw(_("Discount percentage is required")) + try: + percent_value = float(self.percent_off) + if not (0 < percent_value <= 100): + frappe.throw(_("Discount percentage must be between 1 and 100")) + except (ValueError, TypeError): + frappe.throw(_("Discount percentage must be a valid number")) + self.amount_off = None - if self.usage_limit is not None and self.usage_limit != "": - try: - usage_value = int(self.usage_limit) - if usage_value < 0: - frappe.throw(_("Usage limit cannot be negative")) - except (ValueError, TypeError): - frappe.throw(_("Usage limit must be a valid number")) + if self.discount_type == "Amount": + if not self.amount_off or self.amount_off == "": + frappe.throw(_("Discount amount is required")) + try: + amount_value = float(self.amount_off) + if amount_value < 0: + frappe.throw(_("Discount amount cannot be negative")) + except (ValueError, TypeError): + frappe.throw(_("Discount amount must be a valid number")) + self.percent_off = None - if self.expires_on and str(self.expires_on) < nowdate(): - frappe.throw(_("Expiry date cannot be in the past")) + if self.usage_limit is not None and self.usage_limit != "": + try: + usage_value = int(self.usage_limit) + if usage_value < 0: + frappe.throw(_("Usage limit cannot be negative")) + except (ValueError, TypeError): + frappe.throw(_("Usage limit must be a valid number")) - if not self.get("applicable_items") or len(self.get("applicable_items")) == 0: - frappe.throw(_("Please select atleast one course or batch")) + if self.expires_on and str(self.expires_on) < nowdate(): + frappe.throw(_("Expiry date cannot be in the past")) - for item in self.get("applicable_items"): - if not item.get("reference_name"): - frappe.throw(_("Please select a valid course or batch")) + if not self.get("applicable_items") or len(self.get("applicable_items")) == 0: + frappe.throw(_("Please select atleast one course or batch")) + + for item in self.get("applicable_items"): + if not item.get("reference_name"): + frappe.throw(_("Please select a valid course or batch")) diff --git a/lms/lms/doctype/lms_coupon/test_lms_coupon.py b/lms/lms/doctype/lms_coupon/test_lms_coupon.py index 11cdf628..58e26892 100644 --- a/lms/lms/doctype/lms_coupon/test_lms_coupon.py +++ b/lms/lms/doctype/lms_coupon/test_lms_coupon.py @@ -4,7 +4,6 @@ # 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 @@ -12,7 +11,6 @@ EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] - class IntegrationTestLMSCoupon(IntegrationTestCase): """ Integration tests for LMSCoupon. diff --git a/lms/lms/doctype/lms_payment/lms_payment.py b/lms/lms/doctype/lms_payment/lms_payment.py index 710cc304..49e1c3d1 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, flt +from frappe.utils import add_days, flt, nowdate class LMSPayment(Document): @@ -15,6 +15,7 @@ class LMSPayment(Document): gst = flt(self.gst_amount or 0, self.precision("gst_amount")) self.total_amount = flt(amount - discount + gst, self.precision("total_amount")) + def send_payment_reminder(): outgoing_email_account = frappe.get_cached_value( "Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name" diff --git a/lms/lms/payments.py b/lms/lms/payments.py index f69137aa..cfe75347 100644 --- a/lms/lms/payments.py +++ b/lms/lms/payments.py @@ -1,5 +1,6 @@ import frappe + def get_payment_gateway(): return frappe.db.get_single_value("LMS Settings", "payment_gateway") @@ -18,17 +19,17 @@ def validate_currency(payment_gateway, currency): @frappe.whitelist() def get_payment_link( - doctype, - docname, - title, - amount, + doctype, + docname, + title, + amount, discount_amount, - gst_amount, - currency, - address, - redirect_to, - payment_for_certificate, - coupon_code=None, + gst_amount, + currency, + address, + redirect_to, + payment_for_certificate, + coupon_code=None, ): payment_gateway = get_payment_gateway() address = frappe._dict(address) @@ -37,21 +38,22 @@ def get_payment_link( if doctype in ["LMS Course", "LMS Batch"] and coupon_code: try: from lms.lms.utils import apply_coupon + coupon_context = apply_coupon(doctype, docname, coupon_code) except Exception: pass payment = record_payment( - address, - doctype, - docname, - amount, - currency, - discount_amount, - gst_amount, - payment_for_certificate, - coupon_context, - ) + address, + doctype, + docname, + amount, + currency, + discount_amount, + gst_amount, + payment_for_certificate, + coupon_context, + ) controller = get_controller(payment_gateway) payment_details = { @@ -79,15 +81,15 @@ def get_payment_link( def record_payment( - address, - doctype, - docname, - amount, - currency, - discount_amount=0, - gst_amount=0, - payment_for_certificate=0, - coupon_context=None, + address, + doctype, + docname, + amount, + currency, + discount_amount=0, + gst_amount=0, + payment_for_certificate=0, + coupon_context=None, ): address = frappe._dict(address) address_name = save_address(address) diff --git a/lms/lms/utils.py b/lms/lms/utils.py index 50f54cfd..7ed54954 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -953,74 +953,74 @@ def get_current_exchange_rate(source, target="USD"): @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.")) + # 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.")) + if not code: + frappe.throw(_("Coupon code is required.")) - summary = get_order_summary(doctype, docname, country) + summary = get_order_summary(doctype, docname, country) - base_amount = summary.original_amount - currency = summary.currency + 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.")) + # 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) + 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.")) + # 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.")) + # 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.")) + # 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)) + # 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) + 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) + 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, - "coupon_code": coupon.code, - "discount_type": coupon.discount_type, - "discount_percent": coupon.percent_off if coupon.discount_type == "Percent" else None, - } + 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, + "coupon_code": coupon.code, + "discount_type": coupon.discount_type, + "discount_percent": coupon.percent_off if coupon.discount_type == "Percent" else None, + } @frappe.whitelist() From 0f24fd6edc8d67b210340a267c9a406e0a3f5a8d Mon Sep 17 00:00:00 2001 From: Joedeep Singh Date: Mon, 13 Oct 2025 14:54:03 +0000 Subject: [PATCH 05/53] feat(coupon-details): prevent duplicate course/batch selections and clear name on type change --- .../src/components/Settings/CouponDetails.vue | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/frontend/src/components/Settings/CouponDetails.vue b/frontend/src/components/Settings/CouponDetails.vue index 8a552af4..671d2f0c 100644 --- a/frontend/src/components/Settings/CouponDetails.vue +++ b/frontend/src/components/Settings/CouponDetails.vue @@ -65,10 +65,12 @@ { label: 'Course ', value: 'LMS Course' }, { label: 'Batch ', value: 'LMS Batch' }, ]" + @change="(val) => (row.reference_name = null)" /> i !== idx && r.reference_doctype === doctype && r.reference_name + ) + .map((r) => r.reference_name) + if (selectedNames.length === 0) return {} + return { + name: ['not in', selectedNames], + } +} + const saveDoc = createResource({ url: 'frappe.client.save', makeParams(values) { From acb5e5e1c978c6b53cfe4184556f04a2948e0f3a Mon Sep 17 00:00:00 2001 From: Joedeep Singh Date: Mon, 13 Oct 2025 15:35:56 +0000 Subject: [PATCH 06/53] chore: removed unnecessary comments and log statements --- frontend/src/pages/Billing.vue | 1 - lms/lms/utils.py | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/frontend/src/pages/Billing.vue b/frontend/src/pages/Billing.vue index 8e6c8320..c61461fd 100644 --- a/frontend/src/pages/Billing.vue +++ b/frontend/src/pages/Billing.vue @@ -264,7 +264,6 @@ const applyCoupon = createResource({ }, onSuccess(data) { orderSummary.data = data - console.log('orderSummary.data - ', orderSummary.data) appliedCoupon.value = couponCode.value toast.success(__('Coupon applied')) }, diff --git a/lms/lms/utils.py b/lms/lms/utils.py index 7ed54954..562544cb 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -953,7 +953,6 @@ def get_current_exchange_rate(source, target="USD"): @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.")) @@ -965,22 +964,19 @@ def apply_coupon(doctype, docname, code, country=None): 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) + # check applicability applicable = True if len(coupon.applicable_items): applicable = any( @@ -990,7 +986,6 @@ def apply_coupon(doctype, docname, code, country=None): 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) From ad28218893a255f1721a15d873e1f18240a79fac Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Tue, 28 Oct 2025 18:05:31 +0530 Subject: [PATCH 07/53] refactor: coupon list and form --- frappe-ui | 2 +- frontend/components.d.ts | 6 +- .../src/components/Settings/CouponDetails.vue | 230 ---------------- frontend/src/components/Settings/Coupons.vue | 144 ---------- .../Settings/Coupons/CouponDetails.vue | 245 ++++++++++++++++++ .../Settings/Coupons/CouponItems.vue | 164 ++++++++++++ .../Settings/Coupons/CouponList.vue | 203 +++++++++++++++ .../components/Settings/Coupons/Coupons.vue | 54 ++++ .../src/components/Settings/Coupons/types.ts | 30 +++ frontend/src/components/Settings/Settings.vue | 14 +- lms/lms/doctype/lms_coupon/lms_coupon.json | 112 ++++---- lms/lms/doctype/lms_coupon/lms_coupon.py | 71 +---- 12 files changed, 789 insertions(+), 486 deletions(-) delete mode 100644 frontend/src/components/Settings/CouponDetails.vue delete mode 100644 frontend/src/components/Settings/Coupons.vue create mode 100644 frontend/src/components/Settings/Coupons/CouponDetails.vue create mode 100644 frontend/src/components/Settings/Coupons/CouponItems.vue create mode 100644 frontend/src/components/Settings/Coupons/CouponList.vue create mode 100644 frontend/src/components/Settings/Coupons/Coupons.vue create mode 100644 frontend/src/components/Settings/Coupons/types.ts diff --git a/frappe-ui b/frappe-ui index 310089f4..f1bde9bc 160000 --- a/frappe-ui +++ b/frappe-ui @@ -1 +1 @@ -Subproject commit 310089f4a40a6f6c9fa65d3e942c853e893df8ee +Subproject commit f1bde9bcb271af47e9f5de190a18dff8604f5312 diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 3352de8f..3626a724 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -41,9 +41,11 @@ declare module 'vue' { CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default'] CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default'] ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default'] - CouponDetails: typeof import('./src/components/Settings/CouponDetails.vue')['default'] - Coupons: typeof import('./src/components/Settings/Coupons.vue')['default'] ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default'] + CouponDetails: typeof import('./src/components/Settings/Coupons/CouponDetails.vue')['default'] + CouponItems: typeof import('./src/components/Settings/Coupons/CouponItems.vue')['default'] + CouponList: typeof import('./src/components/Settings/Coupons/CouponList.vue')['default'] + Coupons: typeof import('./src/components/Settings/Coupons/Coupons.vue')['default'] CourseCard: typeof import('./src/components/CourseCard.vue')['default'] CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default'] CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default'] diff --git a/frontend/src/components/Settings/CouponDetails.vue b/frontend/src/components/Settings/CouponDetails.vue deleted file mode 100644 index 671d2f0c..00000000 --- a/frontend/src/components/Settings/CouponDetails.vue +++ /dev/null @@ -1,230 +0,0 @@ - - diff --git a/frontend/src/components/Settings/Coupons.vue b/frontend/src/components/Settings/Coupons.vue deleted file mode 100644 index eac012d5..00000000 --- a/frontend/src/components/Settings/Coupons.vue +++ /dev/null @@ -1,144 +0,0 @@ - - diff --git a/frontend/src/components/Settings/Coupons/CouponDetails.vue b/frontend/src/components/Settings/Coupons/CouponDetails.vue new file mode 100644 index 00000000..2ac9c4b4 --- /dev/null +++ b/frontend/src/components/Settings/Coupons/CouponDetails.vue @@ -0,0 +1,245 @@ + + diff --git a/frontend/src/components/Settings/Coupons/CouponItems.vue b/frontend/src/components/Settings/Coupons/CouponItems.vue new file mode 100644 index 00000000..e8f68f03 --- /dev/null +++ b/frontend/src/components/Settings/Coupons/CouponItems.vue @@ -0,0 +1,164 @@ + + diff --git a/frontend/src/components/Settings/Coupons/CouponList.vue b/frontend/src/components/Settings/Coupons/CouponList.vue new file mode 100644 index 00000000..d093167e --- /dev/null +++ b/frontend/src/components/Settings/Coupons/CouponList.vue @@ -0,0 +1,203 @@ + + diff --git a/frontend/src/components/Settings/Coupons/Coupons.vue b/frontend/src/components/Settings/Coupons/Coupons.vue new file mode 100644 index 00000000..77713d5b --- /dev/null +++ b/frontend/src/components/Settings/Coupons/Coupons.vue @@ -0,0 +1,54 @@ + + diff --git a/frontend/src/components/Settings/Coupons/types.ts b/frontend/src/components/Settings/Coupons/types.ts new file mode 100644 index 00000000..c4d67118 --- /dev/null +++ b/frontend/src/components/Settings/Coupons/types.ts @@ -0,0 +1,30 @@ +export interface Coupon { + name: string; + enabled: boolean; + code: string; + discount_type: 'Percentage' | 'Fixed Amount'; + percentage_discount?: number; + fixed_amount_discount?: number; + expires_on?: string; + description?: string; + usage_limit?: number; + redemptions_count: number; + applicable_items: ApplicableItem[]; +} + +export type ApplicableItem = { + reference_doctype: "LMS Course" | "LMS Batch"; + reference_name: string; + name: string; + parent: string; + parenttype: "LMS Coupon"; + parentfield: "applicable_items"; +} + +export interface Coupons { + data: Coupon[]; + update: (args: { filters: any[] }) => void; + insert: { submit: (params: Coupon, options: { onSuccess: (data: Coupon) => void; onError?: (err: any) => void }) => void }; + setValue: { submit: (params: Coupon, options: { onSuccess: (data: Coupon) => void; onError?: (err: any) => void }) => void }; + reload: () => void; +} \ No newline at end of file diff --git a/frontend/src/components/Settings/Settings.vue b/frontend/src/components/Settings/Settings.vue index 78c98a6b..35571494 100644 --- a/frontend/src/components/Settings/Settings.vue +++ b/frontend/src/components/Settings/Settings.vue @@ -78,7 +78,7 @@ import Categories from '@/components/Settings/Categories.vue' import EmailTemplates from '@/components/Settings/EmailTemplates.vue' import BrandSettings from '@/components/Settings/BrandSettings.vue' import PaymentGateways from '@/components/Settings/PaymentGateways.vue' -import Coupons from '@/components/Settings/Coupons.vue' +import Coupons from '@/components/Settings/Coupons/Coupons.vue' import Transactions from '@/components/Settings/Transactions.vue' import ZoomSettings from '@/components/Settings/ZoomSettings.vue' import Badges from '@/components/Settings/Badges.vue' @@ -228,18 +228,18 @@ const tabsStructure = computed(() => { template: markRaw(PaymentGateways), description: 'Add and manage all your payment gateways', }, - { - label: 'Coupons', - icon: 'Tag', - template: markRaw(Coupons), - description: 'Create and manage coupon codes', - }, { label: 'Transactions', icon: 'Landmark', template: markRaw(Transactions), description: 'View all your payment transactions', }, + { + label: 'Coupons', + icon: 'Ticket', + template: markRaw(Coupons), + description: 'Manage discount coupons for courses and batches', + }, ], }, { diff --git a/lms/lms/doctype/lms_coupon/lms_coupon.json b/lms/lms/doctype/lms_coupon/lms_coupon.json index 31eb826a..26dcb5a9 100644 --- a/lms/lms/doctype/lms_coupon/lms_coupon.json +++ b/lms/lms/doctype/lms_coupon/lms_coupon.json @@ -6,15 +6,19 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "enabled", + "section_break_spfj", "code", - "discount_type", - "percent_off", - "amount_off", - "active", "expires_on", + "column_break_mptc", + "discount_type", + "percentage_discount", + "fixed_amount_discount", + "section_break_ixxu", "usage_limit", - "times_redeemed", - "description", + "column_break_dcvj", + "redemption_count", + "section_break_ophm", "applicable_items" ], "fields": [ @@ -30,31 +34,11 @@ "fieldname": "discount_type", "fieldtype": "Select", "in_list_view": 1, + "in_standard_filter": 1, "label": "Discount Type", - "options": "Percent\nAmount", + "options": "Percentage\nFixed Amount", "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", @@ -64,20 +48,8 @@ { "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" + "label": "Usage Limit" }, { "fieldname": "applicable_items", @@ -85,13 +57,64 @@ "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-12 18:41:35.034875", - "modified_by": "Administrator", + "modified": "2025-10-27 19:52:11.835042", + "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Coupon", "naming_rule": "Random", @@ -162,5 +185,6 @@ "rows_threshold_for_grid_search": 20, "sort_field": "creation", "sort_order": "DESC", - "states": [] + "states": [], + "title_field": "code" } diff --git a/lms/lms/doctype/lms_coupon/lms_coupon.py b/lms/lms/doctype/lms_coupon/lms_coupon.py index d5328d84..1054692b 100644 --- a/lms/lms/doctype/lms_coupon/lms_coupon.py +++ b/lms/lms/doctype/lms_coupon/lms_coupon.py @@ -4,73 +4,28 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import nowdate +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() - if not self.code: - frappe.throw(_("Coupon code is required")) - - if len(self.code) < 6: - frappe.throw(_("Coupon code must be atleast 6 characters")) - - 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 is already taken. Use a different one")) - - if not self.discount_type: - frappe.throw(_("Discount type is required")) - - if self.discount_type == "Percent": - if not self.percent_off or self.percent_off == "": - frappe.throw(_("Discount percentage is required")) - try: - percent_value = float(self.percent_off) - if not (0 < percent_value <= 100): - frappe.throw(_("Discount percentage must be between 1 and 100")) - except (ValueError, TypeError): - frappe.throw(_("Discount percentage must be a valid number")) - self.amount_off = None - - if self.discount_type == "Amount": - if not self.amount_off or self.amount_off == "": - frappe.throw(_("Discount amount is required")) - try: - amount_value = float(self.amount_off) - if amount_value < 0: - frappe.throw(_("Discount amount cannot be negative")) - except (ValueError, TypeError): - frappe.throw(_("Discount amount must be a valid number")) - self.percent_off = None - - if self.usage_limit is not None and self.usage_limit != "": - try: - usage_value = int(self.usage_limit) - if usage_value < 0: - frappe.throw(_("Usage limit cannot be negative")) - except (ValueError, TypeError): - frappe.throw(_("Usage limit must be a valid number")) - + 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(_("Please select atleast one course or batch")) + frappe.throw(_("At least one applicable item is required")) - for item in self.get("applicable_items"): - if not item.get("reference_name"): - frappe.throw(_("Please select a valid course or batch")) + 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")) From 74de43c3d682cdc9b9d0b142c0a962347edda309 Mon Sep 17 00:00:00 2001 From: Rehan Ansari Date: Fri, 31 Oct 2025 00:10:16 +0530 Subject: [PATCH 08/53] fix: ensure options reload after updates --- frontend/src/components/Controls/Link.vue | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/src/components/Controls/Link.vue b/frontend/src/components/Controls/Link.vue index f2a7636e..5742a6fe 100644 --- a/frontend/src/components/Controls/Link.vue +++ b/frontend/src/components/Controls/Link.vue @@ -67,6 +67,7 @@ import { watchDebounced } from '@vueuse/core' import { createResource, Button } from 'frappe-ui' import { Plus, X } from 'lucide-vue-next' import { useAttrs, computed, ref } from 'vue' +import { useSettings } from '@/stores/settings' const props = defineProps({ doctype: { @@ -103,6 +104,7 @@ const value = computed({ const autocomplete = ref(null) const text = ref('') +const settingsStore = useSettings() watchDebounced( () => autocomplete.value?.query, @@ -121,6 +123,16 @@ watchDebounced( { debounce: 300, immediate: true } ) +watchDebounced( + () => settingsStore.isSettingsOpen, + (isOpen, wasOpen) => { + if (wasOpen && !isOpen) { + reload('') + } + }, + { debounce: 200 } +) + const options = createResource({ url: 'frappe.desk.search.search_link', cache: [props.doctype, text.value], From 18e499e6dee7f0463bbaf3ebf4e6d3fe7f641734 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Tue, 4 Nov 2025 15:12:17 +0530 Subject: [PATCH 09/53] fix: coupon code application on billing page --- frontend/src/pages/Billing.vue | 165 +++++++++++++++------------------ lms/lms/utils.py | 143 ++++++++++++++-------------- 2 files changed, 148 insertions(+), 160 deletions(-) diff --git a/frontend/src/pages/Billing.vue b/frontend/src/pages/Billing.vue index 3bac2ca0..dc89a738 100644 --- a/frontend/src/pages/Billing.vue +++ b/frontend/src/pages/Billing.vue @@ -13,83 +13,81 @@ class="pt-5 pb-10 mx-5" >
-
-
-
- {{ __('Payment for ') }} {{ type }}: +
+
+
+
+ {{ __('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 }} -
-
-
- {{ - __('Coupon') - }} +
+ {{ __('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 }} +
+
+
+ +
+ + {{ __('Enter a Coupon Code') }}: + +
+ {{ __('Apply') }} + + variant="outline" + > + +
@@ -148,7 +146,7 @@ />
@@ -193,6 +191,7 @@ import { Breadcrumbs, usePageMeta, toast, + call, } from 'frappe-ui' import { reactive, inject, onMounted, computed, ref } from 'vue' import { sessionStore } from '../stores/session' @@ -238,10 +237,12 @@ const access = createResource({ const orderSummary = createResource({ url: 'lms.lms.utils.get_order_summary', makeParams(values) { + console.log(appliedCoupon.value) return { doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course', docname: props.name, country: billingDetails.country, + coupon: appliedCoupon.value, } }, onError(err) { @@ -249,29 +250,7 @@ const orderSummary = createResource({ }, }) -const couponCode = ref('') const appliedCoupon = ref(null) - -const applyCoupon = createResource({ - url: 'lms.lms.utils.apply_coupon', - makeParams() { - return { - doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course', - docname: props.name, - code: couponCode.value, - country: billingDetails.country, - } - }, - onSuccess(data) { - orderSummary.data = data - appliedCoupon.value = couponCode.value - toast.success(__('Coupon applied')) - }, - onError(err) { - toast.error(err.messages?.[0] || err) - }, -}) - const billingDetails = reactive({}) const setBillingDetails = (data) => { @@ -329,13 +308,15 @@ const generatePaymentLink = () => { } function applyCouponCode() { - if (!couponCode.value) return - applyCoupon.submit() + if (!appliedCoupon.value) { + toast.error(__('Please enter a coupon code')) + return + } + orderSummary.reload() } function removeCoupon() { appliedCoupon.value = null - couponCode.value = '' orderSummary.reload() } diff --git a/lms/lms/utils.py b/lms/lms/utils.py index 927079fc..070c0183 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -953,73 +953,6 @@ def get_current_exchange_rate(source, target="USD"): return details["rates"][target] -@frappe.whitelist() -def apply_coupon(doctype, docname, code, country=None): - 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 - - 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) - - if coupon.expires_on and getdate(coupon.expires_on) < getdate(): - frappe.throw(_("This coupon has expired.")) - - if coupon.usage_limit and cint(coupon.times_redeemed) >= cint(coupon.usage_limit): - frappe.throw(_("This coupon has reached its usage limit.")) - - # check applicability - 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.")) - - 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, - "coupon_code": coupon.code, - "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) @@ -1819,7 +1752,7 @@ def get_discussion_replies(topic): @frappe.whitelist() -def get_order_summary(doctype, docname, country=None): +def get_order_summary(doctype, docname, coupon=None, country=None): if doctype == "LMS Course": details = frappe.db.get_value( "LMS Course", @@ -1856,6 +1789,12 @@ def get_order_summary(doctype, docname, country=None): details.original_amount = details.amount details.original_amount_formatted = fmt_money(details.amount, 0, details.currency) + if coupon: + discount_amount, subtotal = 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) + 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) @@ -1864,6 +1803,74 @@ def get_order_summary(doctype, docname, country=None): return details +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(_("Invalid or inactive coupon 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(doctype, 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 + + +def validate_coupon(doctype, code, coupon): + if doctype not in ["LMS Course", "LMS Batch"]: + frappe.throw(_("Invalid doctype for coupon application.")) + + if not code: + frappe.throw(_("Coupon code is required.")) + + 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") From ab366837a231635e5800503f193b4bf3942abc62 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Tue, 11 Nov 2025 12:17:26 +0530 Subject: [PATCH 10/53] fix: escape HTML in forms --- frontend/package.json | 3 +- .../src/components/Controls/MultiSelect.vue | 24 +- .../src/components/Modals/AssignmentForm.vue | 60 +- frontend/src/pages/BatchForm.vue | 10 + frontend/src/pages/CourseForm.vue | 10 + .../ProgrammingExerciseForm.vue | 10 +- frontend/src/pages/Programs/ProgramForm.vue | 7 +- frontend/src/pages/QuizForm.vue | 6 + frontend/src/pages/Quizzes.vue | 6 + frontend/yarn.lock | 834 +++++++++--------- 10 files changed, 514 insertions(+), 456 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 83805f7c..aa9c52e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@editorjs/paragraph": "^2.11.3", "@editorjs/simple-image": "^1.6.0", "@editorjs/table": "^2.4.2", + "@vueuse/core": "^14.0.0", "@vueuse/router": "^12.7.0", "ace-builds": "^1.36.2", "apexcharts": "^4.3.0", @@ -32,7 +33,7 @@ "dayjs": "^1.11.6", "dompurify": "^3.2.6", "feather-icons": "^4.28.0", - "frappe-ui": "^0.1.201", + "frappe-ui": "^0.1.214", "highlight.js": "^11.11.1", "lucide-vue-next": "^0.383.0", "markdown-it": "^14.0.0", diff --git a/frontend/src/components/Controls/MultiSelect.vue b/frontend/src/components/Controls/MultiSelect.vue index dd36d07d..7f8994f3 100644 --- a/frontend/src/components/Controls/MultiSelect.vue +++ b/frontend/src/components/Controls/MultiSelect.vue @@ -20,8 +20,6 @@ } " autocomplete="off" - @focus="() => togglePopover()" - @keydown.delete.capture.stop="removeLastValue" /> - diff --git a/frontend/src/components/Settings/Transactions/TransactionDetails.vue b/frontend/src/components/Settings/Transactions/TransactionDetails.vue new file mode 100644 index 00000000..832db3fd --- /dev/null +++ b/frontend/src/components/Settings/Transactions/TransactionDetails.vue @@ -0,0 +1,184 @@ + + diff --git a/frontend/src/components/Settings/Transactions.vue b/frontend/src/components/Settings/Transactions/TransactionList.vue similarity index 80% rename from frontend/src/components/Settings/Transactions.vue rename to frontend/src/components/Settings/Transactions/TransactionList.vue index fc87fdca..40a2289e 100644 --- a/frontend/src/components/Settings/Transactions.vue +++ b/frontend/src/components/Settings/Transactions/TransactionList.vue @@ -73,8 +73,7 @@ :disabled="true" />
- {{ getCurrencySymbol(row['currency']) }} - {{ row['total_amount'] }} + {{ getCurrencySymbol(row['currency']) }} {{ row[column.key] }}
{{ row[column.key] }} @@ -97,17 +96,10 @@
- diff --git a/frontend/src/pages/Billing.vue b/frontend/src/pages/Billing.vue index dc89a738..dd952ee7 100644 --- a/frontend/src/pages/Billing.vue +++ b/frontend/src/pages/Billing.vue @@ -70,6 +70,7 @@ @input="appliedCoupon = $event.target.value.toUpperCase()" @keydown.enter="applyCouponCode" placeholder="COUPON2025" + autocomplete="off" class="flex-1 [&_input]:bg-white" />