mirror of
https://github.com/frappe/lms.git
synced 2026-05-02 13:39:31 +03:00
Merge pull request #1777 from JoeBrar/feature/coupons
feat: coupon code discount
This commit is contained in:
Vendored
+7
-4
@@ -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']
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
@click="() => togglePopover()"
|
||||
:disabled="attrs.readonly"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center w-[90%]">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
||||
class="block truncate text-base leading-5"
|
||||
v-if="selectedValue"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="flex flex-col text-base h-full">
|
||||
<div class="flex items-center space-x-2 mb-8 -ml-1.5">
|
||||
<ChevronLeft
|
||||
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="emit('updateStep', 'list')"
|
||||
/>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ data?.name ? __('Edit Coupon') : __('New Coupon') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="data.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormControl
|
||||
v-model="data.code"
|
||||
:label="__('Coupon Code')"
|
||||
:required="true"
|
||||
@input="() => (data.code = data.code.toUpperCase())"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-model="data.discount_type"
|
||||
:label="__('Discount Type')"
|
||||
:required="true"
|
||||
type="select"
|
||||
:options="['Percentage', 'Fixed Amount']"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-model="data.expires_on"
|
||||
:label="__('Expires On')"
|
||||
type="date"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-if="data.discount_type === 'Percentage'"
|
||||
v-model="data.percentage_discount"
|
||||
:required="true"
|
||||
:label="__('Discount Percentage')"
|
||||
type="number"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
v-model="data.fixed_amount_discount"
|
||||
:required="true"
|
||||
:label="__('Discount Amount')"
|
||||
type="number"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="data.usage_limit"
|
||||
:label="__('Usage Limit')"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-model="data.redemptions_count"
|
||||
:label="__('Redemptions Count')"
|
||||
type="number"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="py-8">
|
||||
<div class="font-semibold text-ink-gray-9 mb-2">
|
||||
{{ __('Applicable For') }}
|
||||
</div>
|
||||
<CouponItems ref="couponItems" :data="data" :coupons="coupons" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto space-x-2 ml-auto">
|
||||
<Button variant="solid" @click="saveCoupon()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, FormControl, toast } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import type { Coupon, Coupons } from './types'
|
||||
import CouponItems from '@/components/Settings/Coupons/CouponItems.vue'
|
||||
|
||||
const couponItems = ref<any>(null)
|
||||
const emit = defineEmits(['updateStep'])
|
||||
|
||||
const props = defineProps<{
|
||||
coupons: Coupons
|
||||
data: Coupon
|
||||
}>()
|
||||
|
||||
const saveCoupon = () => {
|
||||
if (props.data?.name) {
|
||||
editCoupon()
|
||||
} else {
|
||||
createCoupon()
|
||||
}
|
||||
}
|
||||
|
||||
const editCoupon = () => {
|
||||
props.coupons.setValue.submit(
|
||||
{
|
||||
...props.data,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Coupon) {
|
||||
if (couponItems.value) {
|
||||
couponItems.value.saveItems()
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const createCoupon = () => {
|
||||
if (couponItems.value) {
|
||||
let rows = couponItems.value.saveItems()
|
||||
props.data.applicable_items = rows
|
||||
}
|
||||
props.coupons.insert.submit(
|
||||
{
|
||||
...props.data,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Coupon) {
|
||||
toast.success(__('Coupon created successfully'))
|
||||
emit('updateStep', 'details', { ...data })
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.error(err.messages?.[0] || err.message || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="relative overflow-x-auto border rounded-md">
|
||||
<table class="w-full text-sm text-left text-ink-gray-5">
|
||||
<thead class="text-xs text-ink-gray-7 uppercase bg-surface-gray-2">
|
||||
<tr>
|
||||
<td scope="col" class="px-6 py-2">
|
||||
{{ __('Document Type') }}
|
||||
</td>
|
||||
<td scope="col" class="px-6 py-2">
|
||||
{{ __('Document Name') }}
|
||||
</td>
|
||||
<td scope="col" class="px-6 py-2 w-16"></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in rows"
|
||||
class="bg-white dark:bg-gray-800 dark:border-gray-700 border-gray-200"
|
||||
>
|
||||
<td class="px-6 py-2">
|
||||
<FormControl
|
||||
type="select"
|
||||
v-model="row.reference_doctype"
|
||||
:options="[
|
||||
{ label: 'Course', value: 'LMS Course' },
|
||||
{ label: 'Batch', value: 'LMS Batch' },
|
||||
]"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-2">
|
||||
<Link
|
||||
:doctype="row.reference_doctype"
|
||||
v-model="row.reference_name"
|
||||
class="bg-white"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-2">
|
||||
<Button variant="ghost" @click="removeRow(row)">
|
||||
<template #icon>
|
||||
<X class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<Button @click="addRow()">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Add Row') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ApplicableItem, Coupon, Coupons } from './types'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Button, createListResource, FormControl } from 'frappe-ui'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const rows = ref<
|
||||
{
|
||||
reference_doctype: string
|
||||
reference_name: string | null
|
||||
name: string | null
|
||||
}[]
|
||||
>([])
|
||||
|
||||
const props = defineProps<{
|
||||
data: Coupon
|
||||
coupons: Coupons
|
||||
}>()
|
||||
|
||||
const applicableItems = createListResource({
|
||||
doctype: 'LMS Coupon Item',
|
||||
fields: [
|
||||
'reference_doctype',
|
||||
'reference_name',
|
||||
'name',
|
||||
'parent',
|
||||
'parenttype',
|
||||
'parentfield',
|
||||
],
|
||||
parent: 'LMS Coupon',
|
||||
onSuccess(data: ApplicableItem[]) {
|
||||
rows.value = data
|
||||
},
|
||||
})
|
||||
|
||||
const addRow = () => {
|
||||
rows.value.push({
|
||||
reference_doctype: 'LMS Course',
|
||||
reference_name: null,
|
||||
name: null,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
() => {
|
||||
if (props.data?.name) {
|
||||
applicableItems.update({
|
||||
filters: {
|
||||
parent: props.data.name,
|
||||
},
|
||||
})
|
||||
applicableItems.reload()
|
||||
} else {
|
||||
addRow()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const saveItems = (parent = null) => {
|
||||
return rows.value
|
||||
}
|
||||
|
||||
const removeRow = (rowToRemove: any) => {
|
||||
rows.value = rows.value.filter((row) => row !== rowToRemove)
|
||||
if (rowToRemove.name) {
|
||||
applicableItems.delete.submit(rowToRemove.name, {
|
||||
onSuccess() {
|
||||
props.coupons.reload()
|
||||
applicableItems.reload()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
saveItems,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="openForm()">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="coupons.data?.length" class="overflow-y-scroll">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="coupons.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: true,
|
||||
onRowClick: (row: Coupon) => {
|
||||
openForm(row)
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in coupons.data" :key="row.name">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key == 'enabled'">
|
||||
<Badge v-if="row[column.key]" theme="green">
|
||||
{{ __('Enabled') }}
|
||||
</Badge>
|
||||
<Badge v-else theme="gray">
|
||||
{{ __('Disabled') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-else-if="column.key == 'expires_on'">
|
||||
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
<div v-else-if="column.key == 'discount'">
|
||||
<div v-if="row['discount_type'] == 'Percentage'">
|
||||
{{ row['percentage_discount'] }}%
|
||||
</div>
|
||||
<div v-else-if="row['discount_type'] == 'Fixed Amount'">
|
||||
{{ row['fixed_amount_discount'] }}/-
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="leading-5 text-sm">
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="confirmDeletion(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else class="text-center text-ink-gray-6 italic mt-40">
|
||||
{{ __('No coupons created yet.') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, getCurrentInstance, inject, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import type { Coupon, Coupons } from './types'
|
||||
|
||||
const dayjs = inject('$dayjs') as typeof import('dayjs')
|
||||
const app = getCurrentInstance()
|
||||
const $dialog = app?.appContext.config.globalProperties.$dialog
|
||||
const emit = defineEmits(['updateStep'])
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
description: string
|
||||
coupons: Coupons
|
||||
}>()
|
||||
|
||||
const openForm = (coupon: Coupon = {} as Coupon) => {
|
||||
emit('updateStep', 'details', { ...coupon })
|
||||
}
|
||||
|
||||
const confirmDeletion = (selections: any[], unselectAll: () => void) => {
|
||||
if (selections.length === 0) {
|
||||
toast.info(__('No coupons selected for deletion'))
|
||||
return
|
||||
}
|
||||
$dialog({
|
||||
title: __('Delete this coupon?'),
|
||||
message: __(
|
||||
'This will permanently delete the coupon and the code will no longer be valid.'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick({ close }: { close: () => void }) {
|
||||
call('lms.lms.api.delete_documents', {
|
||||
doctype: 'LMS Coupon',
|
||||
documents: Array.from(selections),
|
||||
}).then((data: any) => {
|
||||
toast.success(__('Coupon(s) deleted successfully'))
|
||||
coupons.reload()
|
||||
unselectAll()
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Code'),
|
||||
key: 'code',
|
||||
icon: 'tag',
|
||||
width: '150px',
|
||||
},
|
||||
{
|
||||
label: __('Discount'),
|
||||
key: 'discount',
|
||||
align: 'center',
|
||||
width: '80px',
|
||||
icon: 'dollar-sign',
|
||||
},
|
||||
{
|
||||
label: __('Expires On'),
|
||||
key: 'expires_on',
|
||||
width: '120px',
|
||||
icon: 'calendar',
|
||||
},
|
||||
{
|
||||
label: __('Usage Limit'),
|
||||
key: 'usage_limit',
|
||||
align: 'center',
|
||||
width: '100px',
|
||||
icon: 'hash',
|
||||
},
|
||||
{
|
||||
label: __('Redemption Count'),
|
||||
key: 'redemption_count',
|
||||
align: 'center',
|
||||
width: '100px',
|
||||
icon: 'users',
|
||||
},
|
||||
{
|
||||
label: __('Enabled'),
|
||||
key: 'enabled',
|
||||
align: 'center',
|
||||
icon: 'check-square',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<CouponList
|
||||
v-if="step === 'list'"
|
||||
:label="props.label"
|
||||
:description="props.description"
|
||||
:coupons="coupons"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
<CouponDetails
|
||||
v-else-if="step == 'details'"
|
||||
:coupons="coupons"
|
||||
:data="data"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { createListResource } from 'frappe-ui'
|
||||
import CouponList from '@/components/Settings/Coupons/CouponList.vue'
|
||||
import CouponDetails from '@/components/Settings/Coupons/CouponDetails.vue'
|
||||
import type { Coupon } from './types'
|
||||
|
||||
const step = ref('list')
|
||||
const data = ref<Coupon | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
description: string
|
||||
}>()
|
||||
|
||||
const updateStep = (newStep: 'list' | 'new' | 'edit', newData: Coupon) => {
|
||||
step.value = newStep
|
||||
if (newData) {
|
||||
data.value = newData
|
||||
}
|
||||
}
|
||||
|
||||
const coupons = createListResource({
|
||||
doctype: 'LMS Coupon',
|
||||
fields: [
|
||||
'name',
|
||||
'code',
|
||||
'discount_type',
|
||||
'percentage_discount',
|
||||
'fixed_amount_discount',
|
||||
'expires_on',
|
||||
'usage_limit',
|
||||
'redemption_count',
|
||||
'enabled',
|
||||
],
|
||||
auto: true,
|
||||
})
|
||||
</script>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<div
|
||||
v-if="activeTab && data.doc"
|
||||
:key="activeTab.label"
|
||||
class="flex flex-1 flex-col px-10 py-8 bg-surface-modal"
|
||||
class="flex flex-1 flex-col p-8 bg-surface-modal"
|
||||
>
|
||||
<component
|
||||
v-if="activeTab.template"
|
||||
@@ -78,7 +78,8 @@ 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 Transactions from '@/components/Settings/Transactions.vue'
|
||||
import Coupons from '@/components/Settings/Coupons/Coupons.vue'
|
||||
import Transactions from '@/components/Settings/Transactions/Transactions.vue'
|
||||
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
|
||||
import Badges from '@/components/Settings/Badges.vue'
|
||||
|
||||
@@ -233,6 +234,12 @@ const tabsStructure = computed(() => {
|
||||
template: markRaw(Transactions),
|
||||
description: 'View all your payment transactions',
|
||||
},
|
||||
{
|
||||
label: 'Coupons',
|
||||
icon: 'Ticket',
|
||||
template: markRaw(Coupons),
|
||||
description: 'Manage discount coupons for courses and batches',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Transaction Details'),
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div v-if="transactionData" class="text-base">
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<FormControl
|
||||
:label="__('Payment Received')"
|
||||
type="checkbox"
|
||||
v-model="transactionData.payment_received"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Payment For Certificate')"
|
||||
type="checkbox"
|
||||
v-model="transactionData.payment_for_certificate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<Link
|
||||
:label="__('Member')"
|
||||
doctype="User"
|
||||
v-model="transactionData.member"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Billing Name')"
|
||||
v-model="transactionData.billing_name"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Source')"
|
||||
v-model="transactionData.source"
|
||||
doctype="LMS Source"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold mt-10">
|
||||
{{ __('Payment Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<Link
|
||||
:label="__('Payment For Document Type')"
|
||||
v-model="transactionData.payment_for_document_type"
|
||||
doctype="DocType"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Payment For Document')"
|
||||
v-model="transactionData.payment_for_document"
|
||||
:doctype="transactionData.payment_for_document_type"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Address')"
|
||||
v-model="transactionData.address"
|
||||
doctype="Address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<Link
|
||||
:label="__('Currency')"
|
||||
v-model="transactionData.currency"
|
||||
doctype="Currency"
|
||||
/>
|
||||
<FormControl :label="__('Amount')" v-model="transactionData.amount" />
|
||||
<FormControl
|
||||
:label="__('Order ID')"
|
||||
v-model="transactionData.order_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<FormControl :label="__('GSTIN')" v-model="transactionData.gstin" />
|
||||
<FormControl :label="__('PAN')" v-model="transactionData.pan" />
|
||||
<FormControl
|
||||
:label="__('Payment ID')"
|
||||
v-model="transactionData.payment_id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="space-x-2 pb-5 float-right">
|
||||
<Button @click="openDetails(close)">
|
||||
{{ __('Open the ') }}
|
||||
{{
|
||||
transaction.payment_for_document_type == 'LMS Course'
|
||||
? __('Course')
|
||||
: __('Batch')
|
||||
}}
|
||||
</Button>
|
||||
<Button variant="solid" @click="saveTransaction(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Dialog, FormControl, Button } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const transactions = defineModel<any>('transactions')
|
||||
const router = useRouter()
|
||||
const showModal = defineModel('show')
|
||||
const transactionData = ref<{ [key: string]: any } | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
transaction: { [key: string]: any } | null
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.transaction,
|
||||
(newVal) => {
|
||||
transactionData.value = newVal ? { ...newVal } : null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const saveTransaction = (close: () => void) => {
|
||||
transactions.value.setValue
|
||||
.submit({
|
||||
...transactionData.value,
|
||||
})
|
||||
.then(() => {
|
||||
close()
|
||||
})
|
||||
}
|
||||
|
||||
const openDetails = (close: Function) => {
|
||||
if (props.transaction) {
|
||||
const docType = props.transaction.payment_for_document_type
|
||||
const docName = props.transaction.payment_for_document
|
||||
if (docType && docName) {
|
||||
router.push({
|
||||
name: docType == 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
|
||||
params: {
|
||||
[docType == 'LMS Course' ? 'courseName' : 'batchName']: docName,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
close()
|
||||
showModal.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full text-base">
|
||||
<div class="flex items-center space-x-2 mb-8 -ml-1.5">
|
||||
<ChevronLeft
|
||||
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="emit('updateStep', 'list')"
|
||||
/>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ __('Transaction Details') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="transactionData" class="overflow-y-auto">
|
||||
<div class="grid grid-cols-3 gap-5">
|
||||
<FormControl
|
||||
:label="__('Payment Received')"
|
||||
type="checkbox"
|
||||
v-model="transactionData.payment_received"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Payment For Certificate')"
|
||||
type="checkbox"
|
||||
v-model="transactionData.payment_for_certificate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<Link
|
||||
:label="__('Member')"
|
||||
doctype="User"
|
||||
v-model="transactionData.member"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Billing Name')"
|
||||
v-model="transactionData.billing_name"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Source')"
|
||||
v-model="transactionData.source"
|
||||
doctype="LMS Source"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Payment For Document Type')"
|
||||
v-model="transactionData.payment_for_document_type"
|
||||
doctype="DocType"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Payment For Document')"
|
||||
v-model="transactionData.payment_for_document"
|
||||
:doctype="transactionData.payment_for_document_type"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold mt-10">
|
||||
{{ __('Payment Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<Link
|
||||
:label="__('Currency')"
|
||||
v-model="transactionData.currency"
|
||||
doctype="Currency"
|
||||
/>
|
||||
<FormControl :label="__('Amount')" v-model="transactionData.amount" />
|
||||
<FormControl
|
||||
v-if="transactionData.amount_with_gst"
|
||||
:label="__('Amount with GST')"
|
||||
v-model="transactionData.amount_with_gst"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="transactionData.coupon">
|
||||
<div class="font-semibold mt-10">
|
||||
{{ __('Coupon Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<FormControl
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Coupon Code')"
|
||||
v-model="transactionData.coupon"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Coupon Code')"
|
||||
v-model="transactionData.coupon_code"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Discount Amount')"
|
||||
v-model="transactionData.discount_amount"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Original Amount')"
|
||||
v-model="transactionData.original_amount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold mt-10">
|
||||
{{ __('Billing Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<Link
|
||||
:label="__('Address')"
|
||||
v-model="transactionData.address"
|
||||
doctype="Address"
|
||||
/>
|
||||
<FormControl :label="__('GSTIN')" v-model="transactionData.gstin" />
|
||||
<FormControl :label="__('PAN')" v-model="transactionData.pan" />
|
||||
<FormControl
|
||||
:label="__('Payment ID')"
|
||||
v-model="transactionData.payment_id"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Order ID')"
|
||||
v-model="transactionData.order_id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2 mt-auto ml-auto">
|
||||
<Button @click="openDetails()">
|
||||
{{ __('Open the ') }}
|
||||
{{
|
||||
data.payment_for_document_type == 'LMS Course'
|
||||
? __('Course')
|
||||
: __('Batch')
|
||||
}}
|
||||
</Button>
|
||||
<Button variant="solid" @click="saveTransaction()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, FormControl } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch } from 'vue'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const transactionData = ref<{ [key: string]: any } | null>(null)
|
||||
const emit = defineEmits(['updateStep'])
|
||||
const show = defineModel('show')
|
||||
|
||||
const props = defineProps<{
|
||||
transactions: any
|
||||
data: any
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(newVal) => {
|
||||
transactionData.value = newVal ? { ...newVal } : null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const saveTransaction = (close: () => void) => {
|
||||
props.transactions.value.setValue
|
||||
.submit({
|
||||
...transactionData.value,
|
||||
})
|
||||
.then(() => {
|
||||
close()
|
||||
})
|
||||
}
|
||||
|
||||
const openDetails = () => {
|
||||
if (props.data) {
|
||||
const docType = props.data.payment_for_document_type
|
||||
const docName = props.data.payment_for_document
|
||||
if (docType && docName) {
|
||||
router.push({
|
||||
name: docType == 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
|
||||
params: {
|
||||
[docType == 'LMS Course' ? 'courseName' : 'batchName']: docName,
|
||||
},
|
||||
})
|
||||
}
|
||||
show.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+9
-48
@@ -96,17 +96,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TransactionDetails
|
||||
v-model="showForm"
|
||||
:transaction="currentTransaction"
|
||||
v-model:transactions="transactions"
|
||||
v-model:show="show"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Button,
|
||||
createListResource,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
@@ -118,50 +111,19 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
import TransactionDetails from './TransactionDetails.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const showForm = ref(false)
|
||||
const currentTransaction = ref<{ [key: string]: any } | null>(null)
|
||||
const show = defineModel('show')
|
||||
const billingName = ref(null)
|
||||
const paymentReceived = ref(false)
|
||||
const paymentForCertificate = ref(false)
|
||||
const member = ref(null)
|
||||
const emit = defineEmits(['updateStep'])
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const transactions = createListResource({
|
||||
doctype: 'LMS Payment',
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'billing_name',
|
||||
'source',
|
||||
'payment_for_document_type',
|
||||
'payment_for_document',
|
||||
'payment_received',
|
||||
'payment_for_certificate',
|
||||
'currency',
|
||||
'amount',
|
||||
'order_id',
|
||||
'payment_id',
|
||||
'gstin',
|
||||
'pan',
|
||||
'address',
|
||||
],
|
||||
auto: true,
|
||||
orderBy: 'modified desc',
|
||||
})
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
description: string
|
||||
transactions: any
|
||||
}>()
|
||||
|
||||
watch(
|
||||
[billingName, member, paymentReceived, paymentForCertificate],
|
||||
@@ -171,7 +133,7 @@ watch(
|
||||
newPaymentReceived,
|
||||
newPaymentForCertificate,
|
||||
]) => {
|
||||
transactions.update({
|
||||
props.transactions.update({
|
||||
filters: [
|
||||
newBillingName ? [['billing_name', 'like', `%${newBillingName}%`]] : [],
|
||||
newMember ? [['member', '=', newMember]] : [],
|
||||
@@ -183,14 +145,13 @@ watch(
|
||||
: [],
|
||||
].flat(),
|
||||
})
|
||||
transactions.reload()
|
||||
props.transactions.reload()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const openForm = (transaction: { [key: string]: any }) => {
|
||||
currentTransaction.value = transaction
|
||||
showForm.value = true
|
||||
emit('updateStep', 'details', { ...transaction })
|
||||
}
|
||||
|
||||
const getCurrencySymbol = (currency: string) => {
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<TransactionList
|
||||
v-if="step === 'list'"
|
||||
:label="props.label"
|
||||
:description="props.description"
|
||||
:transactions="transactions"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
<TransactionDetails
|
||||
v-else-if="step == 'details'"
|
||||
:transactions="transactions"
|
||||
:data="data"
|
||||
v-model:show="show"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { createListResource } from 'frappe-ui'
|
||||
import TransactionList from '@/components/Settings/Transactions/TransactionList.vue'
|
||||
import TransactionDetails from '@/components/Settings/Transactions/TransactionDetails.vue'
|
||||
|
||||
const step = ref('list')
|
||||
const data = ref<any | null>(null)
|
||||
const show = defineModel('show')
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
description: string
|
||||
}>()
|
||||
|
||||
const updateStep = (newStep: 'list' | 'new' | 'edit', newData: any) => {
|
||||
step.value = newStep
|
||||
if (newData) {
|
||||
data.value = newData
|
||||
}
|
||||
}
|
||||
|
||||
const transactions = createListResource({
|
||||
doctype: 'LMS Payment',
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'billing_name',
|
||||
'source',
|
||||
'payment_for_document_type',
|
||||
'payment_for_document',
|
||||
'payment_received',
|
||||
'payment_for_certificate',
|
||||
'currency',
|
||||
'amount',
|
||||
'amount_with_gst',
|
||||
'coupon',
|
||||
'coupon_code',
|
||||
'discount_amount',
|
||||
'original_amount',
|
||||
'order_id',
|
||||
'payment_id',
|
||||
'gstin',
|
||||
'pan',
|
||||
'address',
|
||||
],
|
||||
auto: true,
|
||||
orderBy: 'modified desc',
|
||||
})
|
||||
</script>
|
||||
@@ -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])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,47 +13,82 @@
|
||||
class="pt-5 pb-10 mx-5"
|
||||
>
|
||||
<div class="flex flex-col lg:flex-row justify-between">
|
||||
<div
|
||||
class="h-fit bg-surface-gray-2 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 font-medium lg:w-1/3"
|
||||
>
|
||||
<div class="flex items-baseline justify-between space-y-2">
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __('Payment for ') }} {{ type }}:
|
||||
<div class="flex flex-col lg:order-last mb-10 lg:mt-10 lg:w-1/4">
|
||||
<div class="h-fit bg-surface-gray-2 rounded-md p-5 space-y-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-ink-gray-5 uppercase text-xs">
|
||||
{{ __('Payment for ') }} {{ type }}:
|
||||
</div>
|
||||
<div class="leading-5 text-ink-gray-9">
|
||||
{{ orderSummary.data.title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="leading-5 text-ink-gray-9">
|
||||
{{ orderSummary.data.title }}
|
||||
<div
|
||||
v-if="
|
||||
orderSummary.data.gst_applied ||
|
||||
orderSummary.data.discount_amount
|
||||
"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="text-ink-gray-5 uppercase text-xs">
|
||||
{{ __('Original Amount') }}:
|
||||
</div>
|
||||
<div class="text-ink-gray-9">
|
||||
{{ orderSummary.data.original_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="orderSummary.data.discount_amount" class="space-y-1">
|
||||
<div class="text-ink-gray-5">{{ __('Discount') }}:</div>
|
||||
<div>- {{ orderSummary.data.discount_amount_formatted }}</div>
|
||||
</div>
|
||||
<div v-if="orderSummary.data.gst_applied" class="space-y-1">
|
||||
<div class="text-ink-gray-5 uppercase text-xs">
|
||||
{{ __('GST Amount') }}:
|
||||
</div>
|
||||
<div class="text-ink-gray-9">
|
||||
{{ orderSummary.data.gst_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1 border-t border-outline-gray-3 pt-4 mt-2">
|
||||
<div class="uppercase text-ink-gray-5 text-xs">
|
||||
{{ __('Total') }}:
|
||||
</div>
|
||||
<div class="font-bold text-ink-gray-9">
|
||||
{{ orderSummary.data.total_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="orderSummary.data.gst_applied"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __('Original Amount') }}
|
||||
</div>
|
||||
<div class="text-ink-gray-9">
|
||||
{{ orderSummary.data.original_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="orderSummary.data.gst_applied"
|
||||
class="flex items-center justify-between mt-2"
|
||||
>
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __('GST Amount') }}
|
||||
</div>
|
||||
<div class="text-ink-gray-9">
|
||||
{{ orderSummary.data.gst_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-outline-gray-3 pt-4 mt-2"
|
||||
>
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Total') }}
|
||||
</div>
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ orderSummary.data.total_amount_formatted }}
|
||||
|
||||
<div class="bg-surface-gray-2 rounded-md p-4 space-y-2 my-5">
|
||||
<span class="text-ink-gray-5 uppercase text-xs">
|
||||
{{ __('Enter a Coupon Code') }}:
|
||||
</span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormControl
|
||||
v-model="appliedCoupon"
|
||||
:disabled="orderSummary.data.discount_amount > 0"
|
||||
@input="appliedCoupon = $event.target.value.toUpperCase()"
|
||||
@keydown.enter="applyCouponCode"
|
||||
placeholder="COUPON2025"
|
||||
autocomplete="off"
|
||||
class="flex-1 [&_input]:bg-white"
|
||||
/>
|
||||
<Button
|
||||
v-if="!orderSummary.data.discount_amount"
|
||||
@click="applyCouponCode"
|
||||
variant="outline"
|
||||
>
|
||||
{{ __('Apply') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="orderSummary.data.discount_amount"
|
||||
@click="removeCoupon"
|
||||
variant="outline"
|
||||
>
|
||||
<template #icon>
|
||||
<X class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,7 +147,7 @@
|
||||
/>
|
||||
<FormControl
|
||||
v-if="billingDetails.country == 'India'"
|
||||
:label="__('Pan Number')"
|
||||
:label="__('PAN Number')"
|
||||
v-model="billingDetails.pan"
|
||||
/>
|
||||
</div>
|
||||
@@ -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) => {
|
||||
|
||||
+6
-25
@@ -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"
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
+51
-7
@@ -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
|
||||
|
||||
|
||||
+181
-62
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user