Merge pull request #1035 from pateljannat/payments

feat: payments app integration
This commit is contained in:
Jannat Patel
2024-10-02 12:22:00 +05:30
committed by GitHub
15 changed files with 583 additions and 568 deletions

View File

@@ -52,7 +52,6 @@ const props = defineProps({
const saveSettings = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
console.log(values)
return {
doctype: 'Website Settings',
name: 'Website Settings',
@@ -77,7 +76,6 @@ const update = () => {
}
watch(props.data, (newData) => {
console.log(newData)
if (newData && !isDirty.value) {
isDirty.value = true
}

View File

@@ -45,6 +45,13 @@
:label="activeTab.label"
:description="activeTab.description"
/>
<PaymentSettings
v-else-if="activeTab.label === 'Payment Gateway'"
:label="activeTab.label"
:description="activeTab.description"
:data="data"
:fields="activeTab.fields"
/>
<BrandSettings
v-else-if="activeTab.label === 'Branding'"
:label="activeTab.label"
@@ -73,6 +80,7 @@ import SidebarLink from '@/components/SidebarLink.vue'
import Members from '@/components/Members.vue'
import Categories from '@/components/Categories.vue'
import BrandSettings from '@/components/BrandSettings.vue'
import PaymentSettings from '@/components/PaymentSettings.vue'
const show = defineModel()
const doctype = ref('LMS Settings')
@@ -106,17 +114,6 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{
label: 'Categories',
description: 'Manage the members of your learning system',
icon: 'Network',
},
],
},
{
label: 'Settings',
hideLabel: true,
@@ -128,14 +125,10 @@ const tabsStructure = computed(() => {
'Configure the payment gateway and other payment related settings',
fields: [
{
label: 'Razorpay Key',
name: 'razorpay_key',
type: 'text',
},
{
label: 'Razorpay Secret',
name: 'razorpay_secret',
type: 'password',
label: 'Payment Gateway',
name: 'payment_gateway',
type: 'Link',
doctype: 'Payment Gateway',
},
{
label: 'Default Currency',
@@ -143,9 +136,6 @@ const tabsStructure = computed(() => {
type: 'Link',
doctype: 'Currency',
},
{
type: 'Column Break',
},
{
label: 'Apply GST for India',
name: 'apply_gst',
@@ -165,6 +155,17 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{
label: 'Categories',
description: 'Manage the members of your learning system',
icon: 'Network',
},
],
},
{
label: 'Customise',
hideLabel: false,

View File

@@ -0,0 +1,109 @@
<template>
<div class="flex flex-col h-full">
<div class="flex items-center justify-between">
<div class="text-xl font-semibold mb-1">
{{ label }}
</div>
<!-- <Badge
v-if="isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/> -->
</div>
<div class="overflow-y-scroll">
<div class="flex space-x-4">
<SettingFields :fields="fields" :data="data.doc" class="w-1/2" />
<SettingFields
v-if="paymentGateway.data"
:fields="paymentGateway.data.fields"
:data="paymentGateway.data.data"
class="w-1/2"
/>
</div>
</div>
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" @click="update">
{{ __('Update') }}
</Button>
</div>
</div>
</template>
<script setup>
import SettingFields from '@/components/SettingFields.vue'
import { createResource, Badge, Button } from 'frappe-ui'
import { watch, ref } from 'vue'
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
fields: {
type: Array,
required: true,
},
})
const paymentGateway = createResource({
url: 'lms.lms.api.get_payment_gateway_details',
makeParams(values) {
return {
payment_gateway: props.data.doc.payment_gateway,
}
},
auto: true,
})
const saveSettings = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
let fields = {}
Object.keys(paymentGateway.data.data).forEach((key) => {
if (
paymentGateway.data.data[key] &&
typeof paymentGateway.data.data[key] === 'object'
) {
fields[key] = paymentGateway.data.data[key].file_url
} else {
fields[key] = paymentGateway.data.data[key]
}
})
return {
doctype: paymentGateway.data.doctype,
name: paymentGateway.data.docname,
fieldname: fields,
}
},
auto: false,
onSuccess(data) {
paymentGateway.reload()
},
})
const update = () => {
props.fields.forEach((f) => {
if (f.type != 'Column Break') {
props.data.doc[f.name] = f.value
}
})
props.data.save.submit()
saveSettings.submit()
}
watch(
() => props.data.doc.payment_gateway,
() => {
paymentGateway.reload()
}
)
</script>

View File

@@ -80,6 +80,7 @@
:label="__(field.label)"
:type="field.type"
:rows="field.rows"
:options="field.options"
/>
</div>
</div>
@@ -89,7 +90,7 @@
<script setup>
import { FormControl, FileUploader, Button } from 'frappe-ui'
import { computed } from 'vue'
import { getFileSize } from '@/utils'
import { getFileSize, validateFile } from '@/utils'
import { X, FileText } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue'
import Codemirror from 'codemirror-editor-vue3'

View File

@@ -1,44 +1,50 @@
<template>
<div class="">
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs
class="h-7"
:items="[{ label: __('Billing Details'), route: { name: 'Billing' } }]"
/>
</header>
<div
v-if="access.data?.access && orderSummary.data"
class="mt-10 w-1/2 mx-auto"
class="pt-5 pb-10 mx-5"
>
<div class="text-3xl font-bold">
{{ __('Billing Details') }}
</div>
<div class="text-gray-600 mt-1">
{{ __('Enter the billing information to complete the payment.') }}
</div>
<div class="border rounded-md p-5 mt-5">
<div class="text-xl font-semibold">
{{ __('Summary') }}
<!-- <div class="mb-5">
<div class="text-lg font-semibold">
{{ __('Address') }}
</div>
<div class="text-gray-600 mt-1">
{{ __('Review the details of your purchase.') }}
</div>
<div class="mt-5">
<div class="flex items-center justify-between">
<div>
</div> -->
<div class="flex flex-col lg:flex-row justify-between">
<div
class="h-fit bg-gray-100 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 text-sm font-medium lg:w-1/4"
>
<div class="flex items-center justify-between space-x-2">
<div class="text-gray-600">
{{ __('Ordered Item') }}
</div>
<div class="">
{{ orderSummary.data.title }}
</div>
<div
:class="{
'font-semibold text-xl': !orderSummary.data.gst_applied,
}"
>
{{
orderSummary.data.gst_applied
? orderSummary.data.original_amount_formatted
: orderSummary.data.total_amount_formatted
}}
</div>
<div
v-if="orderSummary.data.gst_applied"
class="flex items-center justify-between"
>
<div class="text-gray-600">
{{ __('Original Amount') }}
</div>
<div class="">
{{ orderSummary.data.original_amount_formatted }}
</div>
</div>
<div
v-if="orderSummary.data.gst_applied"
class="flex items-center justify-between mt-2"
>
<div>
<div class="text-gray-600">
{{ __('GST Amount') }}
</div>
<div>
@@ -46,107 +52,80 @@
</div>
</div>
<div
v-if="orderSummary.data.gst_applied"
class="flex items-center justify-between mt-2"
class="flex items-center justify-between border-t border-gray-400 pt-4 mt-2"
>
<div>
{{ __('Total Amount') }}
<div class="text-lg font-semibold">
{{ __('Total') }}
</div>
<div class="font-semibold text-2xl">
<div class="text-lg font-semibold">
{{ orderSummary.data.total_amount_formatted }}
</div>
</div>
</div>
<div class="text-xl font-semibold mt-10">
{{ __('Address') }}
</div>
<div class="text-gray-600 mt-1">
{{ __('Specify your billing address correctly.') }}
</div>
<div class="grid grid-cols-2 gap-5 mt-4">
<div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Billing Name') }}
</div>
<Input type="text" v-model="billingDetails.billing_name" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Address Line 1') }}
</div>
<Input type="text" v-model="billingDetails.address_line1" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Address Line 2') }}
</div>
<Input type="text" v-model="billingDetails.address_line2" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('City') }}
</div>
<Input type="text" v-model="billingDetails.city" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('State') }}
</div>
<Input type="text" v-model="billingDetails.state" />
<div class="flex-1 lg:mr-10">
<div class="mb-5">
<div class="text-lg font-semibold">
{{ __('Address') }}
</div>
</div>
<div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Country') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-4">
<FormControl
:label="__('Billing Name')"
v-model="billingDetails.billing_name"
/>
<FormControl
:label="__('Address Line 1')"
v-model="billingDetails.address_line1"
/>
<FormControl
:label="__('Address Line 2')"
v-model="billingDetails.address_line2"
/>
<FormControl :label="__('City')" v-model="billingDetails.city" />
<FormControl
:label="__('State')"
v-model="billingDetails.state"
/>
</div>
<div class="space-y-4">
<Link
doctype="Country"
:value="billingDetails.country"
@change="(option) => changeCurrency(option)"
:label="__('Country')"
/>
<FormControl
:label="__('Postal Code')"
v-model="billingDetails.pincode"
/>
<FormControl
:label="__('Phone Number')"
v-model="billingDetails.phone"
/>
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Postal Code') }}
</div>
<Input type="text" v-model="billingDetails.pincode" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Phone Number') }}
</div>
<Input type="text" v-model="billingDetails.phone" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Source') }}
</div>
<Link
doctype="LMS Source"
:value="billingDetails.source"
@change="(option) => (billingDetails.source = option)"
:label="__('Where did you hear about us?')"
/>
<FormControl
v-if="billingDetails.country == 'India'"
:label="__('GST Number')"
v-model="billingDetails.gstin"
/>
<FormControl
v-if="billingDetails.country == 'India'"
:label="__('Pan Number')"
v-model="billingDetails.pan"
/>
</div>
<div v-if="billingDetails.country == 'India'" class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('GST Number') }}
</div>
<Input type="text" v-model="billingDetails.gstin" />
</div>
<div v-if="billingDetails.country == 'India'" class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Pan Number') }}
</div>
<Input type="text" v-model="billingDetails.pan" />
</div>
</div>
<Button variant="solid" class="mt-8" @click="generatePaymentLink()">
{{ __('Proceed to Payment') }}
</Button>
</div>
<Button variant="solid" class="mt-8" @click="generatePaymentLink()">
{{ __('Proceed to Payment') }}
</Button>
</div>
</div>
<div v-else-if="access.data?.message">
@@ -167,11 +146,18 @@
</div>
</template>
<script setup>
import { Input, Button, createResource } from 'frappe-ui'
import {
Input,
Button,
createResource,
FormControl,
Breadcrumbs,
Tooltip,
} from 'frappe-ui'
import { reactive, inject, onMounted, ref } from 'vue'
import Link from '@/components/Controls/Link.vue'
import NotPermitted from '@/components/NotPermitted.vue'
import { createToast } from '@/utils/'
import { showToast } from '@/utils/'
const user = inject('$user')
@@ -202,8 +188,8 @@ const access = createResource({
name: props.name,
},
onSuccess(data) {
orderSummary.submit()
setBillingDetails(data.address)
orderSummary.submit()
},
})
@@ -224,84 +210,49 @@ const orderSummary = createResource({
const billingDetails = reactive({})
const setBillingDetails = (data) => {
billingDetails.billing_name = data.billing_name || ''
billingDetails.address_line1 = data.address_line1 || ''
billingDetails.address_line2 = data.address_line2 || ''
billingDetails.city = data.city || ''
billingDetails.state = data.state || ''
billingDetails.country = data.country || ''
billingDetails.pincode = data.pincode || ''
billingDetails.phone = data.phone || ''
billingDetails.source = data.source || ''
billingDetails.gstin = data.gstin || ''
billingDetails.pan = data.pan || ''
billingDetails.billing_name = data?.billing_name || ''
billingDetails.address_line1 = data?.address_line1 || ''
billingDetails.address_line2 = data?.address_line2 || ''
billingDetails.city = data?.city || ''
billingDetails.state = data?.state || ''
billingDetails.country = data?.country || ''
billingDetails.pincode = data?.pincode || ''
billingDetails.phone = data?.phone || ''
billingDetails.source = data?.source || ''
billingDetails.gstin = data?.gstin || ''
billingDetails.pan = data?.pan || ''
}
const paymentOptions = createResource({
url: 'lms.lms.utils.get_payment_options',
const paymentLink = createResource({
url: 'lms.lms.payments.get_payment_link',
makeParams(values) {
return {
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
docname: props.name,
phone: billingDetails.phone,
country: billingDetails.country,
title: orderSummary.data.title,
amount: orderSummary.data.original_amount,
total_amount: orderSummary.data.amount,
currency: orderSummary.data.currency,
address: billingDetails,
}
},
})
const generatePaymentLink = () => {
paymentOptions.submit(
paymentLink.submit(
{},
{
validate(params) {
validate() {
if (!billingDetails.source) {
return __('Please let us know where you heard about us from.')
}
return validateAddress()
},
onSuccess(data) {
data.handler = (response) => {
let doctype = props.type == 'course' ? 'LMS Course' : 'LMS Batch'
let docname = props.name
handleSuccess(response, doctype, docname, data.order_id)
}
let rzp1 = new Razorpay(data)
rzp1.open()
window.location.href = data
},
onError(err) {
showError(err)
},
}
)
}
const paymentResource = createResource({
url: 'lms.lms.utils.verify_payment',
makeParams(values) {
return {
response: values.response,
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
docname: props.name,
address: billingDetails,
order_id: values.orderId,
}
},
})
const handleSuccess = (response, doctype, docname, orderId) => {
paymentResource.submit(
{
response: response,
orderId: orderId,
},
{
onSuccess(data) {
createToast({
title: 'Success',
text: 'Payment Successful',
icon: 'check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
setTimeout(() => {
window.location.href = data
}, 3000)
showToast(__('Error'), err.messages?.[0] || err, 'x')
},
}
)

View File

@@ -244,7 +244,10 @@ const lesson = createResource({
onSuccess(data) {
lessonProgress.value = data.membership?.progress
if (data.content) editor.value = renderEditor('editor', data.content)
if (JSON.parse(data.instructor_content)?.blocks?.length > 1)
if (
data.instructor_content &&
JSON.parse(data.instructor_content)?.blocks?.length > 1
)
instructorEditor.value = renderEditor(
'instructor-content',
data.instructor_content

View File

@@ -708,3 +708,49 @@ def delete_documents(doctype, documents):
frappe.only_for("Moderator")
for doc in documents:
frappe.delete_doc(doctype, doc)
@frappe.whitelist()
def get_payment_gateway_details(payment_gateway):
fields = []
gateway = frappe.get_doc("Payment Gateway", payment_gateway)
if gateway.gateway_controller is None:
try:
data = frappe.get_doc(f"{payment_gateway} Settings").as_dict()
meta = frappe.get_meta(f"{payment_gateway} Settings").fields
doctype = f"{payment_gateway} Settings"
docname = f"{payment_gateway} Settings"
except Exception:
frappe.throw(_("{0} Settings not found").format(payment_gateway))
else:
try:
data = frappe.get_doc(gateway.gateway_settings, gateway.gateway_controller).as_dict()
meta = frappe.get_meta(gateway.gateway_settings).fields
doctype = gateway.gateway_settings
docname = gateway.gateway_controller
except Exception:
frappe.throw(_("{0} Settings not found").format(payment_gateway))
for row in meta:
if row.fieldtype not in ["Column Break", "Section Break"]:
if row.fieldtype in ["Attach", "Attach Image"]:
fieldtype = "Upload"
data[row.fieldname] = get_file_info(data.get(row.fieldname))
else:
fieldtype = row.fieldtype
fields.append(
{
"label": row.label,
"name": row.fieldname,
"type": fieldtype,
}
)
return {
"fields": fields,
"data": data,
"doctype": doctype,
"docname": docname,
}

View File

@@ -1,148 +1,4 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on("Course Lesson", {
setup: function (frm) {
frm.trigger("setup_help");
},
setup_help(frm) {
let quiz_link = `<a href="/app/lms-quiz"> ${__("Quiz List")} </a>`;
let exercise_link = `<a href="/app/lms-exercise"> ${__(
"Exercise List"
)} </a>`;
let file_link = `<a href="/app/file"> ${__("File DocType")} </a>`;
frm.get_field("help").html(`
<p>${__(
"You can add some more additional content to the lesson using a special syntax. The table below mentions all types of dynamic content that you can add to the lessons and the syntax for the same."
)}</p>
<table class="table">
<tr style="background-color: var(--fg-hover-color); font-weight: bold">
<th style="width: 20%;">
${__("Content Type")}
</th>
<th style="width: 40%;">
${__("Syntax")}
</th>
<th>
${__("Description")}
</th>
</tr>
<tr>
<td>
${__("YouTube Video")}
</td>
<td>
{{ YouTubeVideo("unique_embed_id") }}
</td>
<td>
<span>
${__(
"Copy and paste the syntax in the editor. Replace 'embed_src' with the embed source that YouTube provides. To get the source, follow the steps mentioned below."
)}
</span>
<ul class="p-4">
<li>
${__("Upload the video on youtube.")}
</li>
<li>
${__(
"When you share a youtube video, it shows an option called Embed."
)}
</li>
<li>
${__(
"On clicking it, it provides an iframe. Copy the source (src) of the iframe and paste it here."
)}
</li>
</ul>
</td>
</tr>
<tr>
<td>
${__("Quiz")}
</td>
<td>
{{ Quiz("lms_quiz_id") }}
</td>
<td>
${__(
"Copy and paste the syntax in the editor. Replace 'lms_quiz_id' with the ID of the Quiz you want to add. You can get the ID of the quiz from the {0}.",
[quiz_link]
)}
</td>
</tr>
<tr>
<td>
${__("Video")}
</td>
<td>
{{ Video("url_of_source") }}
</td>
<td>
${__(
"Upload a video from your local machine to the {0}. Copy and paste this syntax in the editor. Replace 'url_of_source' with the File URL field of the document you created in the File DocType.",
[file_link]
)}
</td>
</tr>
<tr>
<td>
${"Exercise"}
</td>
<td>
{{ Exercise("exercise_id") }}
</td>
<td>
${__(
"Copy and paste the syntax in the editor. Replace 'exercise_id' with the ID of the Exercise you want to add. You can get the ID of the exercise from the {0}.",
[exercise_link]
)}
</td>
</tr>
<tr>
<td>
${__("Assignment")}
</td>
<td>
{{ Assignment("id-filetype") }}
</td>
</tr>
</table>
<hr>
<table class="table">
<tr style="background-color: var(--fg-hover-color); font-weight: bold">
<th style="width: 90%">
${__("Supported File Types for Assignment")}
</th>
<th>
${__("Syntax")}
</th>
</tr>
<tr>
<td>
.doc, .docx, .xml
<td>
${__("Document")}
</td>
</tr>
<tr>
<td>
.pdf
</td>
<td>
${__("PDF")}
</td>
</tr>
<tr>
<td>
.png, .jpg, .jpeg
</td>
<td>
${__("Image")}
</td>
</tr>
</table>
`);
},
});
frappe.ui.form.on("Course Lesson", {});

View File

@@ -15,6 +15,7 @@ from lms.lms.utils import (
get_lesson_url,
get_quiz_details,
get_assignment_details,
update_payment_record,
)
from frappe.email.doctype.email_template.email_template import get_email_template
@@ -26,6 +27,7 @@ class LMSBatch(Document):
self.validate_batch_end_date()
self.validate_duplicate_courses()
self.validate_duplicate_students()
self.validate_payments_app()
self.validate_duplicate_assessments()
self.validate_membership()
self.validate_timetable()
@@ -55,6 +57,12 @@ class LMSBatch(Document):
_("Course {0} has already been added to this batch.").format(frappe.bold(title))
)
def validate_payments_app(self):
if self.paid_batch:
installed_apps = frappe.get_installed_apps()
if "payments" not in installed_apps:
frappe.throw(_("Please install the Payments app to create a paid batches."))
def validate_duplicate_assessments(self):
assessments = [row.assessment_name for row in self.assessment]
for assessment in self.assessment:
@@ -164,23 +172,9 @@ class LMSBatch(Document):
_("Row #{0} Date cannot be outside the batch duration.").format(schedule.idx)
)
@frappe.whitelist()
def remove_student(student, batch_name):
frappe.only_for("Moderator")
frappe.db.delete("Batch Student", {"student": student, "parent": batch_name})
@frappe.whitelist()
def remove_course(course, parent):
frappe.only_for("Moderator")
frappe.db.delete("Batch Course", {"course": course, "parent": parent})
@frappe.whitelist()
def remove_assessment(assessment, parent):
frappe.only_for("Moderator")
frappe.db.delete("LMS Assessment", {"assessment_name": assessment, "parent": parent})
def on_payment_authorized(self, payment_status):
if payment_status in ["Authorized", "Completed"]:
update_payment_record("LMS Batch", self.name)
@frappe.whitelist()

View File

@@ -8,7 +8,7 @@ from frappe.model.document import Document
from frappe.utils import cint, today
from frappe.utils.telemetry import capture
from lms.lms.utils import get_chapters, can_create_courses
from ...utils import generate_slug, validate_image
from ...utils import generate_slug, validate_image, update_payment_record
from frappe import _
@@ -18,6 +18,7 @@ class LMSCourse(Document):
self.validate_instructors()
self.validate_video_link()
self.validate_status()
self.validate_payments_app()
self.image = validate_image(self.image)
def validate_published(self):
@@ -44,10 +45,20 @@ class LMSCourse(Document):
if self.published:
self.status = "Approved"
def validate_payments_app(self):
if self.paid_course:
installed_apps = frappe.get_installed_apps()
if "payments" not in installed_apps:
frappe.throw(_("Please install the Payments app to create a paid courses."))
def on_update(self):
if not self.upcoming and self.has_value_changed("upcoming"):
self.send_email_to_interested_users()
def on_payment_authorized(self, payment_status):
if payment_status in ["Authorized", "Completed"]:
update_payment_record("LMS Course", self.name)
def send_email_to_interested_users(self):
interested_users = frappe.get_all(
"LMS Course Interest", {"course": self.name}, ["name", "user"]

View File

@@ -2,6 +2,28 @@
// For license information, please see license.txt
frappe.ui.form.on("LMS Settings", {
// refresh: function(frm) {
// }
setup: function (frm) {
frappe.call({
method: "lms.lms.doctype.lms_settings.lms_settings.check_payments_app",
callback: (data) => {
if (!data.message) {
frm.set_df_property("payment_section", "hidden", 1);
frm.trigger("set_no_payments_app_html");
} else {
frm.set_df_property("no_payments_app", "hidden", 1);
}
},
});
},
set_no_payments_app_html(frm) {
frm.get_field("payments_app_is_not_installed").html(`
<div class="alert alert-warning">
Please install the
<a target="_blank" style="color: var(--alert-text-warning); background: var(--alert-bg-warning);" href="https://frappecloud.com/marketplace/apps/payments">
Payments app
</a>
to enable payment gateway.
`);
},
});

View File

@@ -42,14 +42,15 @@
"mentor_request_status_update",
"payment_settings_tab",
"payment_section",
"razorpay_key",
"razorpay_secret",
"apply_gst",
"column_break_cfcv",
"payment_gateway",
"default_currency",
"exception_country",
"column_break_cfcv",
"apply_gst",
"show_usd_equivalent",
"apply_rounding",
"exception_country",
"no_payments_app",
"payments_app_is_not_installed",
"email_templates_tab",
"certification_template",
"batch_confirmation_template",
@@ -147,16 +148,6 @@
"fieldname": "column_break_cfcv",
"fieldtype": "Column Break"
},
{
"fieldname": "razorpay_key",
"fieldtype": "Data",
"label": "Razorpay Key"
},
{
"fieldname": "razorpay_secret",
"fieldtype": "Password",
"label": "Razorpay Secret"
},
{
"default": "0",
"fieldname": "apply_gst",
@@ -173,7 +164,7 @@
"depends_on": "show_usd_equivalent",
"fieldname": "exception_country",
"fieldtype": "Table MultiSelect",
"label": "Maintain Original Currency",
"label": "Primary Countries",
"options": "Payment Country"
},
{
@@ -331,12 +322,26 @@
"fieldname": "custom_signup_content",
"fieldtype": "HTML Editor",
"label": "Custom Signup Content"
},
{
"fieldname": "payment_gateway",
"fieldtype": "Data",
"label": "Payment Gateway"
},
{
"fieldname": "no_payments_app",
"fieldtype": "Section Break"
},
{
"fieldname": "payments_app_is_not_installed",
"fieldtype": "HTML",
"label": "Payments app is not installed"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2024-09-23 17:57:01.350020",
"modified": "2024-10-01 12:15:49.800242",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Settings",

View File

@@ -39,3 +39,32 @@ class LMSSettings(Document):
frappe.bold("Course Evaluator"),
)
)
@frappe.whitelist()
def check_payments_app():
installed_apps = frappe.get_installed_apps()
if "payments" not in installed_apps:
return False
else:
filters = {
"doctype_or_field": "DocField",
"doc_type": "LMS Settings",
"field_name": "payment_gateway",
}
if frappe.db.exists("Property Setter", filters):
return True
link_property = frappe.new_doc("Property Setter")
link_property.update(filters)
link_property.property = "fieldtype"
link_property.value = "Link"
link_property.save()
options_property = frappe.new_doc("Property Setter")
options_property.update(filters)
options_property.property = "options"
options_property.value = "Payment Gateway"
options_property.save()
return True

92
lms/lms/payments.py Normal file
View File

@@ -0,0 +1,92 @@
import frappe
from payments.utils import get_payment_gateway_controller
def get_payment_gateway():
return frappe.db.get_single_value("LMS Settings", "payment_gateway")
def get_controller(payment_gateway):
return get_payment_gateway_controller(payment_gateway)
def validate_currency(payment_gateway, currency):
controller = get_controller(payment_gateway)
controller().validate_transaction_currency(currency)
@frappe.whitelist()
def get_payment_link(doctype, docname, title, amount, total_amount, currency, address):
payment_gateway = get_payment_gateway()
address = frappe._dict(address)
amount_with_gst = total_amount if total_amount != amount else 0
payment = record_payment(address, doctype, docname, amount, currency, amount_with_gst)
controller = get_controller(payment_gateway)
if doctype == "LMS Course":
redirect_to = f"/lms/courses/{docname}/learn/1-1"
elif doctype == "LMS Batch":
redirect_to = f"/lms/batches/{docname}"
payment_details = {
"amount": total_amount,
"title": f"Payment for {doctype} {title} {docname}",
"description": f"{address.billing_name}'s payment for {title}",
"reference_doctype": doctype,
"reference_docname": docname,
"payer_email": frappe.session.user,
"payer_name": address.billing_name,
"currency": currency,
"payment_gateway": payment_gateway,
"redirect_to": redirect_to,
"payment": payment.name,
}
url = controller.get_payment_url(**payment_details)
return url
def record_payment(address, doctype, docname, amount, currency, amount_with_gst=0):
address = frappe._dict(address)
address_name = save_address(address)
payment_doc = frappe.new_doc("LMS Payment")
payment_doc.update(
{
"member": frappe.session.user,
"billing_name": address.billing_name,
"address": address_name,
"amount": amount,
"currency": currency,
"amount_with_gst": amount_with_gst,
"gstin": address.gstin,
"pan": address.pan,
"source": address.source,
"payment_for_document_type": doctype,
"payment_for_document": docname,
}
)
payment_doc.save(ignore_permissions=True)
return payment_doc
def save_address(address):
filters = {"email_id": frappe.session.user}
exists = frappe.db.exists("Address", filters)
if exists:
address_doc = frappe.get_last_doc("Address", filters=filters)
else:
address_doc = frappe.new_doc("Address")
address_doc.update(address)
address_doc.update(
{
"address_title": frappe.db.get_value("User", frappe.session.user, "full_name"),
"address_type": "Billing",
"is_primary_address": 1,
"email_id": frappe.session.user,
}
)
address_doc.save(ignore_permissions=True)
return address_doc.name

View File

@@ -908,39 +908,6 @@ def get_upcoming_evals(student, courses):
return upcoming_evals
@frappe.whitelist()
def get_payment_options(doctype, docname, phone, country):
if not frappe.db.exists(doctype, docname):
frappe.throw(_("Invalid document provided."))
validate_phone_number(phone, True)
details = get_details(doctype, docname)
details.amount, details.currency = check_multicurrency(
details.amount, details.currency, country, details.amount_usd
)
if details.currency == "INR":
details.amount, details.gst_applied = apply_gst(details.amount, country)
client = get_client()
order = create_order(client, details.amount, details.currency)
options = {
"key_id": frappe.db.get_single_value("LMS Settings", "razorpay_key"),
"name": frappe.db.get_single_value("Website Settings", "app_name"),
"description": _("Payment for {0} course").format(details["title"]),
"order_id": order["id"],
"amount": cint(order["amount"]) * 100,
"currency": order["currency"],
"prefill": {
"name": frappe.db.get_value("User", frappe.session.user, "full_name"),
"email": frappe.session.user,
"contact": phone,
},
}
return options
def check_multicurrency(amount, currency, country=None, amount_usd=None):
settings = frappe.get_single("LMS Settings")
show_usd_equivalent = settings.show_usd_equivalent
@@ -998,145 +965,6 @@ def apply_gst(amount, country=None):
return amount, gst_applied
def get_details(doctype, docname):
if doctype == "LMS Course":
details = frappe.db.get_value(
"LMS Course",
docname,
["name", "title", "paid_course", "currency", "course_price as amount", "amount_usd"],
as_dict=True,
)
if not details.paid_course:
frappe.throw(_("This course is free."))
else:
details = frappe.db.get_value(
"LMS Batch",
docname,
["name", "title", "paid_batch", "currency", "amount", "amount_usd"],
as_dict=True,
)
if not details.paid_batch:
frappe.throw(_("To join this batch, please contact the Administrator."))
return details
def save_address(address):
filters = {"email_id": frappe.session.user}
exists = frappe.db.exists("Address", filters)
if exists:
address_doc = frappe.get_last_doc("Address", filters=filters)
else:
address_doc = frappe.new_doc("Address")
address_doc.update(address)
address_doc.update(
{
"address_title": frappe.db.get_value("User", frappe.session.user, "full_name"),
"address_type": "Billing",
"is_primary_address": 1,
"email_id": frappe.session.user,
}
)
address_doc.save(ignore_permissions=True)
return address_doc.name
def get_client():
settings = frappe.get_single("LMS Settings")
razorpay_key = settings.razorpay_key
razorpay_secret = settings.get_password("razorpay_secret", raise_exception=True)
if not razorpay_key and not razorpay_secret:
frappe.throw(
_(
"There is a problem with the payment gateway. Please contact the Administrator to proceed."
)
)
return razorpay.Client(auth=(razorpay_key, razorpay_secret))
def create_order(client, amount, currency):
try:
return client.order.create(
{
"amount": cint(amount) * 100,
"currency": currency,
}
)
except Exception as e:
frappe.throw(
_(
"Error during payment: {0} Please contact the Administrator. Amount {1} Currency {2} Formatted {3}"
).format(e, amount, currency, cint(amount))
)
@frappe.whitelist()
def verify_payment(response, doctype, docname, address, order_id):
client = get_client()
client.utility.verify_payment_signature(
{
"razorpay_order_id": order_id,
"razorpay_payment_id": response["razorpay_payment_id"],
"razorpay_signature": response["razorpay_signature"],
}
)
payment = record_payment(address, response, client, doctype, docname)
if doctype == "LMS Course":
return create_membership(docname, payment)
else:
return add_student_to_batch(docname, payment)
def record_payment(address, response, client, doctype, docname):
address = frappe._dict(address)
address_name = save_address(address)
payment_details = get_payment_details(doctype, docname, address)
payment_doc = frappe.new_doc("LMS Payment")
payment_doc.update(
{
"member": frappe.session.user,
"billing_name": address.billing_name,
"address": address_name,
"payment_received": 1,
"order_id": response["razorpay_order_id"],
"payment_id": response["razorpay_payment_id"],
"amount": payment_details["amount"],
"currency": payment_details["currency"],
"amount_with_gst": payment_details["amount_with_gst"],
"gstin": address.gstin,
"pan": address.pan,
"source": address.source,
"payment_for_document_type": doctype,
"payment_for_document": docname,
}
)
payment_doc.save(ignore_permissions=True)
return payment_doc
def get_payment_details(doctype, docname, address):
amount_field = "course_price" if doctype == "LMS Course" else "amount"
amount = frappe.db.get_value(doctype, docname, amount_field)
currency = frappe.db.get_value(doctype, docname, "currency")
amount_usd = frappe.db.get_value(doctype, docname, "amount_usd")
amount_with_gst = 0
amount, currency = check_multicurrency(amount, currency, None, amount_usd)
if currency == "INR" and address.country == "India":
amount_with_gst, gst_applied = apply_gst(amount, address.country)
return {
"amount": amount,
"currency": currency,
"amount_with_gst": amount_with_gst,
}
def create_membership(course, payment):
membership = frappe.new_doc("LMS Enrollment")
membership.update(
@@ -1146,24 +974,6 @@ def create_membership(course, payment):
return f"/lms/courses/{course}/learn/1-1"
def add_student_to_batch(batchname, payment):
student = frappe.new_doc("Batch Student")
current_count = frappe.db.count("Batch Student", {"parent": batchname})
student.update(
{
"student": frappe.session.user,
"payment": payment.name,
"source": payment.source,
"parent": batchname,
"parenttype": "LMS Batch",
"parentfield": "students",
"idx": current_count + 1,
}
)
student.save(ignore_permissions=True)
return f"/batches/{batchname}"
def get_current_exchange_rate(source, target="USD"):
url = f"https://api.frankfurter.app/latest?from={source}&to={target}"
@@ -1765,10 +1575,11 @@ def get_order_summary(doctype, docname, country=None):
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)
details.amount, details.gst_applied = apply_gst(details.amount, country)
details.gst_amount_formatted = fmt_money(details.gst_applied, 0, details.currency)
details.total_amount_formatted = fmt_money(details.amount, 0, details.currency)
@@ -1826,3 +1637,89 @@ def publish_notifications(doc, method):
frappe.publish_realtime(
"publish_lms_notifications", user=doc.for_user, after_commit=True
)
def update_payment_record(doctype, docname):
request = frappe.get_all(
"Integration Request",
{
"reference_doctype": doctype,
"reference_docname": docname,
"owner": frappe.session.user,
},
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"
frappe.db.set_value(
"LMS Payment",
data.payment,
{
"payment_received": 1,
"payment_id": data.get(payment_id),
"order_id": data.get("order_id"),
},
)
try:
if doctype == "LMS Course":
enroll_in_course(data.payment, docname)
else:
enroll_in_batch(data.payment, docname)
except Exception as e:
frappe.log_error(frappe.get_traceback(), _("Enrollment Failed"))
def enroll_in_course(payment_name, course):
if not frappe.db.exists(
"LMS Enrollment", {"member": frappe.session.user, "course": course}
):
enrollment = frappe.new_doc("LMS Enrollment")
payment = frappe.db.get_value(
"LMS Payment", payment_name, ["name", "source"], as_dict=True
)
enrollment.update(
{
"member": frappe.session.user,
"course": course,
"payment": payment.name,
}
)
enrollment.save(ignore_permissions=True)
def enroll_in_batch(payment_name, batch):
if not frappe.db.exists(
"Batch Student", {"parent": batch, "student": frappe.session.user}
):
student = frappe.new_doc("Batch Student")
current_count = frappe.db.count("Batch Student", {"parent": batch})
payment = frappe.db.get_value(
"LMS Payment", payment_name, ["name", "source"], as_dict=True
)
student.update(
{
"student": frappe.session.user,
"payment": payment.name,
"source": payment.source,
"parent": batch,
"parenttype": "LMS Batch",
"parentfield": "students",
"idx": current_count + 1,
}
)
student.save(ignore_permissions=True)