feat: implement coupon code manage
ment with billing and transaction integration; bug fixes
This commit is contained in:
Vendored
-2
@@ -10,7 +10,6 @@ declare module 'vue' {
|
|||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
Annoucements: typeof import('./src/components/Annoucements.vue')['default']
|
Annoucements: typeof import('./src/components/Annoucements.vue')['default']
|
||||||
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.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']
|
Apps: typeof import('./src/components/Apps.vue')['default']
|
||||||
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default']
|
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default']
|
||||||
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
|
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
|
||||||
@@ -74,7 +73,6 @@ declare module 'vue' {
|
|||||||
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
||||||
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
|
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
|
||||||
JobCard: typeof import('./src/components/JobCard.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']
|
LessonContent: typeof import('./src/components/LessonContent.vue')['default']
|
||||||
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
|
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
|
||||||
Link: typeof import('./src/components/Controls/Link.vue')['default']
|
Link: typeof import('./src/components/Controls/Link.vue')['default']
|
||||||
|
|||||||
@@ -24,15 +24,28 @@
|
|||||||
:label="__('Discount Amount')"
|
:label="__('Discount Amount')"
|
||||||
type="number"
|
type="number"
|
||||||
/>
|
/>
|
||||||
<FormControl v-model="doc.expires_on" :label="__('Expires On')" type="date" />
|
<FormControl
|
||||||
<FormControl v-model="doc.usage_limit" :label="__('Usage Limit')" type="number" />
|
v-model="doc.expires_on"
|
||||||
|
:label="__('Expires On')"
|
||||||
|
type="date"
|
||||||
|
:description="__('Leave blank for no expiry')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="doc.usage_limit"
|
||||||
|
:label="__('Usage Limit')"
|
||||||
|
type="number"
|
||||||
|
:placeholder="__('Unlimited')"
|
||||||
|
/>
|
||||||
<Switch v-model="doc.active" :label="__('Active')" />
|
<Switch v-model="doc.active" :label="__('Active')" />
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<div class="text-sm font-medium text-ink-gray-7 mb-2">{{ __('Applicable Items (optional)') }}</div>
|
<div class="text-md font-medium text-ink-gray-7 mb-1 mt-2">{{ __('Select Courses/Batches') }}<span class="text-ink-red-3">*</span></div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div v-for="(row, idx) in doc.applicable_items" :key="idx" class="flex gap-2 items-center">
|
<div v-for="(row, idx) in doc.applicable_items" :key="idx" class="flex gap-2 items-end">
|
||||||
<FormControl v-model="row.reference_doctype" :label="__('Type')" type="select" :options="['LMS Course', 'LMS Batch']" />
|
<FormControl class="w-28" v-model="row.reference_doctype" :label="__('Type')" type="select" :options="[
|
||||||
<Link :doctype="row.reference_doctype || 'LMS Course'" :label="__('Item')" :value="row.reference_name" @change="(opt) => (row.reference_name = opt)" />
|
{ label: 'Course ', value: 'LMS Course' },
|
||||||
|
{ label: 'Batch ', value: 'LMS Batch' }
|
||||||
|
]" />
|
||||||
|
<Link class="min-w-40" :doctype="row.reference_doctype || 'LMS Course'" :label="__('Name')" :value="row.reference_name" @change="(opt) => (row.reference_name = opt)" />
|
||||||
<Button variant="subtle" @click="removeRow(idx)">
|
<Button variant="subtle" @click="removeRow(idx)">
|
||||||
<X class="h-3 w-3" />
|
<X class="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -135,7 +148,6 @@ function save() {
|
|||||||
emit('saved')
|
emit('saved')
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
console.log('Save error:', err)
|
|
||||||
toast.error(err.messages?.[0] || err.message || err)
|
toast.error(err.messages?.[0] || err.message || err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -147,7 +159,6 @@ function save() {
|
|||||||
emit('saved')
|
emit('saved')
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
console.log('Insert error:', err)
|
|
||||||
toast.error(err.messages?.[0] || err.message || err)
|
toast.error(err.messages?.[0] || err.message || err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<th class="text-left p-2">{{ __('Expires On') }}</th>
|
<th class="text-left p-2">{{ __('Expires On') }}</th>
|
||||||
<th class="text-left p-2">{{ __('Usage') }}</th>
|
<th class="text-left p-2">{{ __('Usage') }}</th>
|
||||||
<th class="text-left p-2">{{ __('Active') }}</th>
|
<th class="text-left p-2">{{ __('Active') }}</th>
|
||||||
|
<th class="text-right p-2 w-8"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -43,6 +44,13 @@
|
|||||||
<Badge v-if="row.active" theme="green">{{ __('Enabled') }}</Badge>
|
<Badge v-if="row.active" theme="green">{{ __('Enabled') }}</Badge>
|
||||||
<Badge v-else theme="gray">{{ __('Disabled') }}</Badge>
|
<Badge v-else theme="gray">{{ __('Disabled') }}</Badge>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="p-2 text-right" @click.stop>
|
||||||
|
<Button variant="ghost" @click="confirmDelete(row)">
|
||||||
|
<template #icon>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5 text-ink-red-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -52,11 +60,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, Badge, createListResource } from 'frappe-ui'
|
import { Button, Badge, createListResource, toast, call } from 'frappe-ui'
|
||||||
import { ref } from 'vue'
|
import { ref, getCurrentInstance } from 'vue'
|
||||||
import { Plus } from 'lucide-vue-next'
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
import CouponDetails from '@/components/Settings/CouponDetails.vue'
|
import CouponDetails from '@/components/Settings/CouponDetails.vue'
|
||||||
|
|
||||||
|
const app = getCurrentInstance()
|
||||||
|
const { $dialog } = app.appContext.config.globalProperties
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
label: String,
|
label: String,
|
||||||
description: String,
|
description: String,
|
||||||
@@ -78,6 +89,7 @@ const coupons = createListResource({
|
|||||||
'times_redeemed',
|
'times_redeemed',
|
||||||
'active',
|
'active',
|
||||||
],
|
],
|
||||||
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
function openForm(id) {
|
function openForm(id) {
|
||||||
@@ -88,5 +100,31 @@ function openForm(id) {
|
|||||||
function onSaved() {
|
function onSaved() {
|
||||||
coupons.reload()
|
coupons.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmDelete(row) {
|
||||||
|
$dialog({
|
||||||
|
title: __('Delete this coupon?'),
|
||||||
|
message: __('This will permanently delete the coupon and the code will no longer work. Are you sure?'),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Delete'),
|
||||||
|
theme: 'red',
|
||||||
|
variant: 'solid',
|
||||||
|
onClick({ close }) {
|
||||||
|
trashCoupon(row.name, close)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function trashCoupon(name, close) {
|
||||||
|
call('frappe.client.delete', { doctype: 'LMS Coupon', name }).then(() => {
|
||||||
|
toast.success(__('Coupon deleted successfully'))
|
||||||
|
coupons.reload()
|
||||||
|
if (typeof close === 'function') close()
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -67,43 +67,32 @@
|
|||||||
/>
|
/>
|
||||||
<FormControl :label="__('Amount')" v-model="transactionData.amount" />
|
<FormControl :label="__('Amount')" v-model="transactionData.amount" />
|
||||||
<FormControl
|
<FormControl
|
||||||
:label="__('Order ID')"
|
v-if="transactionData.coupon"
|
||||||
v-model="transactionData.order_id"
|
:label="__('Coupon Code')"
|
||||||
|
v-model="transactionData.coupon"
|
||||||
|
:disabled="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||||
v-if="transactionData && (transactionData.coupon || transactionData.discount_amount || transactionData.discount_percent)"
|
<FormControl
|
||||||
class="mt-10"
|
v-if="Number(transactionData.discount_amount)"
|
||||||
>
|
:label="__('Discount Amount')"
|
||||||
<div class="font-semibold">
|
v-model="transactionData.discount_amount"
|
||||||
{{ __('Coupon (if applied)') }}
|
:disabled="true"
|
||||||
</div>
|
/>
|
||||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
<FormControl
|
||||||
<Link
|
v-if="Number(transactionData.gst_amount)"
|
||||||
:label="__('Coupon')"
|
:label="__('GST Amount')"
|
||||||
v-model="transactionData.coupon"
|
v-model="transactionData.gst_amount"
|
||||||
doctype="LMS Coupon"
|
:disabled="true"
|
||||||
:disabled="true"
|
/>
|
||||||
/>
|
<FormControl
|
||||||
<FormControl
|
v-if="Number(transactionData.discount_amount) || Number(transactionData.gst_amount)"
|
||||||
:label="__('Discount Type')"
|
:label="__('Total Amount')"
|
||||||
v-model="transactionData.discount_type"
|
v-model="transactionData.total_amount"
|
||||||
:disabled="true"
|
:disabled="true"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
|
||||||
v-if="transactionData.discount_type === 'Percent'"
|
|
||||||
:label="__('Discount Percent')"
|
|
||||||
v-model="transactionData.discount_percent"
|
|
||||||
:disabled="true"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
v-else
|
|
||||||
:label="__('Discount Amount')"
|
|
||||||
v-model="transactionData.discount_amount"
|
|
||||||
:disabled="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||||
@@ -114,6 +103,13 @@
|
|||||||
v-model="transactionData.payment_id"
|
v-model="transactionData.payment_id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||||
|
<FormControl
|
||||||
|
:label="__('Order ID')"
|
||||||
|
v-model="transactionData.order_id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #actions="{ close }">
|
<template #actions="{ close }">
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
:disabled="true"
|
:disabled="true"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="column.key == 'amount'">
|
<div v-else-if="column.key == 'amount'">
|
||||||
{{ getCurrencySymbol(row['currency']) }} {{ row[column.key] }}
|
{{ getCurrencySymbol(row['currency']) }} {{ row['total_amount'] }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="leading-5 text-sm">
|
<div v-else class="leading-5 text-sm">
|
||||||
{{ row[column.key] }}
|
{{ row[column.key] }}
|
||||||
@@ -153,6 +153,7 @@ const transactions = createListResource({
|
|||||||
'payment_for_certificate',
|
'payment_for_certificate',
|
||||||
'currency',
|
'currency',
|
||||||
'amount',
|
'amount',
|
||||||
|
'total_amount',
|
||||||
'order_id',
|
'order_id',
|
||||||
'payment_id',
|
'payment_id',
|
||||||
'gstin',
|
'gstin',
|
||||||
|
|||||||
@@ -35,6 +35,10 @@
|
|||||||
{{ orderSummary.data.original_amount_formatted }}
|
{{ orderSummary.data.original_amount_formatted }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="orderSummary.data.discount_amount" class="flex items-center justify-between mt-2">
|
||||||
|
<div class="text-ink-gray-5">{{ __('Discount') }}</div>
|
||||||
|
<div>-{{ orderSummary.data.discount_amount_formatted }}</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="orderSummary.data.gst_applied"
|
v-if="orderSummary.data.gst_applied"
|
||||||
class="flex items-center justify-between mt-2"
|
class="flex items-center justify-between mt-2"
|
||||||
@@ -46,10 +50,6 @@
|
|||||||
{{ orderSummary.data.gst_amount_formatted }}
|
{{ orderSummary.data.gst_amount_formatted }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="orderSummary.data.discount_amount" class="flex items-center justify-between mt-2">
|
|
||||||
<div class="text-ink-gray-5">{{ __('Discount') }}</div>
|
|
||||||
<div>-{{ orderSummary.data.discount_amount_formatted }}</div>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between border-t border-outline-gray-3 pt-4 mt-2"
|
class="flex items-center justify-between border-t border-outline-gray-3 pt-4 mt-2"
|
||||||
>
|
>
|
||||||
@@ -63,9 +63,9 @@
|
|||||||
<div class="mb-5">
|
<div class="mb-5">
|
||||||
<div class="flex items-center gap-3 mt-2 flex-wrap md:flex-nowrap" v-if="props.type !== 'certificate'">
|
<div class="flex items-center gap-3 mt-2 flex-wrap md:flex-nowrap" v-if="props.type !== 'certificate'">
|
||||||
<span class="text-ink-gray-5 text-xs shrink-0">{{ __('Coupon') }}</span>
|
<span class="text-ink-gray-5 text-xs shrink-0">{{ __('Coupon') }}</span>
|
||||||
<FormControl class="flex-1 min-w-0 [&_input]:!bg-[#fefefe]" v-model="couponCode" @input="couponCode = $event.target.value.toUpperCase()"/>
|
<FormControl class="flex-1 min-w-0 [&_input]:!bg-[#fefefe]" v-model="couponCode" :disabled="appliedCoupon" @input="couponCode = $event.target.value.toUpperCase()"/>
|
||||||
<Button @click="applyCouponCode" variant="outline">{{ __('Apply') }}</Button>
|
<Button v-if="!appliedCoupon" @click="applyCouponCode" variant="outline">{{ __('Apply') }}</Button>
|
||||||
<Button v-if="appliedCoupon" @click="removeCoupon" variant="subtle">{{ __('Remove') }}</Button>
|
<Button v-if="appliedCoupon" @click="removeCoupon" variant="subtle" class="bg-red-200"><X class="h-4.5 w-4.5" /></Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,6 +174,7 @@ import { reactive, inject, onMounted, computed, ref } from 'vue'
|
|||||||
import { sessionStore } from '../stores/session'
|
import { sessionStore } from '../stores/session'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import NotPermitted from '@/components/NotPermitted.vue'
|
import NotPermitted from '@/components/NotPermitted.vue'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
@@ -239,6 +240,7 @@ const applyCoupon = createResource({
|
|||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
orderSummary.data = data
|
orderSummary.data = data
|
||||||
|
console.log('orderSummary.data - ', orderSummary.data)
|
||||||
appliedCoupon.value = couponCode.value
|
appliedCoupon.value = couponCode.value
|
||||||
toast.success(__('Coupon applied'))
|
toast.success(__('Coupon applied'))
|
||||||
},
|
},
|
||||||
@@ -266,18 +268,20 @@ const setBillingDetails = (data) => {
|
|||||||
const paymentLink = createResource({
|
const paymentLink = createResource({
|
||||||
url: 'lms.lms.payments.get_payment_link',
|
url: 'lms.lms.payments.get_payment_link',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
let data={
|
||||||
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
|
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
|
||||||
docname: props.name,
|
docname: props.name,
|
||||||
title: orderSummary.data.title,
|
title: orderSummary.data.title,
|
||||||
amount: orderSummary.data.original_amount,
|
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,
|
currency: orderSummary.data.currency,
|
||||||
address: billingDetails,
|
address: billingDetails,
|
||||||
redirect_to: redirectTo.value,
|
redirect_to: redirectTo.value,
|
||||||
payment_for_certificate: props.type == 'certificate',
|
payment_for_certificate: props.type == 'certificate',
|
||||||
coupon_code: appliedCoupon.value,
|
coupon_code: appliedCoupon.value,
|
||||||
}
|
}
|
||||||
|
return data
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -308,6 +312,7 @@ function applyCouponCode() {
|
|||||||
|
|
||||||
function removeCoupon() {
|
function removeCoupon() {
|
||||||
appliedCoupon.value = null
|
appliedCoupon.value = null
|
||||||
|
couponCode.value = ''
|
||||||
orderSummary.reload()
|
orderSummary.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ from frappe.utils import nowdate
|
|||||||
|
|
||||||
class LMSCoupon(Document):
|
class LMSCoupon(Document):
|
||||||
def validate(self):
|
def validate(self):
|
||||||
# Normalize code to uppercase and strip spaces
|
|
||||||
if self.code:
|
if self.code:
|
||||||
self.code = self.code.strip().upper()
|
self.code = self.code.strip().upper()
|
||||||
|
|
||||||
if not self.code:
|
if not self.code:
|
||||||
frappe.throw(_("Coupon code is required."))
|
frappe.throw(_("Coupon code is required"))
|
||||||
|
|
||||||
|
if len(self.code) < 6:
|
||||||
|
frappe.throw(_("Coupon code must be atleast 6 characters"))
|
||||||
|
|
||||||
# Ensure uniqueness of code (case-insensitive)
|
|
||||||
if self.name:
|
if self.name:
|
||||||
existing = frappe.db.exists(
|
existing = frappe.db.exists(
|
||||||
"LMS Coupon",
|
"LMS Coupon",
|
||||||
@@ -27,30 +28,49 @@ class LMSCoupon(Document):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
existing = frappe.db.exists("LMS Coupon", {"code": self.code})
|
existing = frappe.db.exists("LMS Coupon", {"code": self.code})
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
frappe.throw(_("Coupon code already exists."))
|
frappe.throw(_("Coupon code is already taken. Use a different one"))
|
||||||
|
|
||||||
if not self.discount_type:
|
if not self.discount_type:
|
||||||
frappe.throw(_("Discount type is required."))
|
frappe.throw(_("Discount type is required"))
|
||||||
|
|
||||||
if self.discount_type == "Percent":
|
if self.discount_type == "Percent":
|
||||||
if self.percent_off is None:
|
if not self.percent_off or self.percent_off == "":
|
||||||
frappe.throw(_("Percent Off is required for Percent discount type."))
|
frappe.throw(_("Discount percentage is required"))
|
||||||
if not (0 < float(self.percent_off) <= 100):
|
try:
|
||||||
frappe.throw(_("Percent Off must be between 1 and 100."))
|
percent_value = float(self.percent_off)
|
||||||
# Clear the other field to avoid confusion
|
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
|
self.amount_off = None
|
||||||
|
|
||||||
if self.discount_type == "Amount":
|
if self.discount_type == "Amount":
|
||||||
if self.amount_off is None:
|
if not self.amount_off or self.amount_off == "":
|
||||||
frappe.throw(_("Amount Off is required for Amount discount type."))
|
frappe.throw(_("Discount amount is required"))
|
||||||
if float(self.amount_off) < 0:
|
try:
|
||||||
frappe.throw(_("Amount Off cannot be negative."))
|
amount_value = float(self.amount_off)
|
||||||
# Clear the other field
|
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
|
self.percent_off = None
|
||||||
|
|
||||||
if self.usage_limit is not None and int(self.usage_limit) < 0:
|
if self.usage_limit is not None and self.usage_limit != "":
|
||||||
frappe.throw(_("Usage limit cannot be negative."))
|
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.expires_on and str(self.expires_on) < nowdate():
|
if self.expires_on and str(self.expires_on) < nowdate():
|
||||||
frappe.throw(_("Expiry date cannot be in the past."))
|
frappe.throw(_("Expiry date cannot be in the past"))
|
||||||
|
|
||||||
|
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"))
|
||||||
|
|||||||
@@ -19,10 +19,9 @@
|
|||||||
"currency",
|
"currency",
|
||||||
"amount",
|
"amount",
|
||||||
"coupon",
|
"coupon",
|
||||||
"discount_type",
|
|
||||||
"discount_percent",
|
|
||||||
"discount_amount",
|
"discount_amount",
|
||||||
"amount_with_gst",
|
"gst_amount",
|
||||||
|
"total_amount",
|
||||||
"column_break_yxpl",
|
"column_break_yxpl",
|
||||||
"order_id",
|
"order_id",
|
||||||
"payment_id",
|
"payment_id",
|
||||||
@@ -57,17 +56,6 @@
|
|||||||
"label": "Coupon",
|
"label": "Coupon",
|
||||||
"options": "LMS Coupon"
|
"options": "LMS Coupon"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"fieldname": "discount_type",
|
|
||||||
"fieldtype": "Select",
|
|
||||||
"label": "Discount Type",
|
|
||||||
"options": "\nPercent\nAmount"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "discount_percent",
|
|
||||||
"fieldtype": "Percent",
|
|
||||||
"label": "Discount Percent"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"fieldname": "discount_amount",
|
"fieldname": "discount_amount",
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
@@ -144,12 +132,6 @@
|
|||||||
"options": "User",
|
"options": "User",
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"depends_on": "eval:doc.currency == \"INR\";",
|
|
||||||
"fieldname": "amount_with_gst",
|
|
||||||
"fieldtype": "Currency",
|
|
||||||
"label": "Amount with GST"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"fieldname": "payment_for_document_type",
|
"fieldname": "payment_for_document_type",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
@@ -176,6 +158,21 @@
|
|||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Payment for Certificate"
|
"label": "Payment for Certificate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"depends_on": "eval:doc.currency == \"INR\";",
|
||||||
|
"fieldname": "gst_amount",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "GST Amount",
|
||||||
|
"options": "currency"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "total_amount",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Total Amount",
|
||||||
|
"options": "currency",
|
||||||
|
"read_only": 1,
|
||||||
|
"reqd": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
@@ -189,8 +186,8 @@
|
|||||||
"link_fieldname": "payment"
|
"link_fieldname": "payment"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2025-09-23 11:04:00.462274",
|
"modified": "2025-10-13 15:25:56.127625",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Payment",
|
"name": "LMS Payment",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import frappe
|
|||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
from frappe.email.doctype.email_template.email_template import get_email_template
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils import add_days, nowdate
|
from frappe.utils import add_days, nowdate, flt
|
||||||
|
|
||||||
|
|
||||||
class LMSPayment(Document):
|
class LMSPayment(Document):
|
||||||
pass
|
def validate(self):
|
||||||
|
amount = flt(self.amount or 0, self.precision("amount"))
|
||||||
|
discount = flt(self.discount_amount or 0, self.precision("discount_amount"))
|
||||||
|
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():
|
def send_payment_reminder():
|
||||||
outgoing_email_account = frappe.get_cached_value(
|
outgoing_email_account = frappe.get_cached_value(
|
||||||
|
|||||||
+52
-54
@@ -1,6 +1,5 @@
|
|||||||
import frappe
|
import frappe
|
||||||
|
|
||||||
|
|
||||||
def get_payment_gateway():
|
def get_payment_gateway():
|
||||||
return frappe.db.get_single_value("LMS Settings", "payment_gateway")
|
return frappe.db.get_single_value("LMS Settings", "payment_gateway")
|
||||||
|
|
||||||
@@ -19,49 +18,46 @@ def validate_currency(payment_gateway, currency):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_payment_link(
|
def get_payment_link(
|
||||||
doctype,
|
doctype,
|
||||||
docname,
|
docname,
|
||||||
title,
|
title,
|
||||||
amount,
|
amount,
|
||||||
total_amount,
|
discount_amount,
|
||||||
currency,
|
gst_amount,
|
||||||
address,
|
currency,
|
||||||
redirect_to,
|
address,
|
||||||
payment_for_certificate,
|
redirect_to,
|
||||||
coupon_code=None,
|
payment_for_certificate,
|
||||||
|
coupon_code=None,
|
||||||
):
|
):
|
||||||
payment_gateway = get_payment_gateway()
|
payment_gateway = get_payment_gateway()
|
||||||
address = frappe._dict(address)
|
address = frappe._dict(address)
|
||||||
amount_with_gst = total_amount if total_amount != amount else 0
|
|
||||||
|
|
||||||
coupon_context = None
|
coupon_context = None
|
||||||
# Coupon application only for courses/batches
|
if doctype in ["LMS Course", "LMS Batch"] and coupon_code:
|
||||||
if doctype in ["LMS Course", "LMS Batch"] and coupon_code:
|
try:
|
||||||
try:
|
from lms.lms.utils import apply_coupon
|
||||||
from lms.lms.utils import apply_coupon
|
coupon_context = apply_coupon(doctype, docname, coupon_code)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
applied = apply_coupon(doctype, docname, coupon_code)
|
payment = record_payment(
|
||||||
# Override total_amount based on validated coupon calculation
|
address,
|
||||||
total_amount = applied.get("amount", total_amount)
|
doctype,
|
||||||
coupon_context = applied
|
docname,
|
||||||
except Exception:
|
amount,
|
||||||
# Ignore coupon errors here; frontend handles validation
|
currency,
|
||||||
pass
|
discount_amount,
|
||||||
|
gst_amount,
|
||||||
payment = record_payment(
|
payment_for_certificate,
|
||||||
address,
|
coupon_context,
|
||||||
doctype,
|
)
|
||||||
docname,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
amount_with_gst,
|
|
||||||
payment_for_certificate,
|
|
||||||
coupon_context,
|
|
||||||
)
|
|
||||||
controller = get_controller(payment_gateway)
|
controller = get_controller(payment_gateway)
|
||||||
|
|
||||||
payment_details = {
|
payment_details = {
|
||||||
"amount": total_amount,
|
"amount": amount - discount_amount + gst_amount,
|
||||||
|
"discount_amount": discount_amount,
|
||||||
|
"gst_amount": gst_amount,
|
||||||
"title": f"Payment for {doctype} {title} {docname}",
|
"title": f"Payment for {doctype} {title} {docname}",
|
||||||
"description": f"{address.billing_name}'s payment for {title}",
|
"description": f"{address.billing_name}'s payment for {title}",
|
||||||
"reference_doctype": doctype,
|
"reference_doctype": doctype,
|
||||||
@@ -83,14 +79,15 @@ def get_payment_link(
|
|||||||
|
|
||||||
|
|
||||||
def record_payment(
|
def record_payment(
|
||||||
address,
|
address,
|
||||||
doctype,
|
doctype,
|
||||||
docname,
|
docname,
|
||||||
amount,
|
amount,
|
||||||
currency,
|
currency,
|
||||||
amount_with_gst=0,
|
discount_amount=0,
|
||||||
payment_for_certificate=0,
|
gst_amount=0,
|
||||||
coupon_context=None,
|
payment_for_certificate=0,
|
||||||
|
coupon_context=None,
|
||||||
):
|
):
|
||||||
address = frappe._dict(address)
|
address = frappe._dict(address)
|
||||||
address_name = save_address(address)
|
address_name = save_address(address)
|
||||||
@@ -103,7 +100,8 @@ def record_payment(
|
|||||||
"address": address_name,
|
"address": address_name,
|
||||||
"amount": amount,
|
"amount": amount,
|
||||||
"currency": currency,
|
"currency": currency,
|
||||||
"amount_with_gst": amount_with_gst,
|
"discount_amount": discount_amount,
|
||||||
|
"gst_amount": gst_amount,
|
||||||
"gstin": address.gstin,
|
"gstin": address.gstin,
|
||||||
"pan": address.pan,
|
"pan": address.pan,
|
||||||
"source": address.source,
|
"source": address.source,
|
||||||
@@ -112,15 +110,15 @@ def record_payment(
|
|||||||
"payment_for_certificate": payment_for_certificate,
|
"payment_for_certificate": payment_for_certificate,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if coupon_context:
|
if coupon_context:
|
||||||
payment_doc.update(
|
payment_doc.update(
|
||||||
{
|
{
|
||||||
"coupon": coupon_context.get("coupon"),
|
"coupon": coupon_context.get("coupon"),
|
||||||
"discount_type": coupon_context.get("discount_type"),
|
"discount_type": coupon_context.get("discount_type"),
|
||||||
"discount_percent": coupon_context.get("discount_percent"),
|
"discount_percent": coupon_context.get("discount_percent"),
|
||||||
"discount_amount": coupon_context.get("discount_amount"),
|
"discount_amount": coupon_context.get("discount_amount"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
payment_doc.save(ignore_permissions=True)
|
payment_doc.save(ignore_permissions=True)
|
||||||
return payment_doc
|
return payment_doc
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user