diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 1cf9d867..7d6f92bf 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -10,7 +10,6 @@ declare module 'vue' { export interface GlobalComponents { Annoucements: typeof import('./src/components/Annoucements.vue')['default'] AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default'] - AppHeader: typeof import('./src/components/AppHeader.vue')['default'] Apps: typeof import('./src/components/Apps.vue')['default'] AppSidebar: typeof import('./src/components/AppSidebar.vue')['default'] AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default'] @@ -43,6 +42,10 @@ declare module 'vue' { CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default'] ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.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'] @@ -73,7 +76,6 @@ declare module 'vue' { InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default'] JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default'] JobCard: typeof import('./src/components/JobCard.vue')['default'] - LayoutHeader: typeof import('./src/components/LayoutHeader.vue')['default'] LessonContent: typeof import('./src/components/LessonContent.vue')['default'] LessonHelp: typeof import('./src/components/LessonHelp.vue')['default'] Link: typeof import('./src/components/Controls/Link.vue')['default'] @@ -109,8 +111,9 @@ declare module 'vue' { StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default'] StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default'] Tags: typeof import('./src/components/Tags.vue')['default'] - TransactionDetails: typeof import('./src/components/Settings/TransactionDetails.vue')['default'] - Transactions: typeof import('./src/components/Settings/Transactions.vue')['default'] + TransactionDetails: typeof import('./src/components/Settings/Transactions/TransactionDetails.vue')['default'] + TransactionList: typeof import('./src/components/Settings/Transactions/TransactionList.vue')['default'] + Transactions: typeof import('./src/components/Settings/Transactions/Transactions.vue')['default'] UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default'] UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default'] Uploader: typeof import('./src/components/Controls/Uploader.vue')['default'] diff --git a/frontend/package.json b/frontend/package.json index aa9c52e2..1ad76a18 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,7 @@ "@editorjs/paragraph": "^2.11.3", "@editorjs/simple-image": "^1.6.0", "@editorjs/table": "^2.4.2", - "@vueuse/core": "^14.0.0", + "@vueuse/core": "^10.4.1", "@vueuse/router": "^12.7.0", "ace-builds": "^1.36.2", "apexcharts": "^4.3.0", diff --git a/frontend/src/components/Controls/Autocomplete.vue b/frontend/src/components/Controls/Autocomplete.vue index d07fa67c..4da8274e 100644 --- a/frontend/src/components/Controls/Autocomplete.vue +++ b/frontend/src/components/Controls/Autocomplete.vue @@ -19,10 +19,10 @@ @click="() => togglePopover()" :disabled="attrs.readonly" > -
+
{{ displayValue(selectedValue) }} diff --git a/frontend/src/components/Settings/Coupons/CouponDetails.vue b/frontend/src/components/Settings/Coupons/CouponDetails.vue new file mode 100644 index 00000000..4a30a6fc --- /dev/null +++ b/frontend/src/components/Settings/Coupons/CouponDetails.vue @@ -0,0 +1,142 @@ + + diff --git a/frontend/src/components/Settings/Coupons/CouponItems.vue b/frontend/src/components/Settings/Coupons/CouponItems.vue new file mode 100644 index 00000000..967ae390 --- /dev/null +++ b/frontend/src/components/Settings/Coupons/CouponItems.vue @@ -0,0 +1,140 @@ + + 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..1518cae4 --- /dev/null +++ b/frontend/src/components/Settings/Coupons/Coupons.vue @@ -0,0 +1,53 @@ + + 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/SettingFields.vue b/frontend/src/components/Settings/SettingFields.vue index 73f3b023..189a486c 100644 --- a/frontend/src/components/Settings/SettingFields.vue +++ b/frontend/src/components/Settings/SettingFields.vue @@ -147,6 +147,8 @@ const columns = computed(() => { } else { if (field.type == 'checkbox') { field.value = props.data[field.name] ? true : false + } else { + field.value = props.data[field.name] } currentColumn.push(field) } diff --git a/frontend/src/components/Settings/Settings.vue b/frontend/src/components/Settings/Settings.vue index fe8b2484..e636c8d8 100644 --- a/frontend/src/components/Settings/Settings.vue +++ b/frontend/src/components/Settings/Settings.vue @@ -29,7 +29,7 @@
{ 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/frontend/src/components/Settings/TransactionDetails.vue b/frontend/src/components/Settings/TransactionDetails.vue deleted file mode 100644 index b996b02d..00000000 --- a/frontend/src/components/Settings/TransactionDetails.vue +++ /dev/null @@ -1,152 +0,0 @@ - - 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 82% rename from frontend/src/components/Settings/Transactions.vue rename to frontend/src/components/Settings/Transactions/TransactionList.vue index 0c6904fb..40a2289e 100644 --- a/frontend/src/components/Settings/Transactions.vue +++ b/frontend/src/components/Settings/Transactions/TransactionList.vue @@ -96,17 +96,10 @@
- diff --git a/frontend/src/pages/BatchForm.vue b/frontend/src/pages/BatchForm.vue index e167af1b..bb6357c7 100644 --- a/frontend/src/pages/BatchForm.vue +++ b/frontend/src/pages/BatchForm.vue @@ -503,7 +503,10 @@ const imageResource = createResource({ const validateFields = () => { Object.keys(batch).forEach((key) => { - if (key != 'description' && typeof batch[key] === 'string') { + if ( + !['description', 'batch_details'].includes(key) && + typeof batch[key] === 'string' + ) { batch[key] = escapeHTML(batch[key]) } }) diff --git a/frontend/src/pages/Billing.vue b/frontend/src/pages/Billing.vue index f23d3792..dd952ee7 100644 --- a/frontend/src/pages/Billing.vue +++ b/frontend/src/pages/Billing.vue @@ -13,47 +13,82 @@ 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 }} +
-
-
- {{ __('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"