From a4a64ba04a0b5204cfc72de78f158c9170ec5d96 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 22 Sep 2023 16:17:32 +0530 Subject: [PATCH 01/12] refactor: get `GoCardless Settings` from ERPNext --- payments/payment_gateways/doctype/__init__.py | 0 .../doctype/gocardless_settings/__init__.py | 89 +++++++ .../gocardless_settings.js | 8 + .../gocardless_settings.json | 72 ++++++ .../gocardless_settings.py | 217 ++++++++++++++++++ .../test_gocardless_settings.py | 8 + pyproject.toml | 1 + 7 files changed, 395 insertions(+) create mode 100644 payments/payment_gateways/doctype/__init__.py create mode 100644 payments/payment_gateways/doctype/gocardless_settings/__init__.py create mode 100644 payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.js create mode 100644 payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.json create mode 100644 payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.py create mode 100644 payments/payment_gateways/doctype/gocardless_settings/test_gocardless_settings.py diff --git a/payments/payment_gateways/doctype/__init__.py b/payments/payment_gateways/doctype/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/payment_gateways/doctype/gocardless_settings/__init__.py b/payments/payment_gateways/doctype/gocardless_settings/__init__.py new file mode 100644 index 00000000..65be5993 --- /dev/null +++ b/payments/payment_gateways/doctype/gocardless_settings/__init__.py @@ -0,0 +1,89 @@ +# Copyright (c) 2018, Frappe Technologies and contributors +# For license information, please see license.txt + + +import hashlib +import hmac +import json + +import frappe + + +@frappe.whitelist(allow_guest=True) +def webhooks(): + r = frappe.request + if not r: + return + + if not authenticate_signature(r): + raise frappe.AuthenticationError + + gocardless_events = json.loads(r.get_data()) or [] + for event in gocardless_events["events"]: + set_status(event) + + return 200 + + +def set_status(event): + resource_type = event.get("resource_type", {}) + + if resource_type == "mandates": + set_mandate_status(event) + + +def set_mandate_status(event): + mandates = [] + if isinstance(event["links"], (list,)): + for link in event["links"]: + mandates.append(link["mandate"]) + else: + mandates.append(event["links"]["mandate"]) + + if ( + event["action"] == "pending_customer_approval" + or event["action"] == "pending_submission" + or event["action"] == "submitted" + or event["action"] == "active" + ): + disabled = 0 + else: + disabled = 1 + + for mandate in mandates: + frappe.db.set_value("GoCardless Mandate", mandate, "disabled", disabled) + + +def authenticate_signature(r): + """Returns True if the received signature matches the generated signature""" + received_signature = frappe.get_request_header("Webhook-Signature") + + if not received_signature: + return False + + for key in get_webhook_keys(): + computed_signature = hmac.new(key.encode("utf-8"), r.get_data(), hashlib.sha256).hexdigest() + if hmac.compare_digest(str(received_signature), computed_signature): + return True + + return False + + +def get_webhook_keys(): + def _get_webhook_keys(): + webhook_keys = [ + d.webhooks_secret + for d in frappe.get_all( + "GoCardless Settings", + fields=["webhooks_secret"], + ) + if d.webhooks_secret + ] + + return webhook_keys + + return frappe.cache().get_value("gocardless_webhooks_secret", _get_webhook_keys) + + +def clear_cache(): + frappe.cache().delete_value("gocardless_webhooks_secret") diff --git a/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.js b/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.js new file mode 100644 index 00000000..ef1b97f6 --- /dev/null +++ b/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2018, Frappe Technologies and contributors +// For license information, please see license.txt + +// frappe.ui.form.on('GoCardless Settings', { +// refresh(frm) { + +// }, +// }); \ No newline at end of file diff --git a/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.json b/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.json new file mode 100644 index 00000000..cc7e36a0 --- /dev/null +++ b/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.json @@ -0,0 +1,72 @@ +{ + "actions": [], + "autoname": "field:gateway_name", + "creation": "2018-02-06 16:11:10.028249", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "gateway_name", + "section_break_2", + "access_token", + "webhooks_secret", + "use_sandbox" + ], + "fields": [ + { + "fieldname": "gateway_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Payment Gateway Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "section_break_2", + "fieldtype": "Section Break" + }, + { + "fieldname": "access_token", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Access Token", + "reqd": 1 + }, + { + "fieldname": "webhooks_secret", + "fieldtype": "Data", + "label": "Webhooks Secret" + }, + { + "default": "0", + "fieldname": "use_sandbox", + "fieldtype": "Check", + "label": "Use Sandbox" + } + ], + "links": [], + "modified": "2023-09-22 13:33:42.225243", + "modified_by": "Administrator", + "module": "Payment Gateways", + "name": "GoCardless Settings", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.py b/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.py new file mode 100644 index 00000000..f3ec5ad6 --- /dev/null +++ b/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.py @@ -0,0 +1,217 @@ +# Copyright (c) 2018, Frappe Technologies and contributors +# For license information, please see license.txt + + +from urllib.parse import urlencode + +import frappe +import gocardless_pro +from frappe import _ +from frappe.integrations.utils import create_request_log +from frappe.model.document import Document +from frappe.utils import call_hook_method, cint, flt, get_url + + +class GoCardlessSettings(Document): + supported_currencies = ["EUR", "DKK", "GBP", "SEK", "AUD", "NZD", "CAD", "USD"] + + def validate(self): + self.initialize_client() + + def initialize_client(self): + self.environment = self.get_environment() + try: + self.client = gocardless_pro.Client( + access_token=self.access_token, environment=self.environment + ) + return self.client + except Exception as e: + frappe.throw(e) + + def on_update(self): + from payments.utils import create_payment_gateway + + create_payment_gateway( + "GoCardless-" + self.gateway_name, settings="GoCardLess Settings", controller=self.gateway_name + ) + call_hook_method("payment_gateway_enabled", gateway="GoCardless-" + self.gateway_name) + + def on_payment_request_submission(self, data): + if data.reference_doctype != "Fees": + customer_data = frappe.db.get_value( + data.reference_doctype, data.reference_name, ["company", "customer_name"], as_dict=1 + ) + + data = { + "amount": flt(data.grand_total, data.precision("grand_total")), + "title": customer_data.company.encode("utf-8"), + "description": data.subject.encode("utf-8"), + "reference_doctype": data.doctype, + "reference_docname": data.name, + "payer_email": data.email_to or frappe.session.user, + "payer_name": customer_data.customer_name, + "order_id": data.name, + "currency": data.currency, + } + + valid_mandate = self.check_mandate_validity(data) + if valid_mandate is not None: + data.update(valid_mandate) + + self.create_payment_request(data) + return False + else: + return True + + def check_mandate_validity(self, data): + + if frappe.db.exists("GoCardless Mandate", dict(customer=data.get("payer_name"), disabled=0)): + registered_mandate = frappe.db.get_value( + "GoCardless Mandate", dict(customer=data.get("payer_name"), disabled=0), "mandate" + ) + self.initialize_client() + mandate = self.client.mandates.get(registered_mandate) + + if ( + mandate.status == "pending_customer_approval" + or mandate.status == "pending_submission" + or mandate.status == "submitted" + or mandate.status == "active" + ): + return {"mandate": registered_mandate} + else: + return None + else: + return None + + def get_environment(self): + if self.use_sandbox: + return "sandbox" + else: + return "live" + + def validate_transaction_currency(self, currency): + if currency not in self.supported_currencies: + frappe.throw( + _( + "Please select another payment method. Go Cardless does not support transactions in currency '{0}'" + ).format(currency) + ) + + def get_payment_url(self, **kwargs): + return get_url("gocardless_checkout?{0}".format(urlencode(kwargs))) + + def create_payment_request(self, data): + self.data = frappe._dict(data) + + try: + self.integration_request = create_request_log(self.data, "Host", "GoCardless") + return self.create_charge_on_gocardless() + + except Exception: + frappe.log_error("Gocardless payment reqeust failed") + return { + "redirect_to": frappe.redirect_to_message( + _("Server Error"), + _( + "There seems to be an issue with the server's GoCardless configuration. Don't worry, in case of failure, the amount will get refunded to your account." + ), + ), + "status": 401, + } + + def create_charge_on_gocardless(self): + redirect_to = self.data.get("redirect_to") or None + redirect_message = self.data.get("redirect_message") or None + + reference_doc = frappe.get_doc( + self.data.get("reference_doctype"), self.data.get("reference_docname") + ) + self.initialize_client() + + try: + payment = self.client.payments.create( + params={ + "amount": cint(reference_doc.grand_total * 100), + "currency": reference_doc.currency, + "links": {"mandate": self.data.get("mandate")}, + "metadata": { + "reference_doctype": reference_doc.doctype, + "reference_document": reference_doc.name, + }, + }, + headers={ + "Idempotency-Key": self.data.get("reference_docname"), + }, + ) + + if ( + payment.status == "pending_submission" + or payment.status == "pending_customer_approval" + or payment.status == "submitted" + ): + self.integration_request.db_set("status", "Authorized", update_modified=False) + self.flags.status_changed_to = "Completed" + self.integration_request.db_set("output", payment.status, update_modified=False) + + elif payment.status == "confirmed" or payment.status == "paid_out": + self.integration_request.db_set("status", "Completed", update_modified=False) + self.flags.status_changed_to = "Completed" + self.integration_request.db_set("output", payment.status, update_modified=False) + + elif ( + payment.status == "cancelled" + or payment.status == "customer_approval_denied" + or payment.status == "charged_back" + ): + self.integration_request.db_set("status", "Cancelled", update_modified=False) + frappe.log_error("Gocardless payment cancelled") + self.integration_request.db_set("error", payment.status, update_modified=False) + else: + self.integration_request.db_set("status", "Failed", update_modified=False) + frappe.log_error("Gocardless payment failed") + self.integration_request.db_set("error", payment.status, update_modified=False) + + except Exception as e: + frappe.log_error("GoCardless Payment Error") + + if self.flags.status_changed_to == "Completed": + status = "Completed" + if "reference_doctype" in self.data and "reference_docname" in self.data: + custom_redirect_to = None + try: + custom_redirect_to = frappe.get_doc( + self.data.get("reference_doctype"), self.data.get("reference_docname") + ).run_method("on_payment_authorized", self.flags.status_changed_to) + except Exception: + frappe.log_error("Gocardless redirect failed") + + if custom_redirect_to: + redirect_to = custom_redirect_to + + redirect_url = redirect_to + else: + status = "Error" + redirect_url = "payment-failed" + + if redirect_message: + redirect_url += "&" + urlencode({"redirect_message": redirect_message}) + + redirect_url = get_url(redirect_url) + + return {"redirect_to": redirect_url, "status": status} + + +def get_gateway_controller(doc): + payment_request = frappe.get_doc("Payment Request", doc) + gateway_controller = frappe.db.get_value( + "Payment Gateway", payment_request.payment_gateway, "gateway_controller" + ) + return gateway_controller + + +def gocardless_initialization(doc): + gateway_controller = get_gateway_controller(doc) + settings = frappe.get_doc("GoCardless Settings", gateway_controller) + client = settings.initialize_client() + return client diff --git a/payments/payment_gateways/doctype/gocardless_settings/test_gocardless_settings.py b/payments/payment_gateways/doctype/gocardless_settings/test_gocardless_settings.py new file mode 100644 index 00000000..379afe51 --- /dev/null +++ b/payments/payment_gateways/doctype/gocardless_settings/test_gocardless_settings.py @@ -0,0 +1,8 @@ +# Copyright (c) 2018, Frappe Technologies and Contributors +# See license.txt + +import unittest + + +class TestGoCardlessSettings(unittest.TestCase): + pass diff --git a/pyproject.toml b/pyproject.toml index 46c94ee9..ecf83242 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "stripe~=2.56.0", "braintree~=4.20.0", "pycryptodome~=3.18.0", + "gocardless-pro~=1.22.0", ] [build-system] From f9a799f9d709b969b79f8e2216a43be8b55874c5 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 22 Sep 2023 16:43:52 +0530 Subject: [PATCH 02/12] refactor: get `GoCardless Mandate` from ERPNext --- .../doctype/gocardless_mandate/__init__.py | 0 .../gocardless_mandate/gocardless_mandate.js | 5 ++ .../gocardless_mandate.json | 72 +++++++++++++++++++ .../gocardless_mandate/gocardless_mandate.py | 9 +++ .../test_gocardless_mandate.py | 8 +++ .../gocardless_settings.py | 2 +- 6 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 payments/payment_gateways/doctype/gocardless_mandate/__init__.py create mode 100644 payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.js create mode 100644 payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.json create mode 100644 payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.py create mode 100644 payments/payment_gateways/doctype/gocardless_mandate/test_gocardless_mandate.py diff --git a/payments/payment_gateways/doctype/gocardless_mandate/__init__.py b/payments/payment_gateways/doctype/gocardless_mandate/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.js b/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.js new file mode 100644 index 00000000..37f9f7b9 --- /dev/null +++ b/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.js @@ -0,0 +1,5 @@ +// Copyright (c) 2018, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('GoCardless Mandate', { +}); diff --git a/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.json b/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.json new file mode 100644 index 00000000..afe9a253 --- /dev/null +++ b/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.json @@ -0,0 +1,72 @@ +{ + "actions": [], + "autoname": "field:mandate", + "creation": "2018-02-08 11:33:15.721919", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "disabled", + "customer", + "mandate", + "gocardless_customer" + ], + "fields": [ + { + "default": "0", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled" + }, + { + "fieldname": "customer", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Customer", + "options": "Customer", + "reqd": 1 + }, + { + "fieldname": "mandate", + "fieldtype": "Data", + "label": "Mandate", + "read_only": 1, + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "gocardless_customer", + "fieldtype": "Data", + "in_list_view": 1, + "label": "GoCardless Customer", + "read_only": 1, + "reqd": 1 + } + ], + "links": [], + "modified": "2023-09-25 10:54:28.956136", + "modified_by": "Administrator", + "module": "Payment Gateways", + "name": "GoCardless Mandate", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.py b/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.py new file mode 100644 index 00000000..bceb3cae --- /dev/null +++ b/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.py @@ -0,0 +1,9 @@ +# Copyright (c) 2018, Frappe Technologies and contributors +# For license information, please see license.txt + + +from frappe.model.document import Document + + +class GoCardlessMandate(Document): + pass diff --git a/payments/payment_gateways/doctype/gocardless_mandate/test_gocardless_mandate.py b/payments/payment_gateways/doctype/gocardless_mandate/test_gocardless_mandate.py new file mode 100644 index 00000000..0c1952a1 --- /dev/null +++ b/payments/payment_gateways/doctype/gocardless_mandate/test_gocardless_mandate.py @@ -0,0 +1,8 @@ +# Copyright (c) 2018, Frappe Technologies and Contributors +# See license.txt + +import unittest + + +class TestGoCardlessMandate(unittest.TestCase): + pass diff --git a/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.py b/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.py index f3ec5ad6..e95781c6 100644 --- a/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.py +++ b/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.py @@ -99,7 +99,7 @@ def validate_transaction_currency(self, currency): ) def get_payment_url(self, **kwargs): - return get_url("gocardless_checkout?{0}".format(urlencode(kwargs))) + return get_url(f"gocardless_checkout?{urlencode(kwargs)}") def create_payment_request(self, data): self.data = frappe._dict(data) From 6da0c90be9a8b9634f27fddfca5a535b3a88da58 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 22 Sep 2023 17:11:53 +0530 Subject: [PATCH 03/12] refactor: get `GoCardless Templates` from ERPNext --- .../templates/includes/gocardless_checkout.js | 24 ++++ .../includes/gocardless_confirmation.js | 24 ++++ .../templates/pages/gocardless_checkout.html | 16 +++ .../templates/pages/gocardless_checkout.py | 100 +++++++++++++++++ .../pages/gocardless_confirmation.html | 16 +++ .../pages/gocardless_confirmation.py | 106 ++++++++++++++++++ 6 files changed, 286 insertions(+) create mode 100644 payments/templates/includes/gocardless_checkout.js create mode 100644 payments/templates/includes/gocardless_confirmation.js create mode 100644 payments/templates/pages/gocardless_checkout.html create mode 100644 payments/templates/pages/gocardless_checkout.py create mode 100644 payments/templates/pages/gocardless_confirmation.html create mode 100644 payments/templates/pages/gocardless_confirmation.py diff --git a/payments/templates/includes/gocardless_checkout.js b/payments/templates/includes/gocardless_checkout.js new file mode 100644 index 00000000..67dee54e --- /dev/null +++ b/payments/templates/includes/gocardless_checkout.js @@ -0,0 +1,24 @@ +$(document).ready(function() { + var data = {{ frappe.form_dict | json }}; + var doctype = "{{ reference_doctype }}" + var docname = "{{ reference_docname }}" + + frappe.call({ + method: "payments.templates.pages.gocardless_checkout.check_mandate", + freeze: true, + headers: { + "X-Requested-With": "XMLHttpRequest" + }, + args: { + "data": JSON.stringify(data), + "reference_doctype": doctype, + "reference_docname": docname + }, + callback: function(r) { + if (r.message) { + window.location.href = r.message.redirect_to + } + } + }) + +}) diff --git a/payments/templates/includes/gocardless_confirmation.js b/payments/templates/includes/gocardless_confirmation.js new file mode 100644 index 00000000..2c0de01b --- /dev/null +++ b/payments/templates/includes/gocardless_confirmation.js @@ -0,0 +1,24 @@ +$(document).ready(function() { + var redirect_flow_id = "{{ redirect_flow_id }}"; + var doctype = "{{ reference_doctype }}"; + var docname = "{{ reference_docname }}"; + + frappe.call({ + method: "payments.templates.pages.gocardless_confirmation.confirm_payment", + freeze: true, + headers: { + "X-Requested-With": "XMLHttpRequest" + }, + args: { + "redirect_flow_id": redirect_flow_id, + "reference_doctype": doctype, + "reference_docname": docname + }, + callback: function(r) { + if (r.message) { + window.location.href = r.message.redirect_to; + } + } + }); + +}); diff --git a/payments/templates/pages/gocardless_checkout.html b/payments/templates/pages/gocardless_checkout.html new file mode 100644 index 00000000..84bddcf4 --- /dev/null +++ b/payments/templates/pages/gocardless_checkout.html @@ -0,0 +1,16 @@ +{% extends "templates/web.html" %} + +{% block title %} Payment {% endblock %} + +{%- block header -%}{% endblock %} + +{% block script %} + +{% endblock %} + +{%- block page_content -%} +

