diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 57ead282ad..2692f3ce47 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -34,7 +34,7 @@ class PaymentRequest(Document): frappe.throw(_("Transaction currency must be same as Payment Gateway currency")) def on_submit(self): - send_mail = True + send_mail = self.payment_gateway_validation() 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") \ @@ -58,6 +58,16 @@ class PaymentRequest(Document): si = si.insert(ignore_permissions=True) si.submit() + def payment_gateway_validation(self): + try: + controller = get_payment_gateway_controller(self.payment_gateway) + if hasattr(controller, 'on_payment_request_submission'): + return controller.on_payment_request_submission(self) + else: + return True + except Exception: + return False + def set_payment_request_url(self): if self.payment_account: self.payment_url = self.get_payment_url() diff --git a/erpnext/config/erpnext_integrations.py b/erpnext/config/erpnext_integrations.py new file mode 100644 index 0000000000..22a01a6ada --- /dev/null +++ b/erpnext/config/erpnext_integrations.py @@ -0,0 +1,22 @@ +from __future__ import unicode_literals +from frappe import _ + +def get_data(): + return [ + { + "label": _("Payments"), + "icon": "fa fa-star", + "items": [ + { + "type": "doctype", + "name": "GoCardless Settings", + "description": _("GoCardless payment gateway settings"), + }, + { + "type": "doctype", + "name": "GoCardless Mandate", + "description": _("GoCardless SEPA Mandate"), + } + ] + } + ] diff --git a/erpnext/docs/assets/img/setup/integrations/gocardless_account.png b/erpnext/docs/assets/img/setup/integrations/gocardless_account.png new file mode 100644 index 0000000000..db6bcc94fb Binary files /dev/null and b/erpnext/docs/assets/img/setup/integrations/gocardless_account.png differ diff --git a/erpnext/docs/assets/img/setup/integrations/gocardless_coa.png b/erpnext/docs/assets/img/setup/integrations/gocardless_coa.png new file mode 100644 index 0000000000..17b5f59964 Binary files /dev/null and b/erpnext/docs/assets/img/setup/integrations/gocardless_coa.png differ diff --git a/erpnext/docs/assets/img/setup/integrations/payment_gateway_account_gocardless.png b/erpnext/docs/assets/img/setup/integrations/payment_gateway_account_gocardless.png new file mode 100644 index 0000000000..2c08e9f060 Binary files /dev/null and b/erpnext/docs/assets/img/setup/integrations/payment_gateway_account_gocardless.png differ diff --git a/erpnext/docs/user/manual/en/setting-up/integrations/gocardless-integration.md b/erpnext/docs/user/manual/en/setting-up/integrations/gocardless-integration.md new file mode 100644 index 0000000000..29ede4a001 --- /dev/null +++ b/erpnext/docs/user/manual/en/setting-up/integrations/gocardless-integration.md @@ -0,0 +1,44 @@ +# Setting up GoCardless + +To setup GoCardless, go to `Explore > Integrations > GoCardless Settings` + +## Setup GoCardless + +To enable GoCardless in your ERPNext account, you need to configure the following parameters and Access Token and optionally (but highly recommended), a Webhooks Secret key. + + +You can setup several GoCardless payment gateways if needed. The choice of payment gateway account will determine which GoCardless account is used for the payment. + +![GoCardless Settings](/docs/assets/img/setup/integrations/gocardless_account.png) + +On enabling service, the system will create a Payment Gateway record and an Account head in chart of account with account type as Bank. + +![GoCardless COA](/docs/assets/img/setup/integrations/gocardless_coa.png) + +It will also create a payment gateway account. You can change the default bank account if needed and create a template for the payment request. + +![Payment Gateway Account](/docs/assets/img/setup/integrations/payment_gateway_account_gocardless.png) + +After configuring the Payment Gateway Account, your system is able to accept online payments through GoCardless. + +## SEPA Payments Flow + +When a new payment SEPA payment in initiated, the customer is asked to enter his IBAN (or local account number) and to validate a SEPA mandate. + +Upon validation of the mandate, a payment request is sent to GoCardless and processed. + +If the customer has already a valid SEPA mandate, when instead of sending a payment request to the customer, the payment request is directly sent to GoCardless without the need for the customer to validate it. +The customer will only receive a confirmation email from GoCardless informing him that a payment has been processed. + + +## Mandate cancellation + +You can setup a Webhook in GoCardless to automatically disabled cancelled or expired mandates in ERPNext. + +The Endpoint URL of your webhook should be: https://yoursite.com/api/method/erpnext.erpnext_integrations.doctype.gocardless_settings.webhooks + +In this case do not forget to configure your Webhooks Secret Key in your GoCardless account settings in ERPNext. + + +## Supported transaction currencies + "EUR", "DKK", "GBP", "SEK" diff --git a/erpnext/erpnext_integrations/doctype/__init__.py b/erpnext/erpnext_integrations/doctype/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/erpnext_integrations/doctype/gocardless_mandate/__init__.py b/erpnext/erpnext_integrations/doctype/gocardless_mandate/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/erpnext_integrations/doctype/gocardless_mandate/gocardless_mandate.js b/erpnext/erpnext_integrations/doctype/gocardless_mandate/gocardless_mandate.js new file mode 100644 index 0000000000..37f9f7b9df --- /dev/null +++ b/erpnext/erpnext_integrations/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/erpnext/erpnext_integrations/doctype/gocardless_mandate/gocardless_mandate.json b/erpnext/erpnext_integrations/doctype/gocardless_mandate/gocardless_mandate.json new file mode 100644 index 0000000000..edf652c8f3 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/gocardless_mandate/gocardless_mandate.json @@ -0,0 +1,184 @@ +{ + "allow_copy": 0, + "allow_guest_to_view": 0, + "allow_import": 0, + "allow_rename": 0, + "autoname": "field:mandate", + "beta": 0, + "creation": "2018-02-08 11:33:15.721919", + "custom": 0, + "docstatus": 0, + "doctype": "DocType", + "document_type": "", + "editable_grid": 1, + "engine": "InnoDB", + "fields": [ + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "disabled", + "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": "Disabled", + "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, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "customer", + "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": "Customer", + "length": 0, + "no_copy": 0, + "options": "Customer", + "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, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "mandate", + "fieldtype": "Data", + "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": "Mandate", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 1, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "gocardless_customer", + "fieldtype": "Data", + "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": "GoCardless Customer", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 1, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + } + ], + "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-02-11 12:28:03.183095", + "modified_by": "Administrator", + "module": "ERPNext Integrations", + "name": "GoCardless Mandate", + "name_case": "", + "owner": "Administrator", + "permissions": [ + { + "amend": 0, + "apply_user_permissions": 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": "System Manager", + "set_user_permissions": 0, + "share": 1, + "submit": 0, + "write": 1 + } + ], + "quick_entry": 1, + "read_only": 0, + "read_only_onload": 0, + "show_name_in_global_search": 0, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "track_seen": 0 +} diff --git a/erpnext/erpnext_integrations/doctype/gocardless_mandate/gocardless_mandate.py b/erpnext/erpnext_integrations/doctype/gocardless_mandate/gocardless_mandate.py new file mode 100644 index 0000000000..9c9df65314 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/gocardless_mandate/gocardless_mandate.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +from frappe.model.document import Document + +class GoCardlessMandate(Document): + pass diff --git a/erpnext/erpnext_integrations/doctype/gocardless_mandate/test_gocardless_mandate.js b/erpnext/erpnext_integrations/doctype/gocardless_mandate/test_gocardless_mandate.js new file mode 100644 index 0000000000..caa9399eb6 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/gocardless_mandate/test_gocardless_mandate.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +// rename this file from _test_[name] to test_[name] to activate +// and remove above this line + +QUnit.test("test: GoCardless Mandate", function (assert) { + let done = assert.async(); + + // number of asserts + assert.expect(1); + + frappe.run_serially([ + // insert a new GoCardless Mandate + () => frappe.tests.make('GoCardless Mandate', [ + // values to be set + {key: 'value'} + ]), + () => { + assert.equal(cur_frm.doc.key, 'value'); + }, + () => done() + ]); + +}); diff --git a/erpnext/erpnext_integrations/doctype/gocardless_mandate/test_gocardless_mandate.py b/erpnext/erpnext_integrations/doctype/gocardless_mandate/test_gocardless_mandate.py new file mode 100644 index 0000000000..d77a352cdb --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/gocardless_mandate/test_gocardless_mandate.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +import unittest + +class TestGoCardlessMandate(unittest.TestCase): + pass diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/__init__.py b/erpnext/erpnext_integrations/doctype/gocardless_settings/__init__.py new file mode 100644 index 0000000000..25784a5620 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/__init__.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +import json +import hmac +import hashlib + +@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/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.js b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.js new file mode 100644 index 0000000000..b649d9d6cc --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.js @@ -0,0 +1,5 @@ +// Copyright (c) 2018, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('GoCardless Settings', { +}); diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.json b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.json new file mode 100644 index 0000000000..9738106a30 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.json @@ -0,0 +1,212 @@ +{ + "allow_copy": 0, + "allow_guest_to_view": 0, + "allow_import": 0, + "allow_rename": 0, + "autoname": "field:gateway_name", + "beta": 0, + "creation": "2018-02-06 16:11:10.028249", + "custom": 0, + "docstatus": 0, + "doctype": "DocType", + "document_type": "", + "editable_grid": 1, + "engine": "InnoDB", + "fields": [ + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "gateway_name", + "fieldtype": "Data", + "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 Name", + "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": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "section_break_2", + "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, + "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, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "access_token", + "fieldtype": "Data", + "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": "Access Token", + "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": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "webhooks_secret", + "fieldtype": "Data", + "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": "Webhooks Secret", + "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, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "use_sandbox", + "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": "Use Sandbox", + "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, + "unique": 0 + } + ], + "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-02-12 14:18:47.209114", + "modified_by": "Administrator", + "module": "ERPNext Integrations", + "name": "GoCardless Settings", + "name_case": "", + "owner": "Administrator", + "permissions": [ + { + "amend": 0, + "apply_user_permissions": 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": "System Manager", + "set_user_permissions": 0, + "share": 1, + "submit": 0, + "write": 1 + } + ], + "quick_entry": 1, + "read_only": 0, + "read_only_onload": 0, + "show_name_in_global_search": 0, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "track_seen": 0 +} diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py new file mode 100644 index 0000000000..af15cf58d0 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe.model.document import Document +import gocardless_pro +from frappe import _ +from six.moves.urllib.parse import urlencode +from frappe.utils import get_url, call_hook_method, flt, cint +from frappe.integrations.utils import create_request_log, create_payment_gateway + +class GoCardlessSettings(Document): + supported_currencies = ["EUR", "DKK", "GBP", "SEK"] + + 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): + 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. Stripe does not support transactions in currency '{0}'").format(currency)) + + def get_payment_url(self, **kwargs): + return get_url("./integrations/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(frappe.get_traceback()) + 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(_("Payment Cancelled. Please check your GoCardless Account for more details"), "GoCardless Payment Error") + 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(_("Payment Failed. Please check your GoCardless Account for more details"), "GoCardless Payment Error") + self.integration_request.db_set('error', payment.status, update_modified=False) + + except Exception as e: + frappe.log_error(e, "GoCardless Payment Error") + + if self.flags.status_changed_to == "Completed": + status = 'Completed' + if self.data.reference_doctype and self.data.reference_docname: + custom_redirect_to = None + try: + custom_redirect_to = frappe.get_doc(self.data.reference_doctype, + self.data.reference_docname).run_method("on_payment_authorized", self.flags.status_changed_to) + except Exception: + frappe.log_error(frappe.get_traceback()) + + 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/erpnext/erpnext_integrations/doctype/gocardless_settings/test_gocardless_settings.js b/erpnext/erpnext_integrations/doctype/gocardless_settings/test_gocardless_settings.js new file mode 100644 index 0000000000..b6daad8de4 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/test_gocardless_settings.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +// rename this file from _test_[name] to test_[name] to activate +// and remove above this line + +QUnit.test("test: GoCardless Settings", function (assert) { + let done = assert.async(); + + // number of asserts + assert.expect(1); + + frappe.run_serially([ + // insert a new GoCardless Settings + () => frappe.tests.make('GoCardless Settings', [ + // values to be set + {key: 'value'} + ]), + () => { + assert.equal(cur_frm.doc.key, 'value'); + }, + () => done() + ]); + +}); diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/test_gocardless_settings.py b/erpnext/erpnext_integrations/doctype/gocardless_settings/test_gocardless_settings.py new file mode 100644 index 0000000000..e377f3482c --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/test_gocardless_settings.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +import unittest + +class TestGoCardlessSettings(unittest.TestCase): + pass diff --git a/erpnext/templates/includes/integrations/gocardless_checkout.js b/erpnext/templates/includes/integrations/gocardless_checkout.js new file mode 100644 index 0000000000..b18d55090c --- /dev/null +++ b/erpnext/templates/includes/integrations/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: "erpnext.templates.pages.integrations.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/erpnext/templates/includes/integrations/gocardless_confirmation.js b/erpnext/templates/includes/integrations/gocardless_confirmation.js new file mode 100644 index 0000000000..fee1d2b632 --- /dev/null +++ b/erpnext/templates/includes/integrations/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: "erpnext.templates.pages.integrations.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/erpnext/templates/pages/integrations/__init__.py b/erpnext/templates/pages/integrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/templates/pages/integrations/gocardless_checkout.html b/erpnext/templates/pages/integrations/gocardless_checkout.html new file mode 100644 index 0000000000..eb124cafb6 --- /dev/null +++ b/erpnext/templates/pages/integrations/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/erpnext/templates/pages/integrations/gocardless_checkout.py b/erpnext/templates/pages/integrations/gocardless_checkout.py new file mode 100644 index 0000000000..3c2466ea2f --- /dev/null +++ b/erpnext/templates/pages/integrations/gocardless_checkout.py @@ -0,0 +1,76 @@ +# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.utils import flt +import json +from erpnext.erpnext_integrations.doctype.gocardless_settings.gocardless_settings import gocardless_initialization, get_gateway_controller +from frappe.utils import get_url + +no_cache = 1 +no_sitemap = 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, + "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("./integrations/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(e, "GoCardless Payment Error") + return {"redirect_to": '/integrations/payment-failed'} diff --git a/erpnext/templates/pages/integrations/gocardless_confirmation.html b/erpnext/templates/pages/integrations/gocardless_confirmation.html new file mode 100644 index 0000000000..1baf23be26 --- /dev/null +++ b/erpnext/templates/pages/integrations/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/erpnext/templates/pages/integrations/gocardless_confirmation.py b/erpnext/templates/pages/integrations/gocardless_confirmation.py new file mode 100644 index 0000000000..fc564c3df9 --- /dev/null +++ b/erpnext/templates/pages/integrations/gocardless_confirmation.py @@ -0,0 +1,85 @@ +# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt +from __future__ import unicode_literals +import frappe +from frappe import _ +from erpnext.erpnext_integrations.doctype.gocardless_settings.gocardless_settings import gocardless_initialization, get_gateway_controller + +no_cache = 1 +no_sitemap = 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 + }) + + data = { + "mandate": redirect_flow.links.mandate, + "customer": redirect_flow.links.customer, + "redirect_to": redirect_flow.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(e, "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": redirect_flow.confirmation_url} + + except Exception as e: + frappe.log_error(e, "GoCardless Payment Error") + return {"redirect_to": '/integrations/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(frappe.get_traceback()) diff --git a/requirements.txt b/requirements.txt index f21cd8fd28..c5641f564d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ pygithub googlemaps python-stdnum braintree +gocardless_pro