Merge pull request #23439 from Mangesh-Khairnar/mpesa-integration
feat: M-pesa integration
This commit is contained in:
commit
c4be397954
@ -1,4 +1,5 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:mode_of_payment",
|
||||
@ -28,7 +29,7 @@
|
||||
"fieldtype": "Select",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Type",
|
||||
"options": "Cash\nBank\nGeneral"
|
||||
"options": "Cash\nBank\nGeneral\nPhone"
|
||||
},
|
||||
{
|
||||
"fieldname": "accounts",
|
||||
@ -45,7 +46,9 @@
|
||||
],
|
||||
"icon": "fa fa-credit-card",
|
||||
"idx": 1,
|
||||
"modified": "2020-09-18 17:26:09.703215",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2020-09-18 17:57:23.835236",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Mode of Payment",
|
||||
|
@ -1,313 +1,98 @@
|
||||
{
|
||||
"allow_copy": 0,
|
||||
"allow_guest_to_view": 0,
|
||||
"allow_import": 0,
|
||||
"allow_rename": 0,
|
||||
"beta": 0,
|
||||
"actions": [],
|
||||
"creation": "2015-12-23 21:31:52.699821",
|
||||
"custom": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "DocType",
|
||||
"document_type": "",
|
||||
"editable_grid": 1,
|
||||
"field_order": [
|
||||
"payment_gateway",
|
||||
"payment_channel",
|
||||
"is_default",
|
||||
"column_break_4",
|
||||
"payment_account",
|
||||
"currency",
|
||||
"payment_request_message",
|
||||
"message",
|
||||
"message_examples"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "payment_gateway",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Payment Gateway",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "Payment Gateway",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 1,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"default": "0",
|
||||
"fieldname": "is_default",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Is Default",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0
|
||||
"label": "Is Default"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "column_break_4",
|
||||
"fieldtype": "Column Break",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "payment_account",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Payment Account",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "Account",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 1,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fetch_from": "payment_account.account_currency",
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Read Only",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Currency",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0
|
||||
"label": "Currency"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"depends_on": "eval: doc.payment_channel !== \"Phone\"",
|
||||
"fieldname": "payment_request_message",
|
||||
"fieldtype": "Section Break",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"default": "Please click on the link below to make your payment",
|
||||
"fieldname": "message",
|
||||
"fieldtype": "Small Text",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Default Payment Request Message",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0
|
||||
"label": "Default Payment Request Message"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "message_examples",
|
||||
"fieldtype": "HTML",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Message Examples",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "<pre><h5>Message Example</h5>\n\n<p> Thank You for being a part of {{ doc.company }}! We hope you are enjoying the service.</p>\n\n<p> Please find enclosed the E Bill statement. The outstanding amount is {{ doc.grand_total }}.</p>\n\n<p> We don't want you to be spending time running around in order to pay for your Bill.<br>After all, life is beautiful and the time you have in hand should be spent to enjoy it!<br>So here are our little ways to help you get more time for life! </p>\n\n<a href=\"{{ payment_url }}\"> click here to pay </a>\n\n</pre>\n",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0
|
||||
"options": "<pre><h5>Message Example</h5>\n\n<p> Thank You for being a part of {{ doc.company }}! We hope you are enjoying the service.</p>\n\n<p> Please find enclosed the E Bill statement. The outstanding amount is {{ doc.grand_total }}.</p>\n\n<p> We don't want you to be spending time running around in order to pay for your Bill.<br>After all, life is beautiful and the time you have in hand should be spent to enjoy it!<br>So here are our little ways to help you get more time for life! </p>\n\n<a href=\"{{ payment_url }}\"> click here to pay </a>\n\n</pre>\n"
|
||||
},
|
||||
{
|
||||
"default": "Email",
|
||||
"fieldname": "payment_channel",
|
||||
"fieldtype": "Select",
|
||||
"label": "Payment Channel",
|
||||
"options": "\nEmail\nPhone"
|
||||
}
|
||||
],
|
||||
"has_web_view": 0,
|
||||
"hide_heading": 0,
|
||||
"hide_toolbar": 0,
|
||||
"idx": 0,
|
||||
"image_view": 0,
|
||||
"in_create": 0,
|
||||
"is_submittable": 0,
|
||||
"issingle": 0,
|
||||
"istable": 0,
|
||||
"max_attachments": 0,
|
||||
"modified": "2018-05-16 22:43:34.970491",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2020-09-20 13:30:27.722852",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Gateway Account",
|
||||
"name_case": "",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"amend": 0,
|
||||
"cancel": 0,
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"if_owner": 0,
|
||||
"import": 0,
|
||||
"permlevel": 0,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts Manager",
|
||||
"set_user_permissions": 0,
|
||||
"share": 1,
|
||||
"submit": 0,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 0,
|
||||
"read_only": 0,
|
||||
"read_only_onload": 0,
|
||||
"show_name_in_global_search": 0,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 0,
|
||||
"track_seen": 0
|
||||
"sort_order": "DESC"
|
||||
}
|
@ -25,7 +25,7 @@ frappe.ui.form.on("Payment Request", "onload", function(frm, dt, dn){
|
||||
})
|
||||
|
||||
frappe.ui.form.on("Payment Request", "refresh", function(frm) {
|
||||
if(frm.doc.payment_request_type == 'Inward' &&
|
||||
if(frm.doc.payment_request_type == 'Inward' && frm.doc.payment_channel !== "Phone" &&
|
||||
!in_list(["Initiated", "Paid"], frm.doc.status) && !frm.doc.__islocal && frm.doc.docstatus==1){
|
||||
frm.add_custom_button(__('Resend Payment Email'), function(){
|
||||
frappe.call({
|
||||
|
@ -48,6 +48,7 @@
|
||||
"section_break_7",
|
||||
"payment_gateway",
|
||||
"payment_account",
|
||||
"payment_channel",
|
||||
"payment_order",
|
||||
"amended_from"
|
||||
],
|
||||
@ -230,6 +231,7 @@
|
||||
"label": "Recipient Message And Payment Details"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.payment_channel != \"Phone\"",
|
||||
"fieldname": "print_format",
|
||||
"fieldtype": "Select",
|
||||
"label": "Print Format"
|
||||
@ -241,6 +243,7 @@
|
||||
"label": "To"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.payment_channel != \"Phone\"",
|
||||
"fieldname": "subject",
|
||||
"fieldtype": "Data",
|
||||
"in_global_search": 1,
|
||||
@ -277,16 +280,18 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.payment_request_type == 'Inward'",
|
||||
"depends_on": "eval: doc.payment_request_type == 'Inward' || doc.payment_channel != \"Phone\"",
|
||||
"fieldname": "section_break_10",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.payment_channel != \"Phone\"",
|
||||
"fieldname": "message",
|
||||
"fieldtype": "Text",
|
||||
"label": "Message"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.payment_channel != \"Phone\"",
|
||||
"fieldname": "message_examples",
|
||||
"fieldtype": "HTML",
|
||||
"label": "Message Examples",
|
||||
@ -347,12 +352,21 @@
|
||||
"options": "Payment Request",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "payment_gateway_account.payment_channel",
|
||||
"fieldname": "payment_channel",
|
||||
"fieldtype": "Select",
|
||||
"label": "Payment Channel",
|
||||
"options": "\nEmail\nPhone",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2020-07-17 14:06:42.185763",
|
||||
"modified": "2020-09-18 12:24:14.178853",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Request",
|
||||
|
@ -36,7 +36,7 @@ class PaymentRequest(Document):
|
||||
ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
|
||||
if (hasattr(ref_doc, "order_type") \
|
||||
and getattr(ref_doc, "order_type") != "Shopping Cart"):
|
||||
ref_amount = get_amount(ref_doc)
|
||||
ref_amount = get_amount(ref_doc, self.payment_account)
|
||||
|
||||
if existing_payment_request_amount + flt(self.grand_total)> ref_amount:
|
||||
frappe.throw(_("Total Payment Request amount cannot be greater than {0} amount")
|
||||
@ -76,11 +76,25 @@ class PaymentRequest(Document):
|
||||
or self.flags.mute_email:
|
||||
send_mail = False
|
||||
|
||||
if send_mail:
|
||||
if send_mail and self.payment_channel != "Phone":
|
||||
self.set_payment_request_url()
|
||||
self.send_email()
|
||||
self.make_communication_entry()
|
||||
|
||||
elif self.payment_channel == "Phone":
|
||||
controller = get_payment_gateway_controller(self.payment_gateway)
|
||||
payment_record = dict(
|
||||
reference_doctype="Payment Request",
|
||||
reference_docname=self.name,
|
||||
payment_reference=self.reference_name,
|
||||
grand_total=self.grand_total,
|
||||
sender=self.email_to,
|
||||
currency=self.currency,
|
||||
payment_gateway=self.payment_gateway
|
||||
)
|
||||
controller.validate_transaction_currency(self.currency)
|
||||
controller.request_for_payment(**payment_record)
|
||||
|
||||
def on_cancel(self):
|
||||
self.check_if_payment_entry_exists()
|
||||
self.set_as_cancelled()
|
||||
@ -105,13 +119,14 @@ class PaymentRequest(Document):
|
||||
return False
|
||||
|
||||
def set_payment_request_url(self):
|
||||
if self.payment_account:
|
||||
if self.payment_account and self.payment_channel != "Phone":
|
||||
self.payment_url = self.get_payment_url()
|
||||
|
||||
if self.payment_url:
|
||||
self.db_set('payment_url', self.payment_url)
|
||||
|
||||
if self.payment_url or not self.payment_gateway_account:
|
||||
if self.payment_url or not self.payment_gateway_account \
|
||||
or (self.payment_gateway_account and self.payment_channel == "Phone"):
|
||||
self.db_set('status', 'Initiated')
|
||||
|
||||
def get_payment_url(self):
|
||||
@ -140,6 +155,10 @@ class PaymentRequest(Document):
|
||||
})
|
||||
|
||||
def set_as_paid(self):
|
||||
if self.payment_channel == "Phone":
|
||||
self.db_set("status", "Paid")
|
||||
|
||||
else:
|
||||
payment_entry = self.create_payment_entry()
|
||||
self.make_invoice()
|
||||
|
||||
@ -151,7 +170,7 @@ class PaymentRequest(Document):
|
||||
|
||||
ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
|
||||
|
||||
if self.reference_doctype == "Sales Invoice":
|
||||
if self.reference_doctype in ["Sales Invoice", "POS Invoice"]:
|
||||
party_account = ref_doc.debit_to
|
||||
elif self.reference_doctype == "Purchase Invoice":
|
||||
party_account = ref_doc.credit_to
|
||||
@ -166,8 +185,8 @@ class PaymentRequest(Document):
|
||||
else:
|
||||
party_amount = self.grand_total
|
||||
|
||||
payment_entry = get_payment_entry(self.reference_doctype, self.reference_name,
|
||||
party_amount=party_amount, bank_account=self.payment_account, bank_amount=bank_amount)
|
||||
payment_entry = get_payment_entry(self.reference_doctype, self.reference_name, party_amount=party_amount,
|
||||
bank_account=self.payment_account, bank_amount=bank_amount)
|
||||
|
||||
payment_entry.update({
|
||||
"reference_no": self.name,
|
||||
@ -255,7 +274,7 @@ class PaymentRequest(Document):
|
||||
|
||||
# if shopping cart enabled and in session
|
||||
if (shopping_cart_settings.enabled and hasattr(frappe.local, "session")
|
||||
and frappe.local.session.user != "Guest"):
|
||||
and frappe.local.session.user != "Guest") and self.payment_channel != "Phone":
|
||||
|
||||
success_url = shopping_cart_settings.payment_success_url
|
||||
if success_url:
|
||||
@ -280,7 +299,9 @@ def make_payment_request(**args):
|
||||
args = frappe._dict(args)
|
||||
|
||||
ref_doc = frappe.get_doc(args.dt, args.dn)
|
||||
grand_total = get_amount(ref_doc)
|
||||
gateway_account = get_gateway_details(args) or frappe._dict()
|
||||
|
||||
grand_total = get_amount(ref_doc, gateway_account.get("payment_account"))
|
||||
if args.loyalty_points and args.dt == "Sales Order":
|
||||
from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points
|
||||
loyalty_amount = validate_loyalty_points(ref_doc, int(args.loyalty_points))
|
||||
@ -288,8 +309,6 @@ def make_payment_request(**args):
|
||||
frappe.db.set_value("Sales Order", args.dn, "loyalty_amount", loyalty_amount, update_modified=False)
|
||||
grand_total = grand_total - loyalty_amount
|
||||
|
||||
gateway_account = get_gateway_details(args) or frappe._dict()
|
||||
|
||||
bank_account = (get_party_bank_account(args.get('party_type'), args.get('party'))
|
||||
if args.get('party_type') else '')
|
||||
|
||||
@ -314,9 +333,11 @@ def make_payment_request(**args):
|
||||
"payment_gateway_account": gateway_account.get("name"),
|
||||
"payment_gateway": gateway_account.get("payment_gateway"),
|
||||
"payment_account": gateway_account.get("payment_account"),
|
||||
"payment_channel": gateway_account.get("payment_channel"),
|
||||
"payment_request_type": args.get("payment_request_type"),
|
||||
"currency": ref_doc.currency,
|
||||
"grand_total": grand_total,
|
||||
"mode_of_payment": args.mode_of_payment,
|
||||
"email_to": args.recipient_id or ref_doc.owner,
|
||||
"subject": _("Payment Request for {0}").format(args.dn),
|
||||
"message": gateway_account.get("message") or get_dummy_message(ref_doc),
|
||||
@ -344,7 +365,7 @@ def make_payment_request(**args):
|
||||
|
||||
return pr.as_dict()
|
||||
|
||||
def get_amount(ref_doc):
|
||||
def get_amount(ref_doc, payment_account=None):
|
||||
"""get amount based on doctype"""
|
||||
dt = ref_doc.doctype
|
||||
if dt in ["Sales Order", "Purchase Order"]:
|
||||
@ -356,6 +377,12 @@ def get_amount(ref_doc):
|
||||
else:
|
||||
grand_total = flt(ref_doc.outstanding_amount) / ref_doc.conversion_rate
|
||||
|
||||
elif dt == "POS Invoice":
|
||||
for pay in ref_doc.payments:
|
||||
if pay.type == "Phone" and pay.account == payment_account:
|
||||
grand_total = pay.amount
|
||||
break
|
||||
|
||||
elif dt == "Fees":
|
||||
grand_total = ref_doc.outstanding_amount
|
||||
|
||||
@ -366,6 +393,10 @@ def get_amount(ref_doc):
|
||||
frappe.throw(_("Payment Entry is already created"))
|
||||
|
||||
def get_existing_payment_request_amount(ref_dt, ref_dn):
|
||||
"""
|
||||
Get the existing payment request which are unpaid or partially paid for payment channel other than Phone
|
||||
and get the summation of existing paid payment request for Phone payment channel.
|
||||
"""
|
||||
existing_payment_request_amount = frappe.db.sql("""
|
||||
select sum(grand_total)
|
||||
from `tabPayment Request`
|
||||
@ -373,7 +404,9 @@ def get_existing_payment_request_amount(ref_dt, ref_dn):
|
||||
reference_doctype = %s
|
||||
and reference_name = %s
|
||||
and docstatus = 1
|
||||
and status != 'Paid'
|
||||
and (status != 'Paid'
|
||||
or (payment_channel = 'Phone'
|
||||
and status = 'Paid'))
|
||||
""", (ref_dt, ref_dn))
|
||||
return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0
|
||||
|
||||
|
@ -201,5 +201,22 @@ frappe.ui.form.on('POS Invoice', {
|
||||
}
|
||||
frm.set_value("loyalty_amount", loyalty_amount);
|
||||
}
|
||||
},
|
||||
|
||||
request_for_payment: function (frm) {
|
||||
frm.save().then(() => {
|
||||
frappe.dom.freeze();
|
||||
frappe.call({
|
||||
method: 'create_payment_request',
|
||||
doc: frm.doc,
|
||||
})
|
||||
.fail(() => {
|
||||
frappe.dom.unfreeze();
|
||||
frappe.msgprint('Payment request failed');
|
||||
})
|
||||
.then(() => {
|
||||
frappe.msgprint('Payment request sent successfully');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
@ -279,8 +279,7 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Return (Credit Note)",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"set_only_once": 1
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break1",
|
||||
@ -461,7 +460,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "contact_mobile",
|
||||
"fieldtype": "Small Text",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Mobile No",
|
||||
"read_only": 1
|
||||
@ -1579,10 +1578,9 @@
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-file-text",
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2020-09-07 12:43:09.138720",
|
||||
"modified": "2020-09-28 16:51:24.641755",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice",
|
||||
|
@ -15,6 +15,7 @@ from erpnext.accounts.doctype.loyalty_program.loyalty_program import \
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice, get_bank_cash_account, update_multi_mode_option
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_pos_reserved_serial_nos
|
||||
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
|
||||
|
||||
from six import iteritems
|
||||
|
||||
@ -57,6 +58,7 @@ class POSInvoice(SalesInvoice):
|
||||
against_psi_doc.make_loyalty_point_entry()
|
||||
if self.redeem_loyalty_points and self.loyalty_points:
|
||||
self.apply_loyalty_points()
|
||||
self.check_phone_payments()
|
||||
self.set_status(update=True)
|
||||
|
||||
def on_cancel(self):
|
||||
@ -69,6 +71,18 @@ class POSInvoice(SalesInvoice):
|
||||
against_psi_doc.delete_loyalty_point_entry()
|
||||
against_psi_doc.make_loyalty_point_entry()
|
||||
|
||||
def check_phone_payments(self):
|
||||
for pay in self.payments:
|
||||
if pay.type == "Phone" and pay.amount >= 0:
|
||||
paid_amt = frappe.db.get_value("Payment Request",
|
||||
filters=dict(
|
||||
reference_doctype="POS Invoice", reference_name=self.name,
|
||||
mode_of_payment=pay.mode_of_payment, status="Paid"),
|
||||
fieldname="grand_total")
|
||||
|
||||
if pay.amount != paid_amt:
|
||||
return frappe.throw(_("Payment related to {0} is not completed").format(pay.mode_of_payment))
|
||||
|
||||
def validate_stock_availablility(self):
|
||||
allow_negative_stock = frappe.db.get_value('Stock Settings', None, 'allow_negative_stock')
|
||||
|
||||
@ -312,6 +326,32 @@ class POSInvoice(SalesInvoice):
|
||||
if not pay.account:
|
||||
pay.account = get_bank_cash_account(pay.mode_of_payment, self.company).get("account")
|
||||
|
||||
def create_payment_request(self):
|
||||
for pay in self.payments:
|
||||
if pay.type == "Phone":
|
||||
if pay.amount <= 0:
|
||||
frappe.throw(_("Payment amount cannot be less than or equal to 0"))
|
||||
|
||||
if not self.contact_mobile:
|
||||
frappe.throw(_("Please enter the phone number first"))
|
||||
|
||||
payment_gateway = frappe.db.get_value("Payment Gateway Account", {
|
||||
"payment_account": pay.account,
|
||||
})
|
||||
record = {
|
||||
"payment_gateway": payment_gateway,
|
||||
"dt": "POS Invoice",
|
||||
"dn": self.name,
|
||||
"payment_request_type": "Inward",
|
||||
"party_type": "Customer",
|
||||
"party": self.customer,
|
||||
"mode_of_payment": pay.mode_of_payment,
|
||||
"recipient_id": self.contact_mobile,
|
||||
"submit_doc": True
|
||||
}
|
||||
|
||||
return make_payment_request(**record)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_stock_availability(item_code, warehouse):
|
||||
latest_sle = frappe.db.sql("""select qty_after_transaction
|
||||
|
@ -796,7 +796,7 @@ def get_children(doctype, parent, company, is_root=False):
|
||||
|
||||
return acc
|
||||
|
||||
def create_payment_gateway_account(gateway):
|
||||
def create_payment_gateway_account(gateway, payment_channel="Email"):
|
||||
from erpnext.setup.setup_wizard.operations.company_setup import create_bank_account
|
||||
|
||||
company = frappe.db.get_value("Global Defaults", None, "default_company")
|
||||
@ -831,7 +831,8 @@ def create_payment_gateway_account(gateway):
|
||||
"is_default": 1,
|
||||
"payment_gateway": gateway,
|
||||
"payment_account": bank_account.name,
|
||||
"currency": bank_account.account_currency
|
||||
"currency": bank_account.account_currency,
|
||||
"payment_channel": payment_channel
|
||||
}).insert(ignore_permissions=True)
|
||||
|
||||
except frappe.DuplicateEntryError:
|
||||
|
@ -0,0 +1,28 @@
|
||||
|
||||
{% if not jQuery.isEmptyObject(data) %}
|
||||
<h5 style="margin-top: 20px;"> {{ __("Balance Details") }} </h5>
|
||||
<table class="table table-bordered small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 20%">{{ __("Account Type") }}</th>
|
||||
<th style="width: 20%" class="text-right">{{ __("Current Balance") }}</th>
|
||||
<th style="width: 20%" class="text-right">{{ __("Available Balance") }}</th>
|
||||
<th style="width: 20%" class="text-right">{{ __("Reserved Balance") }}</th>
|
||||
<th style="width: 20%" class="text-right">{{ __("Uncleared Balance") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for(const [key, value] of Object.entries(data)) { %}
|
||||
<tr>
|
||||
<td> {%= key %} </td>
|
||||
<td class="text-right"> {%= value["current_balance"] %} </td>
|
||||
<td class="text-right"> {%= value["available_balance"] %} </td>
|
||||
<td class="text-right"> {%= value["reserved_balance"] %} </td>
|
||||
<td class="text-right"> {%= value["uncleared_balance"] %} </td>
|
||||
</tr>
|
||||
{% } %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p style="margin-top: 30px;"> Account Balance Information Not Available. </p>
|
||||
{% endif %}
|
@ -0,0 +1,118 @@
|
||||
import base64
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
import datetime
|
||||
|
||||
class MpesaConnector():
|
||||
def __init__(self, env="sandbox", app_key=None, app_secret=None, sandbox_url="https://sandbox.safaricom.co.ke",
|
||||
live_url="https://safaricom.co.ke"):
|
||||
"""Setup configuration for Mpesa connector and generate new access token."""
|
||||
self.env = env
|
||||
self.app_key = app_key
|
||||
self.app_secret = app_secret
|
||||
if env == "sandbox":
|
||||
self.base_url = sandbox_url
|
||||
else:
|
||||
self.base_url = live_url
|
||||
self.authenticate()
|
||||
|
||||
def authenticate(self):
|
||||
"""
|
||||
This method is used to fetch the access token required by Mpesa.
|
||||
|
||||
Returns:
|
||||
access_token (str): This token is to be used with the Bearer header for further API calls to Mpesa.
|
||||
"""
|
||||
authenticate_uri = "/oauth/v1/generate?grant_type=client_credentials"
|
||||
authenticate_url = "{0}{1}".format(self.base_url, authenticate_uri)
|
||||
r = requests.get(
|
||||
authenticate_url,
|
||||
auth=HTTPBasicAuth(self.app_key, self.app_secret)
|
||||
)
|
||||
self.authentication_token = r.json()['access_token']
|
||||
return r.json()['access_token']
|
||||
|
||||
def get_balance(self, initiator=None, security_credential=None, party_a=None, identifier_type=None,
|
||||
remarks=None, queue_timeout_url=None,result_url=None):
|
||||
"""
|
||||
This method uses Mpesa's Account Balance API to to enquire the balance on a M-Pesa BuyGoods (Till Number).
|
||||
|
||||
Args:
|
||||
initiator (str): Username used to authenticate the transaction.
|
||||
security_credential (str): Generate from developer portal.
|
||||
command_id (str): AccountBalance.
|
||||
party_a (int): Till number being queried.
|
||||
identifier_type (int): Type of organization receiving the transaction. (MSISDN/Till Number/Organization short code)
|
||||
remarks (str): Comments that are sent along with the transaction(maximum 100 characters).
|
||||
queue_timeout_url (str): The url that handles information of timed out transactions.
|
||||
result_url (str): The url that receives results from M-Pesa api call.
|
||||
|
||||
Returns:
|
||||
OriginatorConverstionID (str): The unique request ID for tracking a transaction.
|
||||
ConversationID (str): The unique request ID returned by mpesa for each request made
|
||||
ResponseDescription (str): Response Description message
|
||||
"""
|
||||
|
||||
payload = {
|
||||
"Initiator": initiator,
|
||||
"SecurityCredential": security_credential,
|
||||
"CommandID": "AccountBalance",
|
||||
"PartyA": party_a,
|
||||
"IdentifierType": identifier_type,
|
||||
"Remarks": remarks,
|
||||
"QueueTimeOutURL": queue_timeout_url,
|
||||
"ResultURL": result_url
|
||||
}
|
||||
headers = {'Authorization': 'Bearer {0}'.format(self.authentication_token), 'Content-Type': "application/json"}
|
||||
saf_url = "{0}{1}".format(self.base_url, "/mpesa/accountbalance/v1/query")
|
||||
r = requests.post(saf_url, headers=headers, json=payload)
|
||||
return r.json()
|
||||
|
||||
def stk_push(self, business_shortcode=None, passcode=None, amount=None, callback_url=None, reference_code=None,
|
||||
phone_number=None, description=None):
|
||||
"""
|
||||
This method uses Mpesa's Express API to initiate online payment on behalf of a customer.
|
||||
|
||||
Args:
|
||||
business_shortcode (int): The short code of the organization.
|
||||
passcode (str): Get from developer portal
|
||||
amount (int): The amount being transacted
|
||||
callback_url (str): A CallBack URL is a valid secure URL that is used to receive notifications from M-Pesa API.
|
||||
reference_code(str): Account Reference: This is an Alpha-Numeric parameter that is defined by your system as an Identifier of the transaction for CustomerPayBillOnline transaction type.
|
||||
phone_number(int): The Mobile Number to receive the STK Pin Prompt.
|
||||
description(str): This is any additional information/comment that can be sent along with the request from your system. MAX 13 characters
|
||||
|
||||
Success Response:
|
||||
CustomerMessage(str): Messages that customers can understand.
|
||||
CheckoutRequestID(str): This is a global unique identifier of the processed checkout transaction request.
|
||||
ResponseDescription(str): Describes Success or failure
|
||||
MerchantRequestID(str): This is a global unique Identifier for any submitted payment request.
|
||||
ResponseCode(int): 0 means success all others are error codes. e.g.404.001.03
|
||||
|
||||
Error Reponse:
|
||||
requestId(str): This is a unique requestID for the payment request
|
||||
errorCode(str): This is a predefined code that indicates the reason for request failure.
|
||||
errorMessage(str): This is a predefined code that indicates the reason for request failure.
|
||||
"""
|
||||
|
||||
time = str(datetime.datetime.now()).split(".")[0].replace("-", "").replace(" ", "").replace(":", "")
|
||||
password = "{0}{1}{2}".format(str(business_shortcode), str(passcode), time)
|
||||
encoded = base64.b64encode(bytes(password, encoding='utf8'))
|
||||
payload = {
|
||||
"BusinessShortCode": business_shortcode,
|
||||
"Password": encoded.decode("utf-8"),
|
||||
"Timestamp": time,
|
||||
"TransactionType": "CustomerPayBillOnline",
|
||||
"Amount": amount,
|
||||
"PartyA": int(phone_number),
|
||||
"PartyB": business_shortcode,
|
||||
"PhoneNumber": int(phone_number),
|
||||
"CallBackURL": callback_url,
|
||||
"AccountReference": reference_code,
|
||||
"TransactionDesc": description
|
||||
}
|
||||
headers = {'Authorization': 'Bearer {0}'.format(self.authentication_token), 'Content-Type': "application/json"}
|
||||
|
||||
saf_url = "{0}{1}".format(self.base_url, "/mpesa/stkpush/v1/processrequest")
|
||||
r = requests.post(saf_url, headers=headers, json=payload)
|
||||
return r.json()
|
@ -0,0 +1,53 @@
|
||||
import frappe
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
def create_custom_pos_fields():
|
||||
"""Create custom fields corresponding to POS Settings and POS Invoice."""
|
||||
pos_field = {
|
||||
"POS Invoice": [
|
||||
{
|
||||
"fieldname": "request_for_payment",
|
||||
"label": "Request for Payment",
|
||||
"fieldtype": "Button",
|
||||
"hidden": 1,
|
||||
"insert_after": "contact_email"
|
||||
},
|
||||
{
|
||||
"fieldname": "mpesa_receipt_number",
|
||||
"label": "Mpesa Receipt Number",
|
||||
"fieldtype": "Data",
|
||||
"read_only": 1,
|
||||
"insert_after": "company"
|
||||
}
|
||||
]
|
||||
}
|
||||
if not frappe.get_meta("POS Invoice").has_field("request_for_payment"):
|
||||
create_custom_fields(pos_field)
|
||||
|
||||
record_dict = [{
|
||||
"doctype": "POS Field",
|
||||
"fieldname": "contact_mobile",
|
||||
"label": "Mobile No",
|
||||
"fieldtype": "Data",
|
||||
"options": "Phone",
|
||||
"parenttype": "POS Settings",
|
||||
"parent": "POS Settings",
|
||||
"parentfield": "invoice_fields"
|
||||
},
|
||||
{
|
||||
"doctype": "POS Field",
|
||||
"fieldname": "request_for_payment",
|
||||
"label": "Request for Payment",
|
||||
"fieldtype": "Button",
|
||||
"parenttype": "POS Settings",
|
||||
"parent": "POS Settings",
|
||||
"parentfield": "invoice_fields"
|
||||
}
|
||||
]
|
||||
create_pos_settings(record_dict)
|
||||
|
||||
def create_pos_settings(record_dict):
|
||||
for record in record_dict:
|
||||
if frappe.db.exists("POS Field", {"fieldname": record.get("fieldname")}):
|
||||
continue
|
||||
frappe.get_doc(record).insert()
|
@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on('Mpesa Settings', {
|
||||
onload_post_render: function(frm) {
|
||||
frm.events.setup_account_balance_html(frm);
|
||||
},
|
||||
|
||||
refresh: function(frm) {
|
||||
frappe.realtime.on("refresh_mpesa_dashboard", function(){
|
||||
frm.reload_doc();
|
||||
});
|
||||
},
|
||||
|
||||
get_account_balance: function(frm) {
|
||||
if (!frm.initiator_name && !frm.security_credentials) {
|
||||
frappe.throw(__("Please set the initiator name and the security credential"));
|
||||
}
|
||||
frappe.call({
|
||||
method: "get_account_balance_info",
|
||||
doc: frm.doc
|
||||
});
|
||||
},
|
||||
|
||||
setup_account_balance_html: function(frm) {
|
||||
if (!frm.doc.account_balance) return;
|
||||
$("div").remove(".form-dashboard-section.custom");
|
||||
frm.dashboard.add_section(
|
||||
frappe.render_template('account_balance', {
|
||||
data: JSON.parse(frm.doc.account_balance)
|
||||
})
|
||||
);
|
||||
frm.dashboard.show();
|
||||
}
|
||||
|
||||
});
|
@ -0,0 +1,135 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "field:payment_gateway_name",
|
||||
"creation": "2020-09-10 13:21:27.398088",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"payment_gateway_name",
|
||||
"consumer_key",
|
||||
"consumer_secret",
|
||||
"initiator_name",
|
||||
"till_number",
|
||||
"sandbox",
|
||||
"column_break_4",
|
||||
"online_passkey",
|
||||
"security_credential",
|
||||
"get_account_balance",
|
||||
"account_balance"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "payment_gateway_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Payment Gateway Name",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "consumer_key",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Consumer Key",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "consumer_secret",
|
||||
"fieldtype": "Password",
|
||||
"in_list_view": 1,
|
||||
"label": "Consumer Secret",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "till_number",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Till Number",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "sandbox",
|
||||
"fieldtype": "Check",
|
||||
"label": "Sandbox"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_4",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "online_passkey",
|
||||
"fieldtype": "Password",
|
||||
"label": " Online PassKey",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "initiator_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Initiator Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "security_credential",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Security Credential"
|
||||
},
|
||||
{
|
||||
"fieldname": "account_balance",
|
||||
"fieldtype": "Long Text",
|
||||
"hidden": 1,
|
||||
"label": "Account Balance",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "get_account_balance",
|
||||
"fieldtype": "Button",
|
||||
"label": "Get Account Balance"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2020-09-25 20:21:38.215494",
|
||||
"modified_by": "Administrator",
|
||||
"module": "ERPNext Integrations",
|
||||
"name": "Mpesa Settings",
|
||||
"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": "Accounts Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC"
|
||||
}
|
@ -0,0 +1,208 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright (c) 2020, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from __future__ import unicode_literals
|
||||
from json import loads, dumps
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe import _
|
||||
from frappe.utils import call_hook_method, fmt_money
|
||||
from frappe.integrations.utils import create_request_log, create_payment_gateway
|
||||
from frappe.utils import get_request_site_address
|
||||
from erpnext.erpnext_integrations.utils import create_mode_of_payment
|
||||
from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_connector import MpesaConnector
|
||||
from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_custom_fields import create_custom_pos_fields
|
||||
|
||||
class MpesaSettings(Document):
|
||||
supported_currencies = ["KES"]
|
||||
|
||||
def validate_transaction_currency(self, currency):
|
||||
if currency not in self.supported_currencies:
|
||||
frappe.throw(_("Please select another payment method. Mpesa does not support transactions in currency '{0}'").format(currency))
|
||||
|
||||
def on_update(self):
|
||||
create_custom_pos_fields()
|
||||
create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name)
|
||||
call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name, payment_channel="Phone")
|
||||
|
||||
# required to fetch the bank account details from the payment gateway account
|
||||
frappe.db.commit()
|
||||
create_mode_of_payment('Mpesa-' + self.payment_gateway_name, payment_type="Phone")
|
||||
|
||||
def request_for_payment(self, **kwargs):
|
||||
if frappe.flags.in_test:
|
||||
from erpnext.erpnext_integrations.doctype.mpesa_settings.test_mpesa_settings import get_payment_request_response_payload
|
||||
response = frappe._dict(get_payment_request_response_payload())
|
||||
else:
|
||||
response = frappe._dict(generate_stk_push(**kwargs))
|
||||
|
||||
self.handle_api_response("CheckoutRequestID", kwargs, response)
|
||||
|
||||
def get_account_balance_info(self):
|
||||
payload = dict(
|
||||
reference_doctype="Mpesa Settings",
|
||||
reference_docname=self.name,
|
||||
doc_details=vars(self)
|
||||
)
|
||||
|
||||
if frappe.flags.in_test:
|
||||
from erpnext.erpnext_integrations.doctype.mpesa_settings.test_mpesa_settings import get_test_account_balance_response
|
||||
response = frappe._dict(get_test_account_balance_response())
|
||||
else:
|
||||
response = frappe._dict(get_account_balance(payload))
|
||||
|
||||
self.handle_api_response("ConversationID", payload, response)
|
||||
|
||||
def handle_api_response(self, global_id, request_dict, response):
|
||||
"""Response received from API calls returns a global identifier for each transaction, this code is returned during the callback."""
|
||||
# check error response
|
||||
if getattr(response, "requestId"):
|
||||
req_name = getattr(response, "requestId")
|
||||
error = response
|
||||
else:
|
||||
# global checkout id used as request name
|
||||
req_name = getattr(response, global_id)
|
||||
error = None
|
||||
|
||||
create_request_log(request_dict, "Host", "Mpesa", req_name, error)
|
||||
|
||||
if error:
|
||||
frappe.throw(_(getattr(response, "errorMessage")), title=_("Transaction Error"))
|
||||
|
||||
def generate_stk_push(**kwargs):
|
||||
"""Generate stk push by making a API call to the stk push API."""
|
||||
args = frappe._dict(kwargs)
|
||||
try:
|
||||
callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.verify_transaction"
|
||||
|
||||
mpesa_settings = frappe.get_doc("Mpesa Settings", args.payment_gateway[6:])
|
||||
env = "production" if not mpesa_settings.sandbox else "sandbox"
|
||||
|
||||
connector = MpesaConnector(env=env,
|
||||
app_key=mpesa_settings.consumer_key,
|
||||
app_secret=mpesa_settings.get_password("consumer_secret"))
|
||||
|
||||
mobile_number = sanitize_mobile_number(args.sender)
|
||||
|
||||
response = connector.stk_push(business_shortcode=mpesa_settings.till_number,
|
||||
passcode=mpesa_settings.get_password("online_passkey"), amount=args.grand_total,
|
||||
callback_url=callback_url, reference_code=mpesa_settings.till_number,
|
||||
phone_number=mobile_number, description="POS Payment")
|
||||
|
||||
return response
|
||||
|
||||
except Exception:
|
||||
frappe.log_error(title=_("Mpesa Express Transaction Error"))
|
||||
frappe.throw(_("Issue detected with Mpesa configuration, check the error logs for more details"), title=_("Mpesa Express Error"))
|
||||
|
||||
def sanitize_mobile_number(number):
|
||||
"""Add country code and strip leading zeroes from the phone number."""
|
||||
return "254" + str(number).lstrip("0")
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def verify_transaction(**kwargs):
|
||||
"""Verify the transaction result received via callback from stk."""
|
||||
transaction_response = frappe._dict(kwargs["Body"]["stkCallback"])
|
||||
|
||||
checkout_id = getattr(transaction_response, "CheckoutRequestID", "")
|
||||
request = frappe.get_doc("Integration Request", checkout_id)
|
||||
transaction_data = frappe._dict(loads(request.data))
|
||||
|
||||
if transaction_response['ResultCode'] == 0:
|
||||
if request.reference_doctype and request.reference_docname:
|
||||
try:
|
||||
doc = frappe.get_doc(request.reference_doctype,
|
||||
request.reference_docname)
|
||||
doc.run_method("on_payment_authorized", 'Completed')
|
||||
|
||||
item_response = transaction_response["CallbackMetadata"]["Item"]
|
||||
mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name")
|
||||
frappe.db.set_value("POS Invoice", doc.reference_name, "mpesa_receipt_number", mpesa_receipt)
|
||||
request.handle_success(transaction_response)
|
||||
except Exception:
|
||||
request.handle_failure(transaction_response)
|
||||
frappe.log_error(frappe.get_traceback())
|
||||
|
||||
else:
|
||||
request.handle_failure(transaction_response)
|
||||
|
||||
frappe.publish_realtime('process_phone_payment', doctype="POS Invoice",
|
||||
docname=transaction_data.payment_reference, user=request.owner, message=transaction_response)
|
||||
|
||||
def get_account_balance(request_payload):
|
||||
"""Call account balance API to send the request to the Mpesa Servers."""
|
||||
try:
|
||||
mpesa_settings = frappe.get_doc("Mpesa Settings", request_payload.get("reference_docname"))
|
||||
env = "production" if not mpesa_settings.sandbox else "sandbox"
|
||||
connector = MpesaConnector(env=env,
|
||||
app_key=mpesa_settings.consumer_key,
|
||||
app_secret=mpesa_settings.get_password("consumer_secret"))
|
||||
|
||||
callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info"
|
||||
|
||||
response = connector.get_balance(mpesa_settings.initiator_name, mpesa_settings.security_credential, mpesa_settings.till_number, 4, mpesa_settings.name, callback_url, callback_url)
|
||||
return response
|
||||
except Exception:
|
||||
frappe.log_error(title=_("Account Balance Processing Error"))
|
||||
frappe.throw(title=_("Error"), message=_("Please check your configuration and try again"))
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def process_balance_info(**kwargs):
|
||||
"""Process and store account balance information received via callback from the account balance API call."""
|
||||
account_balance_response = frappe._dict(kwargs["Result"])
|
||||
|
||||
conversation_id = getattr(account_balance_response, "ConversationID", "")
|
||||
request = frappe.get_doc("Integration Request", conversation_id)
|
||||
|
||||
if request.status == "Completed":
|
||||
return
|
||||
|
||||
transaction_data = frappe._dict(loads(request.data))
|
||||
|
||||
if account_balance_response["ResultCode"] == 0:
|
||||
try:
|
||||
result_params = account_balance_response["ResultParameters"]["ResultParameter"]
|
||||
|
||||
balance_info = fetch_param_value(result_params, "AccountBalance", "Key")
|
||||
balance_info = format_string_to_json(balance_info)
|
||||
|
||||
ref_doc = frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname)
|
||||
ref_doc.db_set("account_balance", balance_info)
|
||||
|
||||
request.handle_success(account_balance_response)
|
||||
frappe.publish_realtime("refresh_mpesa_dashboard")
|
||||
except Exception:
|
||||
request.handle_failure(account_balance_response)
|
||||
frappe.log_error(title=_("Mpesa Account Balance Processing Error"), message=account_balance_response)
|
||||
else:
|
||||
request.handle_failure(account_balance_response)
|
||||
|
||||
def format_string_to_json(balance_info):
|
||||
"""
|
||||
Format string to json.
|
||||
|
||||
e.g: '''Working Account|KES|481000.00|481000.00|0.00|0.00'''
|
||||
=> {'Working Account': {'current_balance': '481000.00',
|
||||
'available_balance': '481000.00',
|
||||
'reserved_balance': '0.00',
|
||||
'uncleared_balance': '0.00'}}
|
||||
"""
|
||||
balance_dict = frappe._dict()
|
||||
for account_info in balance_info.split("&"):
|
||||
account_info = account_info.split('|')
|
||||
balance_dict[account_info[0]] = dict(
|
||||
current_balance=fmt_money(account_info[2], currency="KES"),
|
||||
available_balance=fmt_money(account_info[3], currency="KES"),
|
||||
reserved_balance=fmt_money(account_info[4], currency="KES"),
|
||||
uncleared_balance=fmt_money(account_info[5], currency="KES")
|
||||
)
|
||||
return dumps(balance_dict)
|
||||
|
||||
def fetch_param_value(response, key, key_field):
|
||||
"""Fetch the specified key from list of dictionary. Key is identified via the key field."""
|
||||
for param in response:
|
||||
if param[key_field] == key:
|
||||
return param["Value"]
|
@ -0,0 +1,240 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
from __future__ import unicode_literals
|
||||
from json import dumps
|
||||
import frappe
|
||||
import unittest
|
||||
from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings import process_balance_info, verify_transaction
|
||||
from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice
|
||||
|
||||
class TestMpesaSettings(unittest.TestCase):
|
||||
def test_creation_of_payment_gateway(self):
|
||||
create_mpesa_settings(payment_gateway_name="_Test")
|
||||
|
||||
mode_of_payment = frappe.get_doc("Mode of Payment", "Mpesa-_Test")
|
||||
self.assertTrue(frappe.db.exists("Payment Gateway Account", {'payment_gateway': "Mpesa-_Test"}))
|
||||
self.assertTrue(mode_of_payment.name)
|
||||
self.assertEquals(mode_of_payment.type, "Phone")
|
||||
|
||||
def test_processing_of_account_balance(self):
|
||||
mpesa_doc = create_mpesa_settings(payment_gateway_name="_Account Balance")
|
||||
mpesa_doc.get_account_balance_info()
|
||||
|
||||
callback_response = get_account_balance_callback_payload()
|
||||
process_balance_info(**callback_response)
|
||||
integration_request = frappe.get_doc("Integration Request", "AG_20200927_00007cdb1f9fb6494315")
|
||||
|
||||
# test integration request creation and successful update of the status on receiving callback response
|
||||
self.assertTrue(integration_request)
|
||||
self.assertEquals(integration_request.status, "Completed")
|
||||
|
||||
# test formatting of account balance received as string to json with appropriate currency symbol
|
||||
mpesa_doc.reload()
|
||||
self.assertEquals(mpesa_doc.account_balance, dumps({
|
||||
"Working Account": {
|
||||
"current_balance": "Sh 481,000.00",
|
||||
"available_balance": "Sh 481,000.00",
|
||||
"reserved_balance": "Sh 0.00",
|
||||
"uncleared_balance": "Sh 0.00"
|
||||
}
|
||||
}))
|
||||
|
||||
def test_processing_of_callback_payload(self):
|
||||
create_mpesa_settings(payment_gateway_name="Payment")
|
||||
mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account")
|
||||
frappe.db.set_value("Account", mpesa_account, "account_currency", "KES")
|
||||
|
||||
pos_invoice = create_pos_invoice(do_not_submit=1)
|
||||
pos_invoice.append("payments", {'mode_of_payment': 'Mpesa-Payment', 'account': mpesa_account, 'amount': 500})
|
||||
pos_invoice.contact_mobile = "093456543894"
|
||||
pos_invoice.currency = "KES"
|
||||
pos_invoice.save()
|
||||
|
||||
pr = pos_invoice.create_payment_request()
|
||||
# test payment request creation
|
||||
self.assertEquals(pr.payment_gateway, "Mpesa-Payment")
|
||||
|
||||
callback_response = get_payment_callback_payload()
|
||||
verify_transaction(**callback_response)
|
||||
# test creation of integration request
|
||||
integration_request = frappe.get_doc("Integration Request", "ws_CO_061020201133231972")
|
||||
|
||||
# test integration request creation and successful update of the status on receiving callback response
|
||||
self.assertTrue(integration_request)
|
||||
self.assertEquals(integration_request.status, "Completed")
|
||||
|
||||
pos_invoice.reload()
|
||||
integration_request.reload()
|
||||
self.assertEquals(pos_invoice.mpesa_receipt_number, "LGR7OWQX0R")
|
||||
self.assertEquals(integration_request.status, "Completed")
|
||||
|
||||
def create_mpesa_settings(payment_gateway_name="Express"):
|
||||
if frappe.db.exists("Mpesa Settings", payment_gateway_name):
|
||||
return frappe.get_doc("Mpesa Settings", payment_gateway_name)
|
||||
|
||||
doc = frappe.get_doc(dict( #nosec
|
||||
doctype="Mpesa Settings",
|
||||
payment_gateway_name=payment_gateway_name,
|
||||
consumer_key="5sMu9LVI1oS3oBGPJfh3JyvLHwZOdTKn",
|
||||
consumer_secret="VI1oS3oBGPJfh3JyvLHw",
|
||||
online_passkey="LVI1oS3oBGPJfh3JyvLHwZOd",
|
||||
till_number="174379"
|
||||
))
|
||||
|
||||
doc.insert(ignore_permissions=True)
|
||||
return doc
|
||||
|
||||
def get_test_account_balance_response():
|
||||
"""Response received after calling the account balance API."""
|
||||
return {
|
||||
"ResultType":0,
|
||||
"ResultCode":0,
|
||||
"ResultDesc":"The service request has been accepted successfully.",
|
||||
"OriginatorConversationID":"10816-694520-2",
|
||||
"ConversationID":"AG_20200927_00007cdb1f9fb6494315",
|
||||
"TransactionID":"LGR0000000",
|
||||
"ResultParameters":{
|
||||
"ResultParameter":[
|
||||
{
|
||||
"Key":"ReceiptNo",
|
||||
"Value":"LGR919G2AV"
|
||||
},
|
||||
{
|
||||
"Key":"Conversation ID",
|
||||
"Value":"AG_20170727_00004492b1b6d0078fbe"
|
||||
},
|
||||
{
|
||||
"Key":"FinalisedTime",
|
||||
"Value":20170727101415
|
||||
},
|
||||
{
|
||||
"Key":"Amount",
|
||||
"Value":10
|
||||
},
|
||||
{
|
||||
"Key":"TransactionStatus",
|
||||
"Value":"Completed"
|
||||
},
|
||||
{
|
||||
"Key":"ReasonType",
|
||||
"Value":"Salary Payment via API"
|
||||
},
|
||||
{
|
||||
"Key":"TransactionReason"
|
||||
},
|
||||
{
|
||||
"Key":"DebitPartyCharges",
|
||||
"Value":"Fee For B2C Payment|KES|33.00"
|
||||
},
|
||||
{
|
||||
"Key":"DebitAccountType",
|
||||
"Value":"Utility Account"
|
||||
},
|
||||
{
|
||||
"Key":"InitiatedTime",
|
||||
"Value":20170727101415
|
||||
},
|
||||
{
|
||||
"Key":"Originator Conversation ID",
|
||||
"Value":"19455-773836-1"
|
||||
},
|
||||
{
|
||||
"Key":"CreditPartyName",
|
||||
"Value":"254708374149 - John Doe"
|
||||
},
|
||||
{
|
||||
"Key":"DebitPartyName",
|
||||
"Value":"600134 - Safaricom157"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ReferenceData":{
|
||||
"ReferenceItem":{
|
||||
"Key":"Occasion",
|
||||
"Value":"aaaa"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def get_payment_request_response_payload():
|
||||
"""Response received after successfully calling the stk push process request API."""
|
||||
return {
|
||||
"MerchantRequestID": "8071-27184008-1",
|
||||
"CheckoutRequestID": "ws_CO_061020201133231972",
|
||||
"ResultCode": 0,
|
||||
"ResultDesc": "The service request is processed successfully.",
|
||||
"CallbackMetadata": {
|
||||
"Item": [
|
||||
{ "Name": "Amount", "Value": 500.0 },
|
||||
{ "Name": "MpesaReceiptNumber", "Value": "LGR7OWQX0R" },
|
||||
{ "Name": "TransactionDate", "Value": 20201006113336 },
|
||||
{ "Name": "PhoneNumber", "Value": 254723575670 }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_payment_callback_payload():
|
||||
"""Response received from the server as callback after calling the stkpush process request API."""
|
||||
return {
|
||||
"Body":{
|
||||
"stkCallback":{
|
||||
"MerchantRequestID":"19465-780693-1",
|
||||
"CheckoutRequestID":"ws_CO_061020201133231972",
|
||||
"ResultCode":0,
|
||||
"ResultDesc":"The service request is processed successfully.",
|
||||
"CallbackMetadata":{
|
||||
"Item":[
|
||||
{
|
||||
"Name":"Amount",
|
||||
"Value":500
|
||||
},
|
||||
{
|
||||
"Name":"MpesaReceiptNumber",
|
||||
"Value":"LGR7OWQX0R"
|
||||
},
|
||||
{
|
||||
"Name":"Balance"
|
||||
},
|
||||
{
|
||||
"Name":"TransactionDate",
|
||||
"Value":20170727154800
|
||||
},
|
||||
{
|
||||
"Name":"PhoneNumber",
|
||||
"Value":254721566839
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def get_account_balance_callback_payload():
|
||||
"""Response received from the server as callback after calling the account balance API."""
|
||||
return {
|
||||
"Result":{
|
||||
"ResultType": 0,
|
||||
"ResultCode": 0,
|
||||
"ResultDesc": "The service request is processed successfully.",
|
||||
"OriginatorConversationID": "16470-170099139-1",
|
||||
"ConversationID": "AG_20200927_00007cdb1f9fb6494315",
|
||||
"TransactionID": "OIR0000000",
|
||||
"ResultParameters": {
|
||||
"ResultParameter": [
|
||||
{
|
||||
"Key": "AccountBalance",
|
||||
"Value": "Working Account|KES|481000.00|481000.00|0.00|0.00"
|
||||
},
|
||||
{ "Key": "BOCompletedTime", "Value": 20200927234123 }
|
||||
]
|
||||
},
|
||||
"ReferenceData": {
|
||||
"ReferenceItem": {
|
||||
"Key": "QueueTimeoutURL",
|
||||
"Value": "https://internalsandbox.safaricom.co.ke/mpesa/abresults/v1/submit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@ import frappe
|
||||
from frappe import _
|
||||
import base64, hashlib, hmac
|
||||
from six.moves.urllib.parse import urlparse
|
||||
from erpnext import get_default_company
|
||||
|
||||
def validate_webhooks_request(doctype, hmac_key, secret_key='secret'):
|
||||
def innerfn(fn):
|
||||
@ -41,3 +42,22 @@ def get_webhook_address(connector_name, method, exclude_uri=False):
|
||||
server_url = '{uri.scheme}://{uri.netloc}/api/method/{endpoint}'.format(uri=urlparse(url), endpoint=endpoint)
|
||||
|
||||
return server_url
|
||||
|
||||
def create_mode_of_payment(gateway, payment_type="General"):
|
||||
payment_gateway_account = frappe.db.get_value("Payment Gateway Account", {
|
||||
"payment_gateway": gateway
|
||||
}, ['payment_account'])
|
||||
|
||||
if not frappe.db.exists("Mode of Payment", gateway) and payment_gateway_account:
|
||||
mode_of_payment = frappe.get_doc({
|
||||
"doctype": "Mode of Payment",
|
||||
"mode_of_payment": gateway,
|
||||
"enabled": 1,
|
||||
"type": payment_type,
|
||||
"accounts": [{
|
||||
"doctype": "Mode of Payment Account",
|
||||
"company": get_default_company(),
|
||||
"default_account": payment_gateway_account
|
||||
}]
|
||||
})
|
||||
mode_of_payment.insert(ignore_permissions=True)
|
@ -730,3 +730,4 @@ erpnext.patches.v13_0.rename_issue_doctype_fields
|
||||
erpnext.patches.v13_0.change_default_pos_print_format
|
||||
erpnext.patches.v13_0.set_youtube_video_id
|
||||
erpnext.patches.v13_0.print_uom_after_quantity_patch
|
||||
erpnext.patches.v13_0.set_payment_channel_in_payment_gateway_account
|
||||
|
@ -0,0 +1,17 @@
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
|
||||
def execute():
|
||||
"""Set the payment gateway account as Email for all the existing payment channel."""
|
||||
doc_meta = frappe.get_meta("Payment Gateway Account")
|
||||
if doc_meta.get_field("payment_channel"):
|
||||
return
|
||||
|
||||
frappe.reload_doc("Accounts", "doctype", "Payment Gateway Account")
|
||||
set_payment_channel_as_email()
|
||||
|
||||
def set_payment_channel_as_email():
|
||||
frappe.db.sql("""
|
||||
UPDATE `tabPayment Gateway Account`
|
||||
SET `payment_channel` = "Email"
|
||||
""")
|
@ -174,6 +174,24 @@ erpnext.PointOfSale.Payment = class {
|
||||
}
|
||||
})
|
||||
|
||||
frappe.realtime.on("process_phone_payment", function(data) {
|
||||
frappe.dom.unfreeze();
|
||||
cur_frm.reload_doc();
|
||||
let message = data["ResultDesc"];
|
||||
let title = __("Payment Failed");
|
||||
|
||||
if (data["ResultCode"] == 0) {
|
||||
title = __("Payment Received");
|
||||
$('.btn.btn-xs.btn-default[data-fieldname=request_for_payment]').html(`Payment Received`)
|
||||
me.events.submit_invoice();
|
||||
}
|
||||
|
||||
frappe.msgprint({
|
||||
"message": message,
|
||||
"title": title
|
||||
});
|
||||
});
|
||||
|
||||
this.$payment_modes.on('click', '.shortcut', function(e) {
|
||||
const value = $(this).attr('data-value');
|
||||
me.selected_mode.set_value(value);
|
||||
|
@ -7,6 +7,10 @@ frappe.ui.form.on("Shopping Cart Settings", {
|
||||
frm.fields_dict.quotation_series.df.options = frm.doc.__onload.quotation_series;
|
||||
frm.refresh_field("quotation_series");
|
||||
}
|
||||
|
||||
frm.set_query('payment_gateway_account', function() {
|
||||
return { 'filters': { 'payment_channel': "Email" } };
|
||||
});
|
||||
},
|
||||
enabled: function(frm) {
|
||||
if (frm.doc.enabled === 1) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user