+ {{ _("Loading Payment System") }} +

+ +{% endblock %} diff --git a/payments/templates/pages/gocardless_checkout.py b/payments/templates/pages/gocardless_checkout.py new file mode 100644 index 00000000..fa780d23 --- /dev/null +++ b/payments/templates/pages/gocardless_checkout.py @@ -0,0 +1,100 @@ +# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import json + +import frappe +from frappe import _ +from frappe.utils import flt, get_url + +from payments.payment_gateways.doctype.gocardless_settings.gocardless_settings import ( + get_gateway_controller, + gocardless_initialization, +) + +no_cache = 1 + +expected_keys = ( + "amount", + "title", + "description", + "reference_doctype", + "reference_docname", + "payer_name", + "payer_email", + "order_id", + "currency", +) + + +def get_context(context): + context.no_cache = 1 + + # all these keys exist in form_dict + if not (set(expected_keys) - set(frappe.form_dict.keys())): + for key in expected_keys: + context[key] = frappe.form_dict[key] + + context["amount"] = flt(context["amount"]) + + gateway_controller = get_gateway_controller(context.reference_docname) + context["header_img"] = frappe.db.get_value( + "GoCardless Settings", gateway_controller, "header_img" + ) + + else: + frappe.redirect_to_message( + _("Some information is missing"), + _("Looks like someone sent you to an incomplete URL. Please ask them to look into it."), + ) + frappe.local.flags.redirect_location = frappe.local.response.location + raise frappe.Redirect + + +@frappe.whitelist(allow_guest=True) +def check_mandate(data, reference_doctype, reference_docname): + data = json.loads(data) + + client = gocardless_initialization(reference_docname) + + payer = frappe.get_doc("Customer", data["payer_name"]) + + if payer.customer_type == "Individual" and payer.customer_primary_contact is not None: + primary_contact = frappe.get_doc("Contact", payer.customer_primary_contact) + prefilled_customer = { + "company_name": payer.name, + "given_name": primary_contact.first_name, + } + if primary_contact.last_name is not None: + prefilled_customer.update({"family_name": primary_contact.last_name}) + + if primary_contact.email_id is not None: + prefilled_customer.update({"email": primary_contact.email_id}) + else: + prefilled_customer.update({"email": frappe.session.user}) + + else: + prefilled_customer = {"company_name": payer.name, "email": frappe.session.user} + + success_url = get_url( + "gocardless_confirmation?reference_doctype=" + + reference_doctype + + "&reference_docname=" + + reference_docname + ) + + try: + redirect_flow = client.redirect_flows.create( + params={ + "description": _("Pay {0} {1}").format(data["amount"], data["currency"]), + "session_token": frappe.session.user, + "success_redirect_url": success_url, + "prefilled_customer": prefilled_customer, + } + ) + + return {"redirect_to": redirect_flow.redirect_url} + + except Exception as e: + frappe.log_error("GoCardless Payment Error") + return {"redirect_to": "payment-failed"} diff --git a/payments/templates/pages/gocardless_confirmation.html b/payments/templates/pages/gocardless_confirmation.html new file mode 100644 index 00000000..a92bcab0 --- /dev/null +++ b/payments/templates/pages/gocardless_confirmation.html @@ -0,0 +1,16 @@ +{% extends "templates/web.html" %} + +{% block title %} Payment {% endblock %} + +{%- block header -%}{% endblock %} + +{% block script %} + +{% endblock %} + +{%- block page_content -%} +

+ {{ _("Payment Confirmation") }} +

+ +{% endblock %} diff --git a/payments/templates/pages/gocardless_confirmation.py b/payments/templates/pages/gocardless_confirmation.py new file mode 100644 index 00000000..3fe7d99b --- /dev/null +++ b/payments/templates/pages/gocardless_confirmation.py @@ -0,0 +1,106 @@ +# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ + +from payments.payment_gateways.doctype.gocardless_settings.gocardless_settings import ( + get_gateway_controller, + gocardless_initialization, +) + +no_cache = 1 + +expected_keys = ("redirect_flow_id", "reference_doctype", "reference_docname") + + +def get_context(context): + context.no_cache = 1 + + # all these keys exist in form_dict + if not (set(expected_keys) - set(frappe.form_dict.keys())): + for key in expected_keys: + context[key] = frappe.form_dict[key] + + else: + frappe.redirect_to_message( + _("Some information is missing"), + _("Looks like someone sent you to an incomplete URL. Please ask them to look into it."), + ) + frappe.local.flags.redirect_location = frappe.local.response.location + raise frappe.Redirect + + +@frappe.whitelist(allow_guest=True) +def confirm_payment(redirect_flow_id, reference_doctype, reference_docname): + + client = gocardless_initialization(reference_docname) + + try: + redirect_flow = client.redirect_flows.complete( + redirect_flow_id, params={"session_token": frappe.session.user} + ) + + confirmation_url = redirect_flow.confirmation_url + gocardless_success_page = frappe.get_hooks("gocardless_success_page") + if gocardless_success_page: + confirmation_url = frappe.get_attr(gocardless_success_page[-1])( + reference_doctype, reference_docname + ) + + data = { + "mandate": redirect_flow.links.mandate, + "customer": redirect_flow.links.customer, + "redirect_to": confirmation_url, + "redirect_message": "Mandate successfully created", + "reference_doctype": reference_doctype, + "reference_docname": reference_docname, + } + + try: + create_mandate(data) + except Exception as e: + frappe.log_error("GoCardless Mandate Registration Error") + + gateway_controller = get_gateway_controller(reference_docname) + frappe.get_doc("GoCardless Settings", gateway_controller).create_payment_request(data) + + return {"redirect_to": confirmation_url} + + except Exception as e: + frappe.log_error("GoCardless Payment Error") + return {"redirect_to": "payment-failed"} + + +def create_mandate(data): + data = frappe._dict(data) + frappe.logger().debug(data) + + mandate = data.get("mandate") + + if frappe.db.exists("GoCardless Mandate", mandate): + return + + else: + reference_doc = frappe.db.get_value( + data.get("reference_doctype"), + data.get("reference_docname"), + ["reference_doctype", "reference_name"], + as_dict=1, + ) + erpnext_customer = frappe.db.get_value( + reference_doc.reference_doctype, reference_doc.reference_name, ["customer_name"], as_dict=1 + ) + + try: + frappe.get_doc( + { + "doctype": "GoCardless Mandate", + "mandate": mandate, + "customer": erpnext_customer.customer_name, + "gocardless_customer": data.get("customer"), + } + ).insert(ignore_permissions=True) + + except Exception: + frappe.log_error("Gocardless: Unable to create mandate") From 38c3925fcc0f9e40c424917293b80b8617ee2454 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 22 Sep 2023 18:16:06 +0530 Subject: [PATCH 04/12] refactor: get `Mpesa Settings` from ERPNext --- .../doctype/mpesa_settings/__init__.py | 0 .../mpesa_settings/account_balance.html | 27 ++ .../doctype/mpesa_settings/mpesa_connector.py | 149 +++++++ .../mpesa_settings/mpesa_custom_fields.py | 56 +++ .../doctype/mpesa_settings/mpesa_settings.js | 36 ++ .../mpesa_settings/mpesa_settings.json | 154 +++++++ .../doctype/mpesa_settings/mpesa_settings.py | 384 ++++++++++++++++ .../mpesa_settings/test_mpesa_settings.py | 415 ++++++++++++++++++ 8 files changed, 1221 insertions(+) create mode 100644 payments/payment_gateways/doctype/mpesa_settings/__init__.py create mode 100644 payments/payment_gateways/doctype/mpesa_settings/account_balance.html create mode 100644 payments/payment_gateways/doctype/mpesa_settings/mpesa_connector.py create mode 100644 payments/payment_gateways/doctype/mpesa_settings/mpesa_custom_fields.py create mode 100644 payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.js create mode 100644 payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.json create mode 100644 payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.py create mode 100644 payments/payment_gateways/doctype/mpesa_settings/test_mpesa_settings.py diff --git a/payments/payment_gateways/doctype/mpesa_settings/__init__.py b/payments/payment_gateways/doctype/mpesa_settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/payment_gateways/doctype/mpesa_settings/account_balance.html b/payments/payment_gateways/doctype/mpesa_settings/account_balance.html new file mode 100644 index 00000000..6614cab8 --- /dev/null +++ b/payments/payment_gateways/doctype/mpesa_settings/account_balance.html @@ -0,0 +1,27 @@ +{% if not jQuery.isEmptyObject(data) %} +
{{ __("Balance Details") }}
+ + + + + + + + + + + + {% for(const [key, value] of Object.entries(data)) { %} + + + + + + + + {% } %} + +
{{ __("Account Type") }}{{ __("Current Balance") }}{{ __("Available Balance") }}{{ __("Reserved Balance") }}{{ __("Uncleared Balance") }}
{%= key %} {%= value["current_balance"] %} {%= value["available_balance"] %} {%= value["reserved_balance"] %} {%= value["uncleared_balance"] %}
+{% else %} +

Account Balance Information Not Available.

+{% endif %} diff --git a/payments/payment_gateways/doctype/mpesa_settings/mpesa_connector.py b/payments/payment_gateways/doctype/mpesa_settings/mpesa_connector.py new file mode 100644 index 00000000..7eb8b9c0 --- /dev/null +++ b/payments/payment_gateways/doctype/mpesa_settings/mpesa_connector.py @@ -0,0 +1,149 @@ +import base64 +import datetime + +import requests +from requests.auth import HTTPBasicAuth + + +class MpesaConnector: + def __init__( + self, + env="sandbox", + app_key=None, + app_secret=None, + sandbox_url="https://sandbox.safaricom.co.ke", + live_url="https://api.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 = f"{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": f"Bearer {self.authentication_token}", + "Content-Type": "application/json", + } + saf_url = "{}{}".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 = f"{str(business_shortcode)}{str(passcode)}{time}" + encoded = base64.b64encode(bytes(password, encoding="utf8")) + payload = { + "BusinessShortCode": business_shortcode, + "Password": encoded.decode("utf-8"), + "Timestamp": time, + "Amount": amount, + "PartyA": int(phone_number), + "PartyB": reference_code, + "PhoneNumber": int(phone_number), + "CallBackURL": callback_url, + "AccountReference": reference_code, + "TransactionDesc": description, + "TransactionType": "CustomerPayBillOnline" + if self.env == "sandbox" + else "CustomerBuyGoodsOnline", + } + headers = { + "Authorization": f"Bearer {self.authentication_token}", + "Content-Type": "application/json", + } + + saf_url = "{}{}".format(self.base_url, "/mpesa/stkpush/v1/processrequest") + r = requests.post(saf_url, headers=headers, json=payload) + return r.json() diff --git a/payments/payment_gateways/doctype/mpesa_settings/mpesa_custom_fields.py b/payments/payment_gateways/doctype/mpesa_settings/mpesa_custom_fields.py new file mode 100644 index 00000000..c92edc5e --- /dev/null +++ b/payments/payment_gateways/doctype/mpesa_settings/mpesa_custom_fields.py @@ -0,0 +1,56 @@ +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() diff --git a/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.js b/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.js new file mode 100644 index 00000000..9d625736 --- /dev/null +++ b/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.js @@ -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(); + frm.events.setup_account_balance_html(frm); + }); + }, + + get_account_balance: function(frm) { + if (!frm.doc.initiator_name && !frm.doc.security_credential) { + 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(); + } +}); diff --git a/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.json b/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.json new file mode 100644 index 00000000..2afecd88 --- /dev/null +++ b/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.json @@ -0,0 +1,154 @@ +{ + "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", + "transaction_limit", + "sandbox", + "column_break_4", + "business_shortcode", + "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" + }, + { + "depends_on": "eval:(doc.sandbox==0)", + "fieldname": "business_shortcode", + "fieldtype": "Data", + "label": "Business Shortcode", + "mandatory_depends_on": "eval:(doc.sandbox==0)" + }, + { + "default": "150000", + "fieldname": "transaction_limit", + "fieldtype": "Float", + "label": "Transaction Limit", + "non_negative": 1 + } + ], + "links": [], + "modified": "2023-09-25 10:55:33.879470", + "modified_by": "Administrator", + "module": "Payment Gateways", + "name": "Mpesa Settings", + "naming_rule": "By fieldname", + "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", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.py b/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.py new file mode 100644 index 00000000..34265889 --- /dev/null +++ b/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.py @@ -0,0 +1,384 @@ +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + + +from json import dumps, loads + +import frappe +from frappe import _ +from frappe.integrations.utils import create_request_log +from frappe.model.document import Document +from frappe.utils import call_hook_method, fmt_money, get_request_site_address + +from payments.payment_gateways.doctype.mpesa_settings.mpesa_connector import MpesaConnector +from payments.payment_gateways.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): + from payments.utils import create_payment_gateway + + if "erpnext" in frappe.get_installed_apps(): + 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() # nosemgrep + create_mode_of_payment("Mpesa-" + self.payment_gateway_name, payment_type="Phone") + + def request_for_payment(self, **kwargs): + args = frappe._dict(kwargs) + request_amounts = self.split_request_amount_according_to_transaction_limit(args) + + for i, amount in enumerate(request_amounts): + args.request_amount = amount + if frappe.flags.in_test: + from payments.payment_gateways.doctype.mpesa_settings.test_mpesa_settings import ( + get_payment_request_response_payload, + ) + + response = frappe._dict(get_payment_request_response_payload(amount)) + else: + response = frappe._dict(generate_stk_push(**args)) + + self.handle_api_response("CheckoutRequestID", args, response) + + def split_request_amount_according_to_transaction_limit(self, args): + request_amount = args.request_amount + if request_amount > self.transaction_limit: + # make multiple requests + request_amounts = [] + requests_to_be_made = frappe.utils.ceil( + request_amount / self.transaction_limit + ) # 480/150 = ceil(3.2) = 4 + for i in range(requests_to_be_made): + amount = self.transaction_limit + if i == requests_to_be_made - 1: + amount = request_amount - ( + self.transaction_limit * i + ) # for 4th request, 480 - (150 * 3) = 30 + request_amounts.append(amount) + else: + request_amounts = [request_amount] + + return request_amounts + + @frappe.whitelist() + 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 payments.payment_gateways.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 + + if not frappe.db.exists("Integration Request", req_name): + 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/payments.payment_gateways.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" + # for sandbox, business shortcode is same as till number + business_shortcode = ( + mpesa_settings.business_shortcode if env == "production" else mpesa_settings.till_number + ) + + 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=business_shortcode, + amount=args.request_amount, + passcode=mpesa_settings.get_password("online_passkey"), + callback_url=callback_url, + reference_code=mpesa_settings.till_number, + phone_number=mobile_number, + description="POS Payment", + ) + + return response + + except Exception: + frappe.log_error("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", "") + if not isinstance(checkout_id, str): + frappe.throw(_("Invalid Checkout Request ID")) + + integration_request = frappe.get_doc("Integration Request", checkout_id) + transaction_data = frappe._dict(loads(integration_request.data)) + total_paid = 0 # for multiple integration request made against a pos invoice + success = False # for reporting successfull callback to point of sale ui + + if transaction_response["ResultCode"] == 0: + if integration_request.reference_doctype and integration_request.reference_docname: + try: + item_response = transaction_response["CallbackMetadata"]["Item"] + amount = fetch_param_value(item_response, "Amount", "Name") + mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name") + pr = frappe.get_doc( + integration_request.reference_doctype, integration_request.reference_docname + ) + + mpesa_receipts, completed_payments = get_completed_integration_requests_info( + integration_request.reference_doctype, integration_request.reference_docname, checkout_id + ) + + total_paid = amount + sum(completed_payments) + mpesa_receipts = ", ".join(mpesa_receipts + [mpesa_receipt]) + + if total_paid >= pr.grand_total: + pr.run_method("on_payment_authorized", "Completed") + success = True + + frappe.db.set_value("POS Invoice", pr.reference_name, "mpesa_receipt_number", mpesa_receipts) + integration_request.handle_success(transaction_response) + except Exception: + integration_request.handle_failure(transaction_response) + frappe.log_error("Mpesa: Failed to verify transaction") + + else: + integration_request.handle_failure(transaction_response) + + frappe.publish_realtime( + event="process_phone_payment", + doctype="POS Invoice", + docname=transaction_data.payment_reference, + user=integration_request.owner, + message={ + "amount": total_paid, + "success": success, + "failure_message": transaction_response["ResultDesc"] + if transaction_response["ResultCode"] != 0 + else "", + }, + ) + + +def get_completed_integration_requests_info(reference_doctype, reference_docname, checkout_id): + output_of_other_completed_requests = frappe.get_all( + "Integration Request", + filters={ + "name": ["!=", checkout_id], + "reference_doctype": reference_doctype, + "reference_docname": reference_docname, + "status": "Completed", + }, + pluck="output", + ) + + mpesa_receipts, completed_payments = [], [] + + for out in output_of_other_completed_requests: + out = frappe._dict(loads(out)) + item_response = out["CallbackMetadata"]["Item"] + completed_amount = fetch_param_value(item_response, "Amount", "Name") + completed_mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name") + completed_payments.append(completed_amount) + mpesa_receipts.append(completed_mpesa_receipt) + + return mpesa_receipts, completed_payments + + +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/payments.payment_gateways.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("Mpesa: Failed to get account balance") + frappe.throw(_("Please check your configuration and try again"), title=_("Error")) + + +@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", "") + if not isinstance(conversation_id, str): + frappe.throw(_("Invalid Conversation ID")) + + 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", + doctype="Mpesa Settings", + docname=transaction_data.reference_docname, + user=transaction_data.owner, + ) + 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"] + + +def create_mode_of_payment(gateway, payment_type="General"): + from erpnext import get_default_company + + payment_gateway_account = frappe.db.get_value( + "Payment Gateway Account", {"payment_gateway": gateway}, ["payment_account"] + ) + + mode_of_payment = frappe.db.exists("Mode of Payment", gateway) + if not mode_of_payment 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) + + return mode_of_payment + elif mode_of_payment: + return frappe.get_doc("Mode of Payment", mode_of_payment) diff --git a/payments/payment_gateways/doctype/mpesa_settings/test_mpesa_settings.py b/payments/payment_gateways/doctype/mpesa_settings/test_mpesa_settings.py new file mode 100644 index 00000000..aab37d93 --- /dev/null +++ b/payments/payment_gateways/doctype/mpesa_settings/test_mpesa_settings.py @@ -0,0 +1,415 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import unittest +from json import dumps + +import frappe + +from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_customer +from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile + +from payments.payment_gateways.doctype.mpesa_settings.mpesa_settings import ( + process_balance_info, + verify_transaction, +) +from payments.payment_gateways.doctype.mpesa_settings.mpesa_settings import create_mode_of_payment + + +class TestMpesaSettings(unittest.TestCase): + def setUp(self): + # create payment gateway in setup + create_mpesa_settings(payment_gateway_name="_Test") + create_mpesa_settings(payment_gateway_name="_Account Balance") + create_mpesa_settings(payment_gateway_name="Payment") + + self.customer = create_customer("_Test Customer", "USD") + self.item = make_item(properties={"is_stock_item": 1}).name + self.pos_profile = make_pos_profile( + company="Wind Power LLC", + cost_center="Main - WP", + currency="USD", + expense_account="Cost of Goods Sold - WP", + income_account="Sales - WP", + selling_price_list="Standard Selling", + territory="United States", + warehouse="Stores - WP", + write_off_account="Write Off - WP", + write_off_cost_center="Main - WP", + ).name + + def tearDown(self): + frappe.db.sql("delete from `tabMpesa Settings`") + frappe.db.sql("delete from `tabIntegration Request` where integration_request_service = 'Mpesa'") + + def test_creation_of_payment_gateway(self): + mode_of_payment = create_mode_of_payment("Mpesa-_Test", payment_type="Phone") + self.assertTrue(frappe.db.exists("Payment Gateway Account", {"payment_gateway": "Mpesa-_Test"})) + self.assertTrue(mode_of_payment.name) + self.assertEqual(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.assertEqual(integration_request.status, "Completed") + + # test formatting of account balance received as string to json with appropriate currency symbol + mpesa_doc.reload() + self.assertEqual( + 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", + } + } + ), + ) + + integration_request.delete() + + def test_processing_of_callback_payload(self): + 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") + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "KES") + pos_invoice = create_pos_invoice( + item=self.item, + customer=self.customer, + debit_to="Debtors - WP", + warehouse="Stores - WP", + cost_center="Main - WP", + company="Wind Power LLC", + income_account="Sales - WP", + pos_profile=self.pos_profile, + account_for_change_amount="Cash - WP", + expense_account="Cost of Goods Sold - WP", + 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.assertEqual(pr.payment_gateway, "Mpesa-Payment") + + # submitting payment request creates integration requests with random id + integration_req_ids = frappe.get_all( + "Integration Request", + filters={ + "reference_doctype": pr.doctype, + "reference_docname": pr.name, + }, + pluck="name", + ) + + callback_response = get_payment_callback_payload( + Amount=500, CheckoutRequestID=integration_req_ids[0] + ) + verify_transaction(**callback_response) + # test creation of integration request + integration_request = frappe.get_doc("Integration Request", integration_req_ids[0]) + + # test integration request creation and successful update of the status on receiving callback response + self.assertTrue(integration_request) + self.assertEqual(integration_request.status, "Completed") + + pos_invoice.reload() + integration_request.reload() + self.assertEqual(pos_invoice.mpesa_receipt_number, "LGR7OWQX0R") + self.assertEqual(integration_request.status, "Completed") + + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "") + integration_request.delete() + pr.reload() + pr.cancel() + pr.delete() + pos_invoice.delete() + + def test_processing_of_multiple_callback_payload(self): + 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") + frappe.db.set_value("Mpesa Settings", "Payment", "transaction_limit", "500") + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "KES") + + pos_invoice = create_pos_invoice( + item=self.item, + customer=self.customer, + debit_to="Debtors - WP", + warehouse="Stores - WP", + cost_center="Main - WP", + company="Wind Power LLC", + income_account="Sales - WP", + pos_profile=self.pos_profile, + account_for_change_amount="Cash - WP", + expense_account="Cost of Goods Sold - WP", + do_not_submit=1, + ) + pos_invoice.append( + "payments", {"mode_of_payment": "Mpesa-Payment", "account": mpesa_account, "amount": 1000} + ) + pos_invoice.contact_mobile = "093456543894" + pos_invoice.currency = "KES" + pos_invoice.save() + + pr = pos_invoice.create_payment_request() + # test payment request creation + self.assertEqual(pr.payment_gateway, "Mpesa-Payment") + + # submitting payment request creates integration requests with random id + integration_req_ids = frappe.get_all( + "Integration Request", + filters={ + "reference_doctype": pr.doctype, + "reference_docname": pr.name, + }, + pluck="name", + ) + + # create random receipt nos and send it as response to callback handler + mpesa_receipt_numbers = [frappe.utils.random_string(5) for d in integration_req_ids] + + integration_requests = [] + for i in range(len(integration_req_ids)): + callback_response = get_payment_callback_payload( + Amount=500, + CheckoutRequestID=integration_req_ids[i], + MpesaReceiptNumber=mpesa_receipt_numbers[i], + ) + # handle response manually + verify_transaction(**callback_response) + # test completion of integration request + integration_request = frappe.get_doc("Integration Request", integration_req_ids[i]) + self.assertEqual(integration_request.status, "Completed") + integration_requests.append(integration_request) + + # check receipt number once all the integration requests are completed + pos_invoice.reload() + self.assertEqual(pos_invoice.mpesa_receipt_number, ", ".join(mpesa_receipt_numbers)) + + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "") + [d.delete() for d in integration_requests] + pr.reload() + pr.cancel() + pr.delete() + pos_invoice.delete() + + def test_processing_of_only_one_succes_callback_payload(self): + 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") + frappe.db.set_value("Mpesa Settings", "Payment", "transaction_limit", "500") + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "KES") + + pos_invoice = create_pos_invoice( + item=self.item, + customer=self.customer, + debit_to="Debtors - WP", + warehouse="Stores - WP", + cost_center="Main - WP", + company="Wind Power LLC", + income_account="Sales - WP", + pos_profile=self.pos_profile, + account_for_change_amount="Cash - WP", + expense_account="Cost of Goods Sold - WP", + do_not_submit=1, + ) + pos_invoice.append( + "payments", {"mode_of_payment": "Mpesa-Payment", "account": mpesa_account, "amount": 1000} + ) + pos_invoice.contact_mobile = "093456543894" + pos_invoice.currency = "KES" + pos_invoice.save() + + pr = pos_invoice.create_payment_request() + # test payment request creation + self.assertEqual(pr.payment_gateway, "Mpesa-Payment") + + # submitting payment request creates integration requests with random id + integration_req_ids = frappe.get_all( + "Integration Request", + filters={ + "reference_doctype": pr.doctype, + "reference_docname": pr.name, + }, + pluck="name", + ) + + # create random receipt nos and send it as response to callback handler + mpesa_receipt_numbers = [frappe.utils.random_string(5) for d in integration_req_ids] + + callback_response = get_payment_callback_payload( + Amount=500, + CheckoutRequestID=integration_req_ids[0], + MpesaReceiptNumber=mpesa_receipt_numbers[0], + ) + # handle response manually + verify_transaction(**callback_response) + # test completion of integration request + integration_request = frappe.get_doc("Integration Request", integration_req_ids[0]) + self.assertEqual(integration_request.status, "Completed") + + # now one request is completed + # second integration request fails + # now retrying payment request should make only one integration request again + pr = pos_invoice.create_payment_request() + new_integration_req_ids = frappe.get_all( + "Integration Request", + filters={ + "reference_doctype": pr.doctype, + "reference_docname": pr.name, + "name": ["not in", integration_req_ids], + }, + pluck="name", + ) + + self.assertEqual(len(new_integration_req_ids), 1) + + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "") + frappe.db.sql("delete from `tabIntegration Request` where integration_request_service = 'Mpesa'") + pr.reload() + pr.cancel() + pr.delete() + pos_invoice.delete() + + +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", + sandbox=1, + 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(Amount=500): + """Response received after successfully calling the stk push process request API.""" + + CheckoutRequestID = frappe.utils.random_string(10) + + return { + "MerchantRequestID": "8071-27184008-1", + "CheckoutRequestID": CheckoutRequestID, + "ResultCode": 0, + "ResultDesc": "The service request is processed successfully.", + "CallbackMetadata": { + "Item": [ + {"Name": "Amount", "Value": Amount}, + {"Name": "MpesaReceiptNumber", "Value": "LGR7OWQX0R"}, + {"Name": "TransactionDate", "Value": 20201006113336}, + {"Name": "PhoneNumber", "Value": 254723575670}, + ] + }, + } + + +def get_payment_callback_payload( + Amount=500, CheckoutRequestID="ws_CO_061020201133231972", MpesaReceiptNumber="LGR7OWQX0R" +): + """Response received from the server as callback after calling the stkpush process request API.""" + return { + "Body": { + "stkCallback": { + "MerchantRequestID": "19465-780693-1", + "CheckoutRequestID": CheckoutRequestID, + "ResultCode": 0, + "ResultDesc": "The service request is processed successfully.", + "CallbackMetadata": { + "Item": [ + {"Name": "Amount", "Value": Amount}, + {"Name": "MpesaReceiptNumber", "Value": MpesaReceiptNumber}, + {"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", + } + }, + } + } From ac2c74929ec549dd16916e4ffc4a7f92105fc6bc Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 25 Sep 2023 11:09:11 +0530 Subject: [PATCH 05/12] refactor: get `stripe_integration.py` from ERPNext --- .../payment_gateways/stripe_integration.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 payments/payment_gateways/stripe_integration.py diff --git a/payments/payment_gateways/stripe_integration.py b/payments/payment_gateways/stripe_integration.py new file mode 100644 index 00000000..35c63c55 --- /dev/null +++ b/payments/payment_gateways/stripe_integration.py @@ -0,0 +1,63 @@ +# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import stripe +import frappe +from frappe import _ +from frappe.integrations.utils import create_request_log + + +def create_stripe_subscription(gateway_controller, data): + stripe_settings = frappe.get_doc("Stripe Settings", gateway_controller) + stripe_settings.data = frappe._dict(data) + + stripe.api_key = stripe_settings.get_password(fieldname="secret_key", raise_exception=False) + stripe.default_http_client = stripe.http_client.RequestsClient() + + try: + stripe_settings.integration_request = create_request_log(stripe_settings.data, "Host", "Stripe") + stripe_settings.payment_plans = frappe.get_doc( + "Payment Request", stripe_settings.data.reference_docname + ).subscription_plans + return create_subscription_on_stripe(stripe_settings) + + except Exception: + stripe_settings.log_error("Unable to create Stripe subscription") + return { + "redirect_to": frappe.redirect_to_message( + _("Server Error"), + _( + "It seems that there is an issue with the server's stripe configuration. In case of failure, the amount will get refunded to your account." + ), + ), + "status": 401, + } + + +def create_subscription_on_stripe(stripe_settings): + items = [] + for payment_plan in stripe_settings.payment_plans: + plan = frappe.db.get_value("Subscription Plan", payment_plan.plan, "product_price_id") + items.append({"price": plan, "quantity": payment_plan.qty}) + + try: + customer = stripe.Customer.create( + source=stripe_settings.data.stripe_token_id, + description=stripe_settings.data.payer_name, + email=stripe_settings.data.payer_email, + ) + + subscription = stripe.Subscription.create(customer=customer, items=items) + + if subscription.status == "active": + stripe_settings.integration_request.db_set("status", "Completed", update_modified=False) + stripe_settings.flags.status_changed_to = "Completed" + + else: + stripe_settings.integration_request.db_set("status", "Failed", update_modified=False) + frappe.log_error(f"Stripe Subscription ID {subscription.id}: Payment failed") + except Exception: + stripe_settings.integration_request.db_set("status", "Failed", update_modified=False) + stripe_settings.log_error("Unable to create Stripe subscription") + + return stripe_settings.finalize_request() From cf47b0964eece5f5a92ad617e226f48f208e7aee Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 27 Sep 2023 10:45:49 +0530 Subject: [PATCH 06/12] refactor: make `Customer` a custom field for `GoCardless Mandate` --- .../gocardless_mandate/gocardless_mandate.json | 11 +---------- payments/utils/utils.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.json b/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.json index afe9a253..8597253d 100644 --- a/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.json +++ b/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.json @@ -7,7 +7,6 @@ "engine": "InnoDB", "field_order": [ "disabled", - "customer", "mandate", "gocardless_customer" ], @@ -18,14 +17,6 @@ "fieldtype": "Check", "label": "Disabled" }, - { - "fieldname": "customer", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Customer", - "options": "Customer", - "reqd": 1 - }, { "fieldname": "mandate", "fieldtype": "Data", @@ -44,7 +35,7 @@ } ], "links": [], - "modified": "2023-09-25 10:54:28.956136", + "modified": "2023-09-27 10:33:44.453462", "modified_by": "Administrator", "module": "Payment Gateways", "name": "GoCardless Mandate", diff --git a/payments/utils/utils.py b/payments/utils/utils.py index 20c6bd1e..930f243d 100644 --- a/payments/utils/utils.py +++ b/payments/utils/utils.py @@ -136,6 +136,23 @@ def make_custom_fields(): frappe.clear_cache(doctype="Web Form") + if "erpnext" in frappe.get_installed_apps(): + custom_fields = { + "GoCardless Mandate": [ + { + "fieldname": "customer", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Customer", + "options": "Customer", + "reqd": 1, + "insert_after": "disabled", + } + ] + } + + create_custom_fields(custom_fields) + def delete_custom_fields(): if frappe.get_meta("Web Form").has_field("payments_tab"): From da03fa380ac3c7a1b461f9ecf921551f552d3527 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 27 Sep 2023 11:03:15 +0530 Subject: [PATCH 07/12] refactor: add import guard for erpnext --- .../doctype/mpesa_settings/mpesa_settings.py | 4 +++- payments/utils/__init__.py | 1 + payments/utils/utils.py | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.py b/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.py index 34265889..43d5348b 100644 --- a/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.py +++ b/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.py @@ -14,6 +14,7 @@ from payments.payment_gateways.doctype.mpesa_settings.mpesa_custom_fields import ( create_custom_pos_fields, ) +from payments.utils import erpnext_app_import_guard class MpesaSettings(Document): @@ -354,7 +355,8 @@ def fetch_param_value(response, key, key_field): def create_mode_of_payment(gateway, payment_type="General"): - from erpnext import get_default_company + with erpnext_app_import_guard(): + from erpnext import get_default_company payment_gateway_account = frappe.db.get_value( "Payment Gateway Account", {"payment_gateway": gateway}, ["payment_account"] diff --git a/payments/utils/__init__.py b/payments/utils/__init__.py index 54a5c2e8..fb540bd5 100644 --- a/payments/utils/__init__.py +++ b/payments/utils/__init__.py @@ -4,4 +4,5 @@ delete_custom_fields, get_payment_gateway_controller, make_custom_fields, + erpnext_app_import_guard, ) diff --git a/payments/utils/utils.py b/payments/utils/utils.py index 930f243d..e56a89c2 100644 --- a/payments/utils/utils.py +++ b/payments/utils/utils.py @@ -1,6 +1,7 @@ import click import frappe from frappe import _ +from contextlib import contextmanager from frappe.custom.doctype.custom_field.custom_field import create_custom_fields @@ -188,3 +189,16 @@ def before_install(): # a lot of apis don;t exist in v10 and this is a (at the moment) required app for erpnext. if not frappe.get_meta("Module Def").has_field("custom"): return False + + +@contextmanager +def erpnext_app_import_guard(): + marketplace_link = 'Marketplace' + github_link = 'GitHub' + msg = _("erpnext app is not installed. Please install it from {} or {}").format( + marketplace_link, github_link + ) + try: + yield + except ImportError: + frappe.throw(msg, title=_("Missing ERPNext App")) From f9cd2828ec1b167d976ad12a89e46dbe048e2f23 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 27 Sep 2023 12:43:21 +0530 Subject: [PATCH 08/12] ci: install erpnext for tests --- .github/workflows/ci.yml | 3 ++- payments/hooks.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b1178dd..392ddf44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,11 +73,12 @@ jobs: - name: Install working-directory: /home/runner/frappe-bench run: | + bench get-app https://github.com/frappe/erpnext --branch "develop" --resolve-deps bench get-app payments $GITHUB_WORKSPACE bench setup requirements --dev bench new-site --db-root-password root --admin-password admin test_site bench start &> bench_start.log & - bench --site test_site install-app payments + bench --site test_site install-app erpnext payments bench build env: CI: 'Yes' diff --git a/payments/hooks.py b/payments/hooks.py index 4ad29caf..caa07d27 100644 --- a/payments/hooks.py +++ b/payments/hooks.py @@ -120,7 +120,7 @@ # Testing # ------- -# before_tests = "pay.install.before_tests" +before_tests = "erpnext.setup.utils.before_tests" # To setup company and accounts # Overriding Methods # ------------------------------ From 30e5debbf043935f66d4da6ecfe3a973b812c9e5 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 18 Oct 2023 12:19:33 +0530 Subject: [PATCH 09/12] chore: loosen pycryptodome and install mariadb --- .github/workflows/ci.yml | 3 +++ pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 392ddf44..f99827fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,9 @@ jobs: - name: Clone uses: actions/checkout@v2 + - name: Install MariaDB Client + run: sudo apt-get -y install mariadb-client-10.6 + - name: Setup Python uses: actions/setup-python@v2 with: diff --git a/pyproject.toml b/pyproject.toml index ecf83242..1dbe5220 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "razorpay~=1.2.0", "stripe~=2.56.0", "braintree~=4.20.0", - "pycryptodome~=3.18.0", + "pycryptodome>=3.18.0,<4.0.0", "gocardless-pro~=1.22.0", ] From 7d71eb4fdab168cab1f8ae0c484efafba6d6c251 Mon Sep 17 00:00:00 2001 From: Saullo Bretas Silva Date: Sun, 5 Nov 2023 17:08:55 -0300 Subject: [PATCH 10/12] fix: update stripe redirect URL to include reference doctype and docname --- .../doctype/stripe_settings/stripe_settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py index 0547a528..32adab85 100644 --- a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py +++ b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py @@ -255,7 +255,9 @@ def finalize_request(self): if custom_redirect_to: redirect_to = custom_redirect_to - redirect_url = "payment-success" + redirect_url = "payment-success?doctype={}&docname={}".format( + self.data.reference_doctype, self.data.reference_docname + ) if self.redirect_url: redirect_url = self.redirect_url From f009002c3adeeaa71852a479ed6a17f33f88456e Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 23 Jan 2024 16:48:50 +0530 Subject: [PATCH 11/12] fix: payment sucess redirect error --- .../doctype/stripe_settings/stripe_settings.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py index 32adab85..816b77e9 100644 --- a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py +++ b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py @@ -265,8 +265,11 @@ def finalize_request(self): else: redirect_url = "payment-failed" - if redirect_to: + if redirect_to and "?" in redirect_url: + redirect_url += "&" + urlencode({"redirect_to": redirect_to}) + else: redirect_url += "?" + urlencode({"redirect_to": redirect_to}) + if redirect_message: redirect_url += "&" + urlencode({"redirect_message": redirect_message}) From 45c63c4b747c7c1d49813cddf30aaae66db29f98 Mon Sep 17 00:00:00 2001 From: Kevin Shenk Date: Tue, 5 Mar 2024 15:11:49 -0500 Subject: [PATCH 12/12] fix: typo in GoCardless Settings name --- .../doctype/gocardless_settings/gocardless_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.py b/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.py index e95781c6..ac6b83a9 100644 --- a/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.py +++ b/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.py @@ -32,7 +32,7 @@ def on_update(self): from payments.utils import create_payment_gateway create_payment_gateway( - "GoCardless-" + self.gateway_name, settings="GoCardLess Settings", controller=self.gateway_name + "GoCardless-" + self.gateway_name, settings="GoCardless Settings", controller=self.gateway_name ) call_hook_method("payment_gateway_enabled", gateway="GoCardless-" + self.gateway_name)