diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 00000000..1f225527 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,130 @@ +{ + "env": { + "browser": true, + "node": true, + "es2022": true + }, + "parserOptions": { + "sourceType": "module" + }, + "extends": "eslint:recommended", + "rules": { + "indent": "off", + "brace-style": "off", + "no-mixed-spaces-and-tabs": "off", + "no-useless-escape": "off", + "space-unary-ops": ["error", { "words": true }], + "linebreak-style": "off", + "quotes": ["off"], + "semi": "off", + "camelcase": "off", + "no-unused-vars": "off", + "no-console": ["warn"], + "no-extra-boolean-cast": ["off"], + "no-control-regex": ["off"] + }, + "root": true, + "globals": { + "frappe": true, + "Vue": true, + "SetVueGlobals": true, + "erpnext": true, + "hub": true, + "$": true, + "jQuery": true, + "moment": true, + "hljs": true, + "Awesomplete": true, + "CalHeatMap": true, + "Sortable": true, + "Showdown": true, + "Taggle": true, + "Gantt": true, + "Slick": true, + "PhotoSwipe": true, + "PhotoSwipeUI_Default": true, + "fluxify": true, + "io": true, + "c3": true, + "__": true, + "_p": true, + "_f": true, + "repl": true, + "Class": true, + "locals": true, + "cint": true, + "cstr": true, + "cur_frm": true, + "cur_dialog": true, + "cur_page": true, + "cur_list": true, + "cur_tree": true, + "cur_pos": true, + "msg_dialog": true, + "is_null": true, + "in_list": true, + "has_common": true, + "posthog": true, + "has_words": true, + "validate_email": true, + "open_web_template_values_editor": true, + "get_number_format": true, + "format_number": true, + "format_currency": true, + "round_based_on_smallest_currency_fraction": true, + "roundNumber": true, + "comment_when": true, + "replace_newlines": true, + "open_url_post": true, + "toTitle": true, + "lstrip": true, + "strip": true, + "strip_html": true, + "replace_all": true, + "flt": true, + "precision": true, + "md5": true, + "CREATE": true, + "AMEND": true, + "CANCEL": true, + "copy_dict": true, + "get_number_format_info": true, + "print_table": true, + "Layout": true, + "web_form_settings": true, + "$c": true, + "$a": true, + "$i": true, + "$bg": true, + "$y": true, + "$c_obj": true, + "$c_obj_csv": true, + "refresh_many": true, + "refresh_field": true, + "toggle_field": true, + "get_field_obj": true, + "get_query_params": true, + "unhide_field": true, + "hide_field": true, + "set_field_options": true, + "getCookie": true, + "getCookies": true, + "get_url_arg": true, + "get_server_fields": true, + "set_multiple": true, + "QUnit": true, + "Chart": true, + "Cypress": true, + "cy": true, + "describe": true, + "expect": true, + "it": true, + "context": true, + "before": true, + "beforeEach": true, + "onScan": true, + "extend_cscript": true, + "localforage": true, + "Plaid": true + } +} diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..56274db0 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,15 @@ +# Since version 2.23 (released in August 2019), git-blame has a feature +# to ignore or bypass certain commits. +# +# This file contains a list of commits that are not likely what you +# are looking for in a blame, such as mass reformatting or renaming. +# You can set this file as a default ignore file for blame by running +# the following command. +# +# $ git config blame.ignoreRevsFile .git-blame-ignore-revs + +# pre-commit formatting ruff, eslint, prettier (automated) +63a4acf6c9fe9657fa6d7ad659465b0d5ef3d73f + +# pre-commit formatting ruff, eslint, prettier (manual fixup) +cecf0bec9de2dcd176fc632e8a5348ab2f491cbe \ No newline at end of file diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 632e6069..3e386d6b 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -53,4 +53,4 @@ bench start &>> ~/frappe-bench/bench_start.log & CI=Yes bench build --app frappe & bench --site test_site reinstall --yes -bench --verbose --site test_site install-app payments \ No newline at end of file +bench --verbose --site test_site install-app payments diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..65bc68f2 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,4 @@ +# Any python files modifed but no test files modified +needs-tests: +- any: ['payments/**/*.py'] + all: ['!payments/**/test*.py'] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 832fce93..4d94a1f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,4 +137,4 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true - verbose: true \ No newline at end of file + verbose: true diff --git a/.github/workflows/labeller.yml b/.github/workflows/labeller.yml new file mode 100644 index 00000000..97fa4a1a --- /dev/null +++ b/.github/workflows/labeller.yml @@ -0,0 +1,12 @@ +name: "Pull Request Labeler" +on: + pull_request_target: + types: [opened, reopened] + +jobs: + triage: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v4 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.gitignore b/.gitignore index 175055b1..a4cd298d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ tags payments/docs/current node_modules/ __pycache__/ +.aider* +.helix \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bd9bf1af..2fb83911 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,24 +20,47 @@ repos: - id: check-yaml - id: debug-statements - - repo: https://github.com/asottile/pyupgrade - rev: v2.34.0 + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.7.1 hooks: - - id: pyupgrade - args: ['--py310-plus'] + - id: prettier + types_or: [javascript, vue, scss] + # Ignore any files that might contain jinja / bundles + exclude: | + (?x)^( + payments/public/dist/.*| + cypress/.*| + .*node_modules.*| + payments/templates/includes/.* + )$ - - repo: https://github.com/adityahase/black - rev: 9cb0a69f4d0030cdf687eddf314468b39ed54119 + - repo: https://github.com/pre-commit/mirrors-eslint + rev: v8.44.0 hooks: - - id: black - additional_dependencies: ['click==8.0.4'] + - id: eslint + types_or: [javascript] + args: ['--quiet'] + # Ignore any files that might contain jinja / bundles + exclude: | + (?x)^( + payments/public/dist/.*| + cypress/.*| + .*node_modules.*| + payments/templates/includes/.* + )$ - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.2.0 hooks: - - id: flake8 - additional_dependencies: ['flake8-bugbear',] - args: ['--config', '.github/helper/flake8.conf'] + - id: ruff + name: "Run ruff import sorter" + args: ["--select=I", "--fix"] + + - id: ruff + name: "Run ruff linter" + + - id: ruff-format + name: "Run ruff formatter" ci: autoupdate_schedule: weekly diff --git a/README.md b/README.md index 7772261f..cf0d0659 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ All general utils are stored in [utils](payments/utils) directory. The utils are [templates](payments/templates) directory has all the payment gateways' custom checkout pages. -# +## Ongoing Work +- New API design: https://github.com/frappe/payments/pull/53 +- Mollie Integration: https://github.com/frappe/payments/pull/68 (awaiting the former, but you may use the branc) ## License MIT ([license.txt](license.txt)) diff --git a/commitlint.config.js b/commitlint.config.js index 8847564e..bd5cebce 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1,25 +1,25 @@ module.exports = { - parserPreset: 'conventional-changelog-conventionalcommits', - rules: { - 'subject-empty': [2, 'never'], - 'type-case': [2, 'always', 'lower-case'], - 'type-empty': [2, 'never'], - 'type-enum': [ - 2, - 'always', - [ - 'build', - 'chore', - 'ci', - 'docs', - 'feat', - 'fix', - 'perf', - 'refactor', - 'revert', - 'style', - 'test', - ], - ], - }, + parserPreset: "conventional-changelog-conventionalcommits", + rules: { + "subject-empty": [2, "never"], + "type-case": [2, "always", "lower-case"], + "type-empty": [2, "never"], + "type-enum": [ + 2, + "always", + [ + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "style", + "test", + ], + ], + }, }; diff --git a/payments/hooks.py b/payments/hooks.py index caa07d27..c6039c77 100644 --- a/payments/hooks.py +++ b/payments/hooks.py @@ -92,9 +92,9 @@ # DocType Class # --------------- -# Override standard doctype classes +# Extend standard doctype classes -override_doctype_class = {"Web Form": "payments.overrides.payment_webform.PaymentWebForm"} +extend_doctype_class = {"Web Form": "payments.overrides.payment_webform.PaymentWebForm"} # Document Events # --------------- diff --git a/payments/payment_gateways/doctype/braintree_settings/braintree_settings.js b/payments/payment_gateways/doctype/braintree_settings/braintree_settings.js index c844022c..f9d77bf7 100644 --- a/payments/payment_gateways/doctype/braintree_settings/braintree_settings.js +++ b/payments/payment_gateways/doctype/braintree_settings/braintree_settings.js @@ -1,6 +1,4 @@ // Copyright (c) 2018, Frappe Technologies and contributors // For license information, please see license.txt -frappe.ui.form.on('Braintree Settings', { - -}); +frappe.ui.form.on("Braintree Settings", {}); diff --git a/payments/payment_gateways/doctype/braintree_settings/braintree_settings.py b/payments/payment_gateways/doctype/braintree_settings/braintree_settings.py index 17800436..43ec8f84 100644 --- a/payments/payment_gateways/doctype/braintree_settings/braintree_settings.py +++ b/payments/payment_gateways/doctype/braintree_settings/braintree_settings.py @@ -267,10 +267,7 @@ def create_charge_on_braintree(self): status = "Error" redirect_url = "payment-failed" - get_parameters = [ - ("doctype", self.data.reference_doctype), - ("docname", self.data.reference_docname), - ] + get_parameters = [("doctype", self.data.reference_doctype), ("docname", self.data.reference_docname)] if redirect_to: get_parameters.append(("redirect_to", redirect_to)) if redirect_message: @@ -282,9 +279,8 @@ def create_charge_on_braintree(self): def get_gateway_controller(doc): payment_request = frappe.get_doc("Payment Request", doc) - return frappe.db.get_value( - "Payment Gateway", payment_request.payment_gateway, "gateway_controller" - ) + return frappe.db.get_value("Payment Gateway", payment_request.payment_gateway, "gateway_controller") + def get_client_token(doc): diff --git a/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.js b/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.js index 37f9f7b9..9f8a22bb 100644 --- a/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.js +++ b/payments/payment_gateways/doctype/gocardless_mandate/gocardless_mandate.js @@ -1,5 +1,4 @@ // Copyright (c) 2018, Frappe Technologies and contributors // For license information, please see license.txt -frappe.ui.form.on('GoCardless Mandate', { -}); +frappe.ui.form.on("GoCardless Mandate", {}); diff --git a/payments/payment_gateways/doctype/gocardless_settings/__init__.py b/payments/payment_gateways/doctype/gocardless_settings/__init__.py index 65be5993..d8f8a3d3 100644 --- a/payments/payment_gateways/doctype/gocardless_settings/__init__.py +++ b/payments/payment_gateways/doctype/gocardless_settings/__init__.py @@ -34,7 +34,7 @@ def set_status(event): def set_mandate_status(event): mandates = [] - if isinstance(event["links"], (list,)): + if isinstance(event["links"], list): for link in event["links"]: mandates.append(link["mandate"]) else: diff --git a/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.js b/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.js index ef1b97f6..a4db118d 100644 --- a/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.js +++ b/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.js @@ -5,4 +5,4 @@ // refresh(frm) { // }, -// }); \ 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 index d732244f..3a9cd0fb 100644 --- a/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.py +++ b/payments/payment_gateways/doctype/gocardless_settings/gocardless_settings.py @@ -21,9 +21,7 @@ def validate(self): def initialize_client(self): self.environment = self.get_environment() try: - self.client = gocardless_pro.Client( - access_token=self.access_token, environment=self.environment - ) + self.client = gocardless_pro.Client(access_token=self.access_token, environment=self.environment) return self.client except Exception as e: frappe.throw(e) @@ -124,9 +122,7 @@ 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") - ) + reference_doc = frappe.get_doc(self.data.get("reference_doctype"), self.data.get("reference_docname")) self.initialize_client() try: @@ -205,9 +201,7 @@ def create_charge_on_gocardless(self): def get_gateway_controller(doc): payment_request = frappe.get_doc("Payment Request", doc) - return frappe.db.get_value( - "Payment Gateway", payment_request.payment_gateway, "gateway_controller" - ) + return frappe.db.get_value("Payment Gateway", payment_request.payment_gateway, "gateway_controller") def gocardless_initialization(doc): diff --git a/payments/payment_gateways/doctype/mpesa_settings/mpesa_connector.py b/payments/payment_gateways/doctype/mpesa_settings/mpesa_connector.py index 7eb8b9c0..ce6df833 100644 --- a/payments/payment_gateways/doctype/mpesa_settings/mpesa_connector.py +++ b/payments/payment_gateways/doctype/mpesa_settings/mpesa_connector.py @@ -119,10 +119,8 @@ def stk_push( 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}" + time = str(datetime.datetime.now()).split(".")[0].replace("-", "").replace(" ", "").replace(":", "") + password = f"{business_shortcode!s}{passcode!s}{time}" encoded = base64.b64encode(bytes(password, encoding="utf8")) payload = { "BusinessShortCode": business_shortcode, @@ -135,9 +133,7 @@ def stk_push( "CallBackURL": callback_url, "AccountReference": reference_code, "TransactionDesc": description, - "TransactionType": "CustomerPayBillOnline" - if self.env == "sandbox" - else "CustomerBuyGoodsOnline", + "TransactionType": "CustomerPayBillOnline" if self.env == "sandbox" else "CustomerBuyGoodsOnline", } headers = { "Authorization": f"Bearer {self.authentication_token}", diff --git a/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.js b/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.js index 9d625736..491223da 100644 --- a/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.js +++ b/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.js @@ -1,36 +1,38 @@ // 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); - }, +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); - }); - }, + 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 - }); - }, + 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(); - } + 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.py b/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.py index 640ecb4c..b0f8eb6e 100644 --- a/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.py +++ b/payments/payment_gateways/doctype/mpesa_settings/mpesa_settings.py @@ -18,7 +18,7 @@ class MpesaSettings(Document): - supported_currencies = ["KES"] + supported_currencies = ("KES",) def validate_transaction_currency(self, currency): if currency not in self.supported_currencies: @@ -44,14 +44,14 @@ def on_update(self): ) # 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") + frappe.db.commit() # nosemgrep 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): + 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 ( @@ -104,8 +104,8 @@ def get_account_balance_info(self): 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") + if response.requestId: + req_name = response.requestId error = response else: # global checkout id used as request name @@ -116,7 +116,7 @@ def handle_api_response(self, global_id, request_dict, response): create_request_log(request_dict, "Host", "Mpesa", req_name, error) if error: - frappe.throw(_(getattr(response, "errorMessage")), title=_("Transaction Error")) + frappe.throw(_(response.errorMessage), title=_("Transaction Error")) def generate_stk_push(**kwargs): @@ -194,7 +194,7 @@ def verify_transaction(**kwargs): ) total_paid = amount + sum(completed_payments) - mpesa_receipts = ", ".join(mpesa_receipts + [mpesa_receipt]) + mpesa_receipts = ", ".join([*mpesa_receipts, mpesa_receipt]) if total_paid >= pr.grand_total: pr.run_method("on_payment_authorized", "Completed") @@ -314,9 +314,7 @@ def process_balance_info(**kwargs): ) except Exception: request.handle_failure(account_balance_response) - frappe.log_error( - title="Mpesa Account Balance Processing Error", message=account_balance_response - ) + frappe.log_error(title="Mpesa Account Balance Processing Error", message=account_balance_response) else: request.handle_failure(account_balance_response) diff --git a/payments/payment_gateways/doctype/mpesa_settings/test_mpesa_settings.py b/payments/payment_gateways/doctype/mpesa_settings/test_mpesa_settings.py index 729d4cb6..d712f94b 100644 --- a/payments/payment_gateways/doctype/mpesa_settings/test_mpesa_settings.py +++ b/payments/payment_gateways/doctype/mpesa_settings/test_mpesa_settings.py @@ -126,9 +126,7 @@ def test_processing_of_callback_payload(self): pluck="name", ) - callback_response = get_payment_callback_payload( - Amount=500, CheckoutRequestID=integration_req_ids[0] - ) + 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]) diff --git a/payments/payment_gateways/doctype/paymob_settings/__init__.py b/payments/payment_gateways/doctype/paymob_settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/payment_gateways/doctype/paymob_settings/paymob_settings.js b/payments/payment_gateways/doctype/paymob_settings/paymob_settings.js new file mode 100644 index 00000000..39d99cca --- /dev/null +++ b/payments/payment_gateways/doctype/paymob_settings/paymob_settings.js @@ -0,0 +1,32 @@ +// Copyright (c) 2025, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Paymob Settings", { + refresh(frm) { + frm.add_custom_button(__("Get Access Token"), () => { + frm.trigger("get_access_token"); + }); + }, + get_access_token: function (frm) { + try { + frm + .call({ + method: "refresh_access_token", + doc: frm.doc, + freeze: true, + freeze_message: __("Getting Access Token ..."), + }) + .then((r) => { + if (!r.exc && r.message) { + frm.set_value("token", r.message); + frappe.show_alert({ + message: __("Access Token Updated"), + indicator: "green", + }); + } + }); + } catch (e) { + console.log(e); + } + }, +}); diff --git a/payments/payment_gateways/doctype/paymob_settings/paymob_settings.json b/payments/payment_gateways/doctype/paymob_settings/paymob_settings.json new file mode 100644 index 00000000..55c1fc61 --- /dev/null +++ b/payments/payment_gateways/doctype/paymob_settings/paymob_settings.json @@ -0,0 +1,145 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-10-22 13:58:02.482023", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "credentials_section", + "api_key", + "public_key", + "hmac", + "column_break_rdwp", + "secret_key", + "token", + "expires_in", + "payment_config", + "iframe", + "payment_integration", + "column_break_qgqc", + "redirect_to" + ], + "fields": [ + { + "fieldname": "credentials_section", + "fieldtype": "Section Break", + "label": "Credentials" + }, + { + "fieldname": "api_key", + "fieldtype": "Password", + "in_list_view": 1, + "label": "API Key", + "reqd": 1 + }, + { + "fieldname": "public_key", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Public Key", + "reqd": 1 + }, + { + "fieldname": "hmac", + "fieldtype": "Password", + "in_list_view": 1, + "label": "HMAC", + "reqd": 1 + }, + { + "fieldname": "column_break_rdwp", + "fieldtype": "Column Break" + }, + { + "fieldname": "token", + "fieldtype": "Password", + "label": "Token" + }, + { + "fieldname": "secret_key", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Secret Key", + "reqd": 1 + }, + { + "fieldname": "payment_config", + "fieldtype": "Section Break", + "label": "Payment Config" + }, + { + "fieldname": "iframe", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Iframe", + "reqd": 1 + }, + { + "fieldname": "payment_integration", + "fieldtype": "Int", + "label": "Payment Integration", + "reqd": 1 + }, + { + "fieldname": "expires_in", + "fieldtype": "Datetime", + "label": "Expires In" + }, + { + "fieldname": "column_break_qgqc", + "fieldtype": "Column Break" + }, + { + "description": "Mention transaction completion page URL\n\n", + "fieldname": "redirect_to", + "fieldtype": "Data", + "label": "Redirect To" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2025-11-20 23:53:24.529932", + "modified_by": "Administrator", + "module": "Payment Gateways", + "name": "Paymob Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Accounts Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/payments/payment_gateways/doctype/paymob_settings/paymob_settings.py b/payments/payment_gateways/doctype/paymob_settings/paymob_settings.py new file mode 100644 index 00000000..04bff5b9 --- /dev/null +++ b/payments/payment_gateways/doctype/paymob_settings/paymob_settings.py @@ -0,0 +1,289 @@ +# Copyright (c) 2025, Frappe Technologies and contributors +# For license information, please see license.txt + +from datetime import timedelta +from urllib.parse import urlencode + +import frappe +from frappe import _ +from frappe.integrations.utils import create_request_log, make_get_request, make_post_request +from frappe.model.document import Document +from frappe.utils import get_datetime, now_datetime + +from payments.payment_gateways.paymob.accept_api import AcceptAPI +from payments.payment_gateways.paymob.hmac_validator import HMACValidator +from payments.payment_gateways.paymob.paymob_urls import PaymobUrls +from payments.payment_gateways.paymob.response_codes import SUCCESS + + +class PaymobSettings(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + api_key: DF.Password + hmac: DF.Password + iframe: DF.Data + payment_integration: DF.Int + public_key: DF.Password + secret_key: DF.Password + token: DF.Password | None + # end: auto-generated types + + @frappe.whitelist() + def refresh_access_token(self): + """ + If existing token expired → fetch new one + """ + + accept = AcceptAPI() + token = accept.retrieve_auth_token() + self.token = token + self.expires_in = now_datetime() + timedelta(minutes=50) + self.save(ignore_permissions=True) + + return token + + def get_valid_token(self): + token = self.get_password("token") if self.token else None + + buffer = timedelta(minutes=2) + if token and self.expires_in: + expires_in = ( + get_datetime(self.expires_in) if isinstance(self.expires_in, str) else self.expires_in + ) + if now_datetime() + buffer < expires_in: + return token + + return self.refresh_access_token() + + def get_payment_url(self, **kwargs): + try: + paymob_urls = PaymobUrls() + + if not kwargs.get("order_id") or not kwargs.get("amount"): + frappe.throw(_("Missing order ID or amount")) + + # Build dummy billing data + billing_data = { + "apartment": "NA", + "email": kwargs.get("payer_email"), + "floor": "NA", + "first_name": kwargs.get("payer_name").split()[0], + "street": "NA", + "building": "NA", + "phone_number": "+201111111111", + "shipping_method": "NA", + "postal_code": "NA", + "city": "Cairo", + "country": "EG", + "last_name": kwargs.get("payer_name").split()[-1], + "state": "NA", + } + + payment_key_payload = { + "auth_token": self.get_valid_token(), + "amount_cents": str(int(float(kwargs.get("amount")) * 100)), + "expiration": 3600, + "order_id": kwargs.get("order_id"), + "currency": kwargs.get("currency", "EGP"), + "billing_data": billing_data, + "integration_id": self.payment_integration, + } + + url = paymob_urls.get_url("payment_key") + headers = {"Content-Type": "application/json"} + response = make_post_request(url=url, json=payment_key_payload, headers=headers) + + if not response or "token" not in response: + frappe.throw(_("Failed to retrieve payment token from Paymob")) + + payment_token = response["token"] + + iframe_url = f"https://accept.paymob.com/api/acceptance/iframes/{self.iframe}?payment_token={payment_token}" + return iframe_url + + except Exception: + frappe.log_error(frappe.get_traceback()) + frappe.throw(_("Could not generate Paymob payment URL")) + + def create_order(self, **kwargs): + integration_request = create_request_log(kwargs, service_name="Paymob") + paymob_urls = PaymobUrls() + + token = self.get_valid_token() + + amount_cents = int(kwargs.get("amount")) * 100 # Paymob uses cents + currency = kwargs.get("currency", "EGP") + delivery_needed = kwargs.get("delivery_needed", False) + items = kwargs.get("items", []) + + payload = { + "auth_token": token, + "delivery_needed": str(delivery_needed).lower(), + "amount_cents": str(amount_cents), + "currency": currency, + "items": items, + } + + try: + url = paymob_urls.get_url("order") + headers = {"Content-Type": "application/json"} + order = make_post_request(url=url, json=payload, headers=headers) + + if not order or not order.get("id"): + frappe.throw(_("Failed to create order in Paymob")) + + paymob_order_id = order.get("id") + + integration_request_dict = frappe.parse_json(integration_request.data) + integration_request_dict["paymob_order_id"] = str(paymob_order_id) + + order["integration_request"] = integration_request.name + + integration_request.data = frappe.as_json(integration_request_dict) + integration_request.save(ignore_permissions=True) + frappe.db.commit() + + return order + except Exception: + frappe.log_error(frappe.get_traceback()) + frappe.throw(_("Could not create Paymob order")) + + +@frappe.whitelist(allow_guest=True) +def callback(): + try: + incoming_hmac = frappe.request.args.get("hmac") or frappe.request.form.get("hmac") + + if not incoming_hmac: + frappe.throw(_("Missing HMAC")) + + incoming_data_json = frappe.request.get_json() + + # Validate the HMAC + validator = HMACValidator(incoming_hmac=incoming_hmac, callback_dict=incoming_data_json) + + if not validator.is_valid: + frappe.throw(_("Invalid HMAC")) + + obj_data = incoming_data_json.get("obj", {}) + success = obj_data.get("success") + pending = obj_data.get("pending") + payment_status = obj_data.get("order", {}).get("payment_status") + txn_response_code = obj_data.get("data", {}).get("txn_response_code") + migs_data = obj_data.get("data", {}).get("migs_order", {}) + capture_status = migs_data.get("status") + paymob_payment_id = obj_data.get("id") + paymob_order_id = obj_data.get("order", {}).get("id") + + is_payment_successful = ( + success is True + and pending is False + and str(payment_status).upper() == "PAID" + and str(txn_response_code).upper() == "APPROVED" + ) + + if not paymob_order_id: + frappe.throw(_("Missing order ID")) + + integration_request_doc = get_integration_request(paymob_order_id) + integration_request_dict = frappe.parse_json(integration_request_doc.data) + + integration_request_dict.update( + { + "paymob_payment_id": str(paymob_payment_id), + "order_id": str(paymob_order_id), + } + ) + + integration_request_doc.data = frappe.as_json(integration_request_dict) + + if is_payment_successful: + if capture_status == "CAPTURED": + integration_request_doc.status = "Completed" + + integration_request_doc.save(ignore_permissions=True) + frappe.db.commit() + + handle_payment_success(integration_request_dict) + + else: + integration_request_doc.error = ( + f"Payment Status: {payment_status}, Response Code: {txn_response_code}" + ) + integration_request_doc.save(ignore_permissions=True) + frappe.db.commit() + frappe.log_error(frappe.get_traceback(), "Paymob Payment not authorized") + + except Exception: + frappe.log_error(frappe.get_traceback(), "Paymob Callback Error") + + +def get_integration_request(paymob_order_id): + """Fetch Integration Request linked to Paymob order.""" + + integration_requests = frappe.get_all( + "Integration Request", + filters={ + "integration_request_service": "Paymob", + "data": ["like", f'%"paymob_order_id": "{paymob_order_id}"%'], + }, + fields=["name", "data", "reference_doctype", "reference_docname"], + order_by="creation desc", + limit=1, + ) + if not integration_requests: + frappe.throw(_("No Integration Request found for this order")) + + return frappe.get_doc("Integration Request", integration_requests[0].name) + + +def handle_payment_success(integration_request_dict): + """Handle post-success payments""" + + redirect_to = integration_request_dict["redirect_to"] + if integration_request_dict["reference_doctype"] and integration_request_dict["reference_docname"]: + custom_redirect_to = None + try: + custom_redirect_to = frappe.get_doc( + integration_request_dict["reference_doctype"], integration_request_dict["reference_docname"] + ).run_method("on_payment_authorized", "Completed") + + except Exception: + frappe.log_error(frappe.get_traceback()) + + if custom_redirect_to: + redirect_to = custom_redirect_to + + redirect_url = f"payment-success?doctype={integration_request_dict['reference_doctype']}&docname={integration_request_dict['reference_docname']}" + + if redirect_to: + redirect_url += "&" + urlencode({"redirect_to": redirect_to}) + + return {"redirect_to": redirect_url, "status": "Completed"} + + +@frappe.whitelist() +def update_paymob_settings(**kwargs): + args = frappe._dict(kwargs) + fields = frappe._dict( + { + "api_key": args.get("api_key"), + "secret_key": args.get("secret_key"), + "public_key": args.get("public_key"), + "hmac": args.get("hmac"), + "iframe": args.get("iframe"), + "payment_integration": args.get("payment_integration"), + } + ) + try: + paymob_settings = frappe.get_doc("Paymob Settings").update(fields) + paymob_settings.save() + return "Paymob Credentials Successfully" + except Exception: + return "Failed to Update Paymob Credentials" diff --git a/payments/payment_gateways/doctype/paymob_settings/test_paymob_settings.py b/payments/payment_gateways/doctype/paymob_settings/test_paymob_settings.py new file mode 100644 index 00000000..b8e019e3 --- /dev/null +++ b/payments/payment_gateways/doctype/paymob_settings/test_paymob_settings.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, Frappe Technologies and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestPaymobSettings(FrappeTestCase): + pass diff --git a/payments/payment_gateways/doctype/paypal_settings/paypal_settings.js b/payments/payment_gateways/doctype/paypal_settings/paypal_settings.js index 63480bc9..9f8ad92a 100644 --- a/payments/payment_gateways/doctype/paypal_settings/paypal_settings.js +++ b/payments/payment_gateways/doctype/paypal_settings/paypal_settings.js @@ -1,8 +1,6 @@ // Copyright (c) 2016, Frappe Technologies and contributors // For license information, please see license.txt -frappe.ui.form.on('PayPal Settings', { - refresh: function(frm) { - - } +frappe.ui.form.on("PayPal Settings", { + refresh: function (frm) {}, }); diff --git a/payments/payment_gateways/doctype/paypal_settings/paypal_settings.py b/payments/payment_gateways/doctype/paypal_settings/paypal_settings.py index 04943a5c..3c25bec8 100644 --- a/payments/payment_gateways/doctype/paypal_settings/paypal_settings.py +++ b/payments/payment_gateways/doctype/paypal_settings/paypal_settings.py @@ -64,6 +64,7 @@ def on_payment_authorized(payment_status): import json from urllib.parse import urlencode +from zoneinfo import ZoneInfo import frappe import pytz @@ -78,8 +79,15 @@ def on_payment_authorized(payment_status): api_path = "/api/method/payments.payment_gateways.doctype.paypal_settings.paypal_settings" +from payments.utils import create_payment_gateway + +api_path = ( + "/api/method/payments.payment_gateways.doctype.paypal_settings.paypal_settings" +) + + class PayPalSettings(Document): - supported_currencies = [ + supported_currencies = ( "AUD", "BRL", "CAD", @@ -105,14 +113,14 @@ class PayPalSettings(Document): "THB", "TRY", "USD", - ] + ) def __setup__(self): - setattr(self, "use_sandbox", 0) + self.use_sandbox = 0 def setup_sandbox_env(self, token): data = json.loads(frappe.db.get_value("Integration Request", token, "data")) - setattr(self, "use_sandbox", cint(frappe._dict(data).use_sandbox) or 0) + self.use_sandbox = cint(frappe._dict(data).use_sandbox) or 0 def validate(self): create_payment_gateway("PayPal") @@ -171,7 +179,7 @@ def validate_paypal_credentails(self): frappe.throw(_("Invalid payment gateway credentials")) def get_payment_url(self, **kwargs): - setattr(self, "use_sandbox", cint(kwargs.get("use_sandbox", 0))) + self.use_sandbox = cint(kwargs.get("use_sandbox", 0)) response = self.execute_set_express_checkout(**kwargs) @@ -212,6 +220,7 @@ def execute_set_express_checkout(self, **kwargs): response = make_post_request(url, data=params.encode("utf-8")) if response.get("ACK")[0] != "Success": + create_request_log(response, service_name="PayPal", status="Failed") frappe.throw(_("Looks like something is wrong with this site's Paypal configuration.")) return response @@ -244,6 +253,51 @@ def get_paypal_and_transaction_details(token): return data, params, url +def setup_redirect(data, redirect_url, custom_redirect_to=None, redirect=True): + redirect_to = data.get("redirect_to") or None + redirect_message = data.get("redirect_message") or None + + if custom_redirect_to: + redirect_to = custom_redirect_to + + if redirect_to: + redirect_url += "&" + urlencode({"redirect_to": redirect_to}) + if redirect_message: + redirect_url += "&" + urlencode({"redirect_message": redirect_message}) + + # this is done so that functions called via hooks can update flags.redirect_to + if redirect: + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = get_url(redirect_url) + + def configure_recurring_payments(self, params, kwargs): + # removing the params as we have to setup rucurring payments + for param in ( + "PAYMENTREQUEST_0_PAYMENTACTION", + "PAYMENTREQUEST_0_AMT", + "PAYMENTREQUEST_0_CURRENCYCODE", + ): + del params[param] + + params.update( + { + "L_BILLINGTYPE0": "RecurringPayments", # The type of billing agreement + "L_BILLINGAGREEMENTDESCRIPTION0": kwargs["description"], + } + ) + + +def get_paypal_and_transaction_details(token): + doc = frappe.get_doc("PayPal Settings") + doc.setup_sandbox_env(token) + params, url = doc.get_paypal_params_and_url() + + integration_request = frappe.get_doc("Integration Request", token) + data = json.loads(integration_request.data) + + return data, params, url + + def setup_redirect(data, redirect_url, custom_redirect_to=None, redirect=True): redirect_to = data.get("redirect_to") or None redirect_message = data.get("redirect_message") or None @@ -379,7 +433,7 @@ def create_recurring_profile(token, payerid): status_changed_to = "Completed" if data.get("starting_immediately") or updating else "Verified" starts_at = get_datetime(subscription_details.get("start_date")) or frappe.utils.now_datetime() - starts_at = starts_at.replace(tzinfo=pytz.timezone(get_system_timezone())).astimezone(pytz.utc) + starts_at = starts_at.replace(tzinfo=ZoneInfo(get_system_timezone())).astimezone(ZoneInfo("UTC")) # "PROFILESTARTDATE": datetime.utcfromtimestamp(get_timestamp(starts_at)).isoformat() params.update({"PROFILESTARTDATE": starts_at.isoformat()}) @@ -432,6 +486,7 @@ def get_redirect_uri(doc, token, payerid): return get_url(f"{api_path}.confirm_payment?token={token}") + def manage_recurring_payment_profile_status(profile_id, action, args, url): args.update( { diff --git a/payments/payment_gateways/doctype/paytm_settings/paytm_settings.js b/payments/payment_gateways/doctype/paytm_settings/paytm_settings.js index fe2ee7c9..e561698e 100644 --- a/payments/payment_gateways/doctype/paytm_settings/paytm_settings.js +++ b/payments/payment_gateways/doctype/paytm_settings/paytm_settings.js @@ -1,8 +1,14 @@ // Copyright (c) 2020, Frappe Technologies and contributors // For license information, please see license.txt -frappe.ui.form.on('Paytm Settings', { - refresh: function(frm) { - frm.dashboard.set_headline(__("For more information, {0}.", [`${__('Click here')}`])); - } +frappe.ui.form.on("Paytm Settings", { + refresh: function (frm) { + frm.dashboard.set_headline( + __("For more information, {0}.", [ + `${__( + "Click here" + )}`, + ]) + ); + }, }); diff --git a/payments/payment_gateways/doctype/paytm_settings/paytm_settings.py b/payments/payment_gateways/doctype/paytm_settings/paytm_settings.py index 1010d050..4debb63b 100644 --- a/payments/payment_gateways/doctype/paytm_settings/paytm_settings.py +++ b/payments/payment_gateways/doctype/paytm_settings/paytm_settings.py @@ -23,8 +23,11 @@ from payments.utils import create_payment_gateway +from payments.utils import create_payment_gateway + + class PaytmSettings(Document): - supported_currencies = ["INR"] + supported_currencies = ("INR",) def validate(self): create_payment_gateway("Paytm") @@ -75,7 +78,6 @@ def get_paytm_config(): def get_paytm_params(payment_details, order_id, paytm_config): - # initialize a dictionary paytm_params = dict() @@ -127,9 +129,7 @@ def verify_transaction(**paytm_params): http_status_code=401, indicator_color="red", ) - frappe.log_error( - "Order unsuccessful. Failed Response:" + cstr(paytm_params), "Paytm Payment Failed" - ) + frappe.log_error("Order unsuccessful. Failed Response:" + cstr(paytm_params), "Paytm Payment Failed") def verify_transaction_status(paytm_config, order_id): diff --git a/payments/payment_gateways/doctype/razorpay_settings/razorpay_settings.js b/payments/payment_gateways/doctype/razorpay_settings/razorpay_settings.js index bf2a955c..71fe56e2 100644 --- a/payments/payment_gateways/doctype/razorpay_settings/razorpay_settings.js +++ b/payments/payment_gateways/doctype/razorpay_settings/razorpay_settings.js @@ -13,4 +13,4 @@ frappe.ui.form.on("Razorpay Settings", { }); }); }, -}); \ No newline at end of file +}); diff --git a/payments/payment_gateways/doctype/razorpay_settings/razorpay_settings.py b/payments/payment_gateways/doctype/razorpay_settings/razorpay_settings.py index 434518cd..3234e653 100644 --- a/payments/payment_gateways/doctype/razorpay_settings/razorpay_settings.py +++ b/payments/payment_gateways/doctype/razorpay_settings/razorpay_settings.py @@ -324,10 +324,128 @@ def prepare_subscription_details(self, settings, **kwargs): return kwargs + def setup_addon(self, settings, **kwargs): + """ + Addon template: + { + "item": { + "name": row.upgrade_type, + "amount": row.amount, + "currency": currency, + "description": "add-on description" + }, + "quantity": 1 (The total amount is calculated as item.amount * quantity) + } + """ + url = "https://api.razorpay.com/v1/subscriptions/{0}/addons".format( + kwargs.get("subscription_id") + ) + + try: + if not frappe.conf.converted_rupee_to_paisa: + convert_rupee_to_paisa(**kwargs) + + for addon in kwargs.get("addons"): + resp = make_post_request( + url, + auth=(settings.api_key, settings.api_secret), + data=json.dumps(addon), + headers={"content-type": "application/json"}, + ) + if not resp.get("id"): + frappe.log_error(message=str(resp), title="Razorpay Failed while creating subscription") + except Exception: + frappe.log_error() + # failed + pass + + def setup_subscription(self, settings, **kwargs): + start_date = ( + get_timestamp(kwargs.get("subscription_details").get("start_date")) + if kwargs.get("subscription_details").get("start_date") + else None + ) + + subscription_details = { + "plan_id": kwargs.get("subscription_details").get("plan_id"), + "total_count": kwargs.get("subscription_details").get("billing_frequency"), + "customer_notify": kwargs.get("subscription_details").get("customer_notify"), + } + + if start_date: + subscription_details["start_at"] = cint(start_date) + + if kwargs.get("addons"): + convert_rupee_to_paisa(**kwargs) + subscription_details.update({"addons": kwargs.get("addons")}) + + try: + resp = make_post_request( + "https://api.razorpay.com/v1/subscriptions", + auth=(settings.api_key, settings.api_secret), + data=json.dumps(subscription_details), + headers={"content-type": "application/json"}, + ) + + if resp.get("status") == "created": + kwargs["subscription_id"] = resp.get("id") + frappe.flags.status = "created" + return kwargs + else: + frappe.log_error(message=str(resp), title="Razorpay Failed while creating subscription") + + except Exception: + frappe.log_error() + + def prepare_subscription_details(self, settings, **kwargs): + if not kwargs.get("subscription_id"): + kwargs = self.setup_subscription(settings, **kwargs) + + if frappe.flags.status != "created": + kwargs["subscription_id"] = None + + return kwargs + def get_payment_url(self, **kwargs): + if not kwargs.get("order_id"): + order = self.create_order(**kwargs) + kwargs.update({"order_id": order.get("id")}) + integration_request = create_request_log(kwargs, service_name="Razorpay") return get_url(f"./razorpay_checkout?token={integration_request.name}") + def create_order(self, **kwargs): + # Creating Orders https://razorpay.com/docs/api/orders/ + + # convert rupees to paisa + kwargs["amount"] = int(kwargs["amount"] * 100) + + # Create integration log + integration_request = create_request_log(kwargs, service_name="Razorpay") + + # Setup payment options + payment_options = { + "amount": kwargs.get("amount"), + "currency": kwargs.get("currency", "INR"), + "receipt": kwargs.get("receipt"), + "payment_capture": kwargs.get("payment_capture"), + } + if self.api_key and self.api_secret: + try: + order = make_post_request( + "https://api.razorpay.com/v1/orders", + auth=( + self.api_key, + self.get_password(fieldname="api_secret", raise_exception=False), + ), + data=payment_options, + ) + order["integration_request"] = integration_request.name + return order # Order returned to be consumed by razorpay.js + except Exception: + frappe.log(frappe.get_traceback()) + frappe.throw(_("Could not create razorpay order")) + def create_order(self, **kwargs): # Creating Orders https://razorpay.com/docs/api/orders/ @@ -382,8 +500,8 @@ def create_request(self, data): def authorize_payment(self): """ - An authorization is performed when user’s payment details are successfully authenticated by the bank. - The money is deducted from the customer’s account, but will not be transferred to the merchant’s account + An authorization is performed when user's payment details are successfully authenticated by the bank. + The money is deducted from the customer's account, but will not be transferred to the merchant's account until it is explicitly captured by merchant. """ data = json.loads(self.integration_request.data) @@ -437,8 +555,8 @@ def authorize_payment(self): if custom_redirect_to: redirect_to = custom_redirect_to - redirect_url = "payment-success?doctype={}&docname={}".format( - self.data.reference_doctype, self.data.reference_docname + redirect_url = ( + f"payment-success?doctype={self.data.reference_doctype}&docname={self.data.reference_docname}" ) else: redirect_url = "payment-failed" @@ -458,6 +576,49 @@ def get_settings(self, data): } ) + if cint(data.get("notes", {}).get("use_sandbox")) or data.get("use_sandbox"): + settings.update( + { + "api_key": frappe.conf.sandbox_api_key, + "api_secret": frappe.conf.sandbox_api_secret, + } + ) + + return settings + + def cancel_subscription(self, subscription_id): + settings = self.get_settings({}) + + try: + make_post_request( + f"https://api.razorpay.com/v1/subscriptions/{subscription_id}/cancel", + auth=(settings.api_key, settings.api_secret), + ) + except Exception: + frappe.log_error(frappe.get_traceback()) + + def verify_signature(self, body, signature, key): + key = bytes(key, "utf-8") + body = bytes(body, "utf-8") + + dig = hmac.new(key=key, msg=body, digestmod=hashlib.sha256) + + generated_signature = dig.hexdigest() + result = hmac.compare_digest(generated_signature, signature) + + if not result: + frappe.throw(_("Razorpay Signature Verification Failed"), exc=frappe.PermissionError) + + return result + + @frappe.whitelist() + def clear(self): + self.api_key = self.api_secret = None + self.redirect_url = None + self.flags.ignore_mandatory = True + self.save() + + if cint(data.get("notes", {}).get("use_sandbox")) or data.get("use_sandbox"): settings.update( { @@ -536,6 +697,17 @@ def capture_payment(is_sandbox=False, sanbox_response=None): data={"amount": data.get("amount")}, ) + if resp.get('status') == "authorized": + resp = make_post_request("https://api.razorpay.com/v1/payments/{0}/capture".format(data.get("razorpay_payment_id")), + auth=(settings.api_key, settings.api_secret), data={"amount": data.get("amount")}) + + if resp.get("status") == "authorized": + resp = make_post_request( + "https://api.razorpay.com/v1/payments/{0}/capture".format(data.get("razorpay_payment_id")), + auth=(settings.api_key, settings.api_secret), + data={"amount": data.get("amount")}, + ) + if resp.get("status") == "captured": frappe.db.set_value("Integration Request", doc.name, "status", "Completed") @@ -547,6 +719,67 @@ def capture_payment(is_sandbox=False, sanbox_response=None): frappe.log_error(doc.error, f"{doc.name} Failed") +@frappe.whitelist(allow_guest=True) +def get_api_key(): + controller = frappe.get_doc("Razorpay Settings") + return controller.api_key + + +@frappe.whitelist(allow_guest=True) +def get_order(doctype, docname): + # Order returned to be consumed by razorpay.js + doc = frappe.get_doc(doctype, docname) + try: + # Do not use run_method here as it fails silently + return doc.get_razorpay_order() + except AttributeError: + frappe.log_error(frappe.get_traceback(), _("Controller method get_razorpay_order missing")) + frappe.throw(_("Could not create Razorpay order. Please contact Administrator")) + + +@frappe.whitelist(allow_guest=True) +def order_payment_success(integration_request, params): + """Called by razorpay.js on order payment success, the params + contains razorpay_payment_id, razorpay_order_id, razorpay_signature + that is updated in the data field of integration request + + Args: + integration_request (string): Name for integration request doc + params (string): Params to be updated for integration request. + """ + params = json.loads(params) + integration = frappe.get_doc("Integration Request", integration_request) + + # Update integration request + integration.update_status(params, integration.status) + integration.reload() + + data = json.loads(integration.data) + controller = frappe.get_doc("Razorpay Settings") + + # Update payment and integration data for payment controller object + controller.integration_request = integration + controller.data = frappe._dict(data) + + # Authorize payment + controller.authorize_payment() + + +@frappe.whitelist(allow_guest=True) +def order_payment_failure(integration_request, params): + """Called by razorpay.js on failure + + Args: + integration_request (TYPE): Description + params (TYPE): error data to be updated + """ + frappe.log_error(params, "Razorpay Payment Failure") + params = json.loads(params) + integration = frappe.get_doc("Integration Request", integration_request) + integration.update_status(params, integration.status) + + + @frappe.whitelist(allow_guest=True) def get_api_key(): controller = frappe.get_doc("Razorpay Settings") diff --git a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.js b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.js index 578ae949..31636949 100644 --- a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.js +++ b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.js @@ -1,8 +1,6 @@ // Copyright (c) 2017, Frappe Technologies and contributors // For license information, please see license.txt -frappe.ui.form.on('Stripe Settings', { - refresh: function(frm) { - - } +frappe.ui.form.on("Stripe Settings", { + refresh: function (frm) {}, }); diff --git a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.json b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.json index b1bd1c3e..17d94347 100644 --- a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.json +++ b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.json @@ -234,6 +234,132 @@ "translatable": 0, "unique": 0 }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "redirect_url", + "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": "Redirect URL", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "section_break_5", + "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, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "header_img", + "fieldtype": "Attach Image", + "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": "Header Image", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "column_break_7", + "fieldtype": "Column Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, { "allow_bulk_edit": 0, "allow_in_quick_entry": 0, diff --git a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py index f8e8c9f6..4356d2ce 100644 --- a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py +++ b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py @@ -190,6 +190,15 @@ def validate_minimum_transaction_amount(self, currency, amount): ) ) + def validate_minimum_transaction_amount(self, currency, amount): + if currency in self.currency_wise_minimum_charge_amount: + if flt(amount) < self.currency_wise_minimum_charge_amount.get(currency, 0.0): + frappe.throw( + _("For currency {0}, the minimum transaction amount should be {1}").format( + currency, self.currency_wise_minimum_charge_amount.get(currency, 0.0) + ) + ) + def get_payment_url(self, **kwargs): return get_url(f"./stripe_checkout?{urlencode(kwargs)}") @@ -258,9 +267,7 @@ def finalize_request(self): if custom_redirect_to: redirect_to = custom_redirect_to - redirect_url = ( - f"payment-success?doctype={self.data.reference_doctype}&docname={self.data.reference_docname}" - ) + redirect_url = f"payment-success?doctype={self.data.reference_doctype}&docname={self.data.reference_docname}" if self.redirect_url: redirect_url = self.redirect_url diff --git a/payments/payment_gateways/paymob/accept_api.py b/payments/payment_gateways/paymob/accept_api.py new file mode 100644 index 00000000..ae3ee4e5 --- /dev/null +++ b/payments/payment_gateways/paymob/accept_api.py @@ -0,0 +1,79 @@ +import json +from typing import Any, Union + +import frappe +import requests + +from .connection import AcceptConnection +from .paymob_urls import PaymobUrls +from .response_codes import SUCCESS +from .response_feedback_dataclass import ResponseFeedBack + + +class AcceptAPI: + def __init__(self) -> None: + """Class for Accept APIs + By Initializing an Instance from This class, an auth token is obtained automatically + and You will be able to call The Following APIs: + - Create Payment Intention + - Get Transaction Details + """ + self.connection = AcceptConnection() + self.paymob_settings = frappe.get_doc("Paymob Settings") + self.paymob_urls = PaymobUrls() + + def retrieve_auth_token(self): + """ + Authentication Request: + :return: token: Authentication token, which is valid for one hour from the creation time. + """ + return self.connection.auth_token + + def create_payment_intent(self, data: dict) -> tuple[str, dict | None, ResponseFeedBack]: + """ + Creates a Paymob Payment Intent + :param data: Dictionary containing payment intent details (refer to Paymob documentation) + :return: Tuple[str, Union[Dict, None], ResponseFeedBack]: (Code, Dict, ResponseFeedBack Instance) + + """ + + headers = { + "Authorization": f"Token {self.paymob_settings.get_password('secret_key')}", + "Content-Type": "application/json", + } + payload = json.dumps(data) + code, feedback = self.connection.post( + url=self.paymob_urls.get_url("intention"), headers=headers, data=payload + ) + + payment_intent = frappe._dict() + + if code == SUCCESS: + payment_intent = feedback.data + feedback.message = "Payment Intention Created Successfully" + + return code, payment_intent, feedback + + def retrieve_transaction(self, transaction_id: int) -> tuple[str, dict | None, ResponseFeedBack]: + """Retrieves Transaction Data by Transaction ID + + Args: + transaction_id (int): Paymob's Transaction ID + + Returns: + Tuple[str, Union[Dict, None], ResponseFeedBack]: (Code, Dict, ResponseFeedBack Instance) + """ + code, feedback = self.connection.get( + url=self.paymob_urls.get_url("retrieve_transaction", id=transaction_id) + ) + transaction = None + if code == SUCCESS: + transaction = feedback.data + feedback.message = f"Transaction with id {transaction_id} retrieved Scuccessfully" + return code, transaction, feedback + + def retrieve_iframe(self, iframe_id, payment_token): + iframe_url = self.paymob_urls.get_url( + "iframe", iframe_id=self.paymob_settings.iframe, payment_token=payment_token + ) + return iframe_url diff --git a/payments/payment_gateways/paymob/connection.py b/payments/payment_gateways/paymob/connection.py new file mode 100644 index 00000000..8f1c865a --- /dev/null +++ b/payments/payment_gateways/paymob/connection.py @@ -0,0 +1,137 @@ +from typing import Any + +import requests +from frappe.utils.password import get_decrypted_password +from requests import HTTPError, JSONDecodeError, RequestException + +from .paymob_urls import PaymobUrls +from .response_codes import ( + HTTP_EXCEPTION, + HTTP_EXCEPTION_MESSAGE, + JSON_DECODE_EXCEPTION, + JSON_DECODE_EXCEPTION_MESSAGE, + REQUEST_EXCEPTION, + REQUEST_EXCEPTION_MESSAGE, + SUCCESS, + SUCCESS_MESSAGE, + UNHANDLED_EXCEPTION, + UNHANDLED_EXCEPTION_MESSAGE, +) +from .response_feedback_dataclass import ResponseFeedBack + + +class AcceptConnection: + def __init__(self) -> None: + """Initializing the Following: + 1- Requests Session + 2- Auth Token + 3- Set Headers + 4- Paymob Urls + """ + self.session = requests.Session() + self.paymob_urls = PaymobUrls() + self.auth_token = self._get_auth_token() + self.session.headers.update(self._get_headers()) + + def _get_headers(self) -> dict[str, Any]: + """Initialize Header for Requests + + Returns: + Dict[str, Any]: Initialized Header Dict + """ + return { + "Content-Type": "application/json", + "Authorization": f"{self.auth_token}", + } + + def _get_auth_token(self) -> str | None: + """Retrieve an Auth Token + + Returns: + Union[str, None]: Auth Token + """ + api_key = get_decrypted_password("Paymob Settings", "Paymob Settings", "api_key") + request_body = {"api_key": api_key} + + code, feedback = self.post( + url=self.paymob_urls.get_url("auth"), + json=request_body, + ) + + token = None + if code == SUCCESS: + token = feedback.data.get("token") + return token + + def _process_request(self, call, *args, **kwargs) -> tuple[str, dict[str, Any], ResponseFeedBack]: + """Process the Request + + Args: + call (Session.get/Session.post): Session.get/Session.post + *args, **kwargs: Same Args of requests.post/requests.get methods + + Returns: + Tuple[str, Dict[str, Any], ResponseFeedBack]: Tuple containes the Following (Code, Data, Success/Error Message) + """ + + reponse_data = None + try: + response = call(*args, timeout=90, **kwargs) + reponse_data = response.json() + response.raise_for_status() + except JSONDecodeError as error: + reponse_feedback = ResponseFeedBack( + message=JSON_DECODE_EXCEPTION_MESSAGE, + status_code=response.status_code, + exception_error=error, + ) + return JSON_DECODE_EXCEPTION, reponse_feedback + except HTTPError as error: + reponse_feedback = ResponseFeedBack( + message=HTTP_EXCEPTION_MESSAGE, + data=reponse_data, + status_code=response.status_code, + exception_error=error, + ) + return HTTP_EXCEPTION, reponse_feedback + except RequestException as error: + reponse_feedback = ResponseFeedBack( + message=REQUEST_EXCEPTION_MESSAGE, + exception_error=error, + ) + return REQUEST_EXCEPTION, reponse_feedback + except Exception as error: + reponse_feedback = ResponseFeedBack( + message=UNHANDLED_EXCEPTION_MESSAGE, + exception_error=error, + ) + return UNHANDLED_EXCEPTION, reponse_feedback + + reponse_feedback = ResponseFeedBack( + message=SUCCESS_MESSAGE, + data=reponse_data, + status_code=response.status_code, + ) + return SUCCESS, reponse_feedback + + def get(self, *args, **kwargs) -> tuple[str, dict[str, Any], ResponseFeedBack]: + """Wrapper for requests.get method + + Args: + Same Args of requests.post/requests.get methods + + Returns: + Tuple[str, Dict[str, Any], ResponseFeedBack]: Tuple containes the Following (Code, Data, Success/Error Message) + """ + return self._process_request(*args, call=self.session.get, **kwargs) + + def post(self, *args, **kwargs) -> tuple[str, dict[str, Any], ResponseFeedBack]: + """Wrapper for requests.get method + + Args: + Same Args of requests.post/requests.get methods + + Returns: + Tuple[str, Dict[str, Any], ResponseFeedBack]: Tuple containes the Following (Code, Data, Success/Error Message) + """ + return self._process_request(*args, call=self.session.post, **kwargs) diff --git a/payments/payment_gateways/paymob/constants.py b/payments/payment_gateways/paymob/constants.py new file mode 100644 index 00000000..be88d47e --- /dev/null +++ b/payments/payment_gateways/paymob/constants.py @@ -0,0 +1,4 @@ +class AcceptCallbackTypes: + TRANSACTION = "TRANSACTION" + CARD_TOKEN = "TOKEN" + DELIVERY_STATUS = "DELIVERY_STATUS" diff --git a/payments/payment_gateways/paymob/hmac_validator.py b/payments/payment_gateways/paymob/hmac_validator.py new file mode 100644 index 00000000..30ec0635 --- /dev/null +++ b/payments/payment_gateways/paymob/hmac_validator.py @@ -0,0 +1,168 @@ +import hashlib +import hmac +from typing import Any + +import frappe + +from .constants import AcceptCallbackTypes + + +class HMACValidator: + def __init__(self, incoming_hmac: str, callback_dict: dict[str, Any], **kwargs) -> None: + """Initialize HMAC Attributes + + Args: + incoming_hmac (str): Incoming Paymob's HMAC + callback_dict Dict[str, Any]: Incoming Callback Dict + """ + self.incoming_hmac = incoming_hmac + self.callback_dict = callback_dict + if isinstance(self.callback_dict, dict): + self.callback_obj_dict = self.callback_dict.get("obj") + + super().__init__(**kwargs) + + @staticmethod + def _calculate_hmac(message: str) -> str: + """Calculates HMAC + + Args: + message (str): GeneratedHMAC Message + + Returns: + str: Calculated HMAC + """ + hmac_secret = frappe.get_doc("Paymob Settings").get_password("hmac").encode("utf-8") + return ( + hmac.new( + hmac_secret, + message.encode("utf-8"), + hashlib.sha512, + ) + .hexdigest() + .lower() + ) + + @classmethod + def _generate_processed_hmac(cls, hmac_dict: dict[str, Any]) -> str: + """Creates HMAC from sent self.callback_obj_dict + + Args: + hmac_dict (Dict[str, Any]): Hmac Dict + + Returns: + str: Generated HMAC + """ + if not isinstance(hmac_dict, dict): + return "" + + message = "" + for value in hmac_dict.values(): + if isinstance(value, bool): + value = str(value).lower() + if value is None: + value = "" + message += str(value) + + return cls._calculate_hmac(message=message) + + def _generate_transaction_processed_hmac(self) -> str: + """Creates HMAC from sent transaction callback self.callback_obj_dict + + Returns: + str: Generated HMAC + """ + if not isinstance(self.callback_obj_dict, dict): + return "" + + hmac_dict = { + "amount_cents": self.callback_obj_dict.get("amount_cents"), + "created_at": self.callback_obj_dict.get("created_at"), + "currency": self.callback_obj_dict.get("currency"), + "error_occured": self.callback_obj_dict.get("error_occured"), + "has_parent_transaction": self.callback_obj_dict.get("has_parent_transaction"), + "id": self.callback_obj_dict.get("id"), + "integration_id": self.callback_obj_dict.get("integration_id"), + "is_3d_secure": self.callback_obj_dict.get("is_3d_secure"), + "is_auth": self.callback_obj_dict.get("is_auth"), + "is_capture": self.callback_obj_dict.get("is_capture"), + "is_refunded": self.callback_obj_dict.get("is_refunded"), + "is_standalone_payment": self.callback_obj_dict.get("is_standalone_payment"), + "is_voided": self.callback_obj_dict.get("is_voided"), + "order.id": self.callback_obj_dict.get("order", {}).get("id"), + "owner": self.callback_obj_dict.get("owner"), + "pending": self.callback_obj_dict.get("pending"), + "source_data.pan": self.callback_obj_dict.get("source_data", {}).get("pan"), + "source_data.sub_type": self.callback_obj_dict.get("source_data", {}).get("sub_type"), + "source_data.type": self.callback_obj_dict.get("source_data", {}).get("type"), + "success": self.callback_obj_dict.get("success"), + } + + return self._generate_processed_hmac(hmac_dict=hmac_dict) + + def _generate_card_token_processed_hmac(self) -> str: + """Creates HMAC from sent card token callback body_dic + + Returns: + str: Generated HMAC + """ + if not isinstance(self.callback_obj_dict, dict): + return "" + + hmac_dict = { + "card_subtype": self.callback_obj_dict.get("card_subtype"), + "created_at": self.callback_obj_dict.get("created_at"), + "email": self.callback_obj_dict.get("email"), + "id": self.callback_obj_dict.get("id"), + "masked_pan": self.callback_obj_dict.get("masked_pan"), + "merchant_id": self.callback_obj_dict.get("merchant_id"), + "order_id": self.callback_obj_dict.get("order_id"), + "token": self.callback_obj_dict.get("token"), + } + + return self._generate_processed_hmac(hmac_dict=hmac_dict) + + def _generate_delivery_status_processed_hmac(self) -> str: + """Creates HMAC from sent Delivery Status callback body_dic + + Returns: + str: Generated HMAC + """ + if not isinstance(self.callback_obj_dict, dict): + return "" + + hmac_dict = { + "order_id": self.callback_obj_dict.get("order_id"), + "order_delivery_status": self.callback_obj_dict.get("order_delivery_status"), + "merchant_id": self.callback_obj_dict.get("merchant_id"), + "merchant_name": self.callback_obj_dict.get("merchant_name"), + "updated_at": self.callback_obj_dict.get("updated_at"), + } + + return self._generate_processed_hmac(hmac_dict=hmac_dict) + + # Public Method that can be used Directly to Validate HMAC + @property + def is_valid(self) -> bool: + """Validates HMAC for processed callback + + Returns: + bool: True if HMAC is Valid, False otherwise + """ + if not isinstance(self.callback_dict, dict): + return False + + callback_type = self.callback_dict.get("type") + if callback_type == AcceptCallbackTypes.TRANSACTION: + calculated_hmac = self._generate_transaction_processed_hmac() + elif callback_type == AcceptCallbackTypes.CARD_TOKEN: + calculated_hmac = self._generate_card_token_processed_hmac() + elif callback_type == AcceptCallbackTypes.DELIVERY_STATUS: + calculated_hmac = self._generate_delivery_status_processed_hmac() + else: + return False + + if calculated_hmac != self.incoming_hmac: + return False + + return True diff --git a/payments/payment_gateways/paymob/paymob_urls.py b/payments/payment_gateways/paymob/paymob_urls.py new file mode 100644 index 00000000..c866c032 --- /dev/null +++ b/payments/payment_gateways/paymob/paymob_urls.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass + +import frappe + + +@dataclass +class PaymobUrls: + base_url: str = "https://accept.paymob.com/" + + # Auth + auth: str = "api/auth/tokens" + + # Ecommerce + order: str = "api/ecommerce/orders" + inquire_transaction: str = "api/ecommerce/orders/transaction_inquiry" + tracking: str = "api/ecommerce/orders/{order_id}/delivery_status?token={token}" + preparing_package: str = "api/ecommerce/orders/{order_id}/airway_bill?token={token}" + + # Acceptance + payment_key: str = "api/acceptance/payment_keys" + payment: str = "api/acceptance/payments/pay" + capture: str = "api/acceptance/capture" + refund: str = "api/acceptance/void_refund/refund" + void: str = "api/acceptance/void_refund/void?token={token}" + retrieve_transaction: str = "api/acceptance/transactions/{id}" + retrieve_transactions: str = ( + "api/acceptance/portal-transactions?page={from_page}&page_size={page_size}&token={token}" + ) + loyalty_checkout: str = "api/acceptance/loyalty_checkout" + iframe: str = "api/acceptance/iframes/{iframe_id}?payment_token={payment_token}" + intention: str = "v1/intention/" + + def get_url(self, endpoint, **kwargs): + # based on available attributes and passed keyword arguments + return f"{self.base_url}{getattr(self, endpoint)}".format(**kwargs) + + +# Example usage +# paymob_urls = PaymobUrls() +# order_registration_url = paymob_urls.get_url("order") +# void_transaction_url = paymob_urls.get_url("void", token="your_token") +# tracking_url = paymob_urls.get_url("tracking", order_id="123", token="your_token") diff --git a/payments/payment_gateways/paymob/response_codes.py b/payments/payment_gateways/paymob/response_codes.py new file mode 100644 index 00000000..43f90e08 --- /dev/null +++ b/payments/payment_gateways/paymob/response_codes.py @@ -0,0 +1,16 @@ +# Response Codes +SUCCESS = 10 + +# Request Related Error Codes +JSON_DECODE_EXCEPTION = 20 +REQUEST_EXCEPTION = 21 +HTTP_EXCEPTION = 22 +UNHANDLED_EXCEPTION = 23 + + +# Error Messages Templates +JSON_DECODE_EXCEPTION_MESSAGE = "An Error Occurred While Parsing the Response into JSON" +REQUEST_EXCEPTION_MESSAGE = "An Error Occurred During the Request" +HTTP_EXCEPTION_MESSAGE = "Non 2xx Status Code Returned." +UNHANDLED_EXCEPTION_MESSAGE = "Unhandled Exception" +SUCCESS_MESSAGE = "API Successfully Called." diff --git a/payments/payment_gateways/paymob/response_feedback_dataclass.py b/payments/payment_gateways/paymob/response_feedback_dataclass.py new file mode 100644 index 00000000..42e41eba --- /dev/null +++ b/payments/payment_gateways/paymob/response_feedback_dataclass.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from typing import Any, Optional + + +@dataclass +class ResponseFeedBack: + message: str | None + data: Any = None + status_code: int = None + exception_error: str = None diff --git a/payments/payment_gateways/stripe_integration.py b/payments/payment_gateways/stripe_integration.py index 35c63c55..2d7e8a5d 100644 --- a/payments/payment_gateways/stripe_integration.py +++ b/payments/payment_gateways/stripe_integration.py @@ -1,8 +1,8 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -import stripe import frappe +import stripe from frappe import _ from frappe.integrations.utils import create_request_log diff --git a/payments/payments/doctype/payment_gateway/payment_gateway.js b/payments/payments/doctype/payment_gateway/payment_gateway.js index 0eff5a56..3e74b9cb 100644 --- a/payments/payments/doctype/payment_gateway/payment_gateway.js +++ b/payments/payments/doctype/payment_gateway/payment_gateway.js @@ -1,8 +1,6 @@ // Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -frappe.ui.form.on('Payment Gateway', { - refresh: function(frm) { - - } +frappe.ui.form.on("Payment Gateway", { + refresh: function (frm) {}, }); diff --git a/payments/public/js/razorpay.js b/payments/public/js/razorpay.js index c74d1be7..cf55b991 100644 --- a/payments/public/js/razorpay.js +++ b/payments/public/js/razorpay.js @@ -54,95 +54,110 @@ Razorpay Payment frappe.provide("frappe.checkout"); -frappe.require('https://checkout.razorpay.com/v1/checkout.js').then(() => { - frappe.checkout.razorpay = class RazorpayCheckout { - constructor(opts) { - Object.assign(this, opts); - } - - init() { - frappe.run_serially([ - () => this.get_key(), - () => this.make_order(), - () => this.prepare_options(), - () => this.setup_handler(), - () => this.show() - ]); - } - - show() { - this.razorpay = new Razorpay(this.options); - this.razorpay.once('ready', (response) => { - this.on_open && this.on_open(response); - }) - this.razorpay.open(); - } - - get_key() { - return new Promise(resolve => { - frappe.call("payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.get_api_key").then(res => { - this.key = res.message; - resolve(true); - }) - }); - } - - make_order() { - return new Promise(resolve => { - frappe.call("payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.get_order", { - doctype: this.doctype, - docname: this.docname - }).then(res => { - this.order = res.message; - resolve(true); - }) - }); - } - - order_success(response) { - frappe.call("payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.order_payment_success", { - integration_request: this.order.integration_request, - params: { - razorpay_payment_id: response.razorpay_payment_id, - razorpay_order_id: response.razorpay_order_id, - razorpay_signature: response.razorpay_signature - } - }) - } - - order_fail(response) { - frappe.call( "payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.order_payment_failure", { - integration_request: this.order.integration_request, - params: response - }) - } - - prepare_options() { - this.options = { - "key": this.key, - "amount": this.order.amount_due, - "currency": this.order.currency, - "name": this.name, - "description": this.description, - "image": this.image, - "order_id": this.order.id, - "prefill": this.prefill, - "theme": this.theme, - "modal": this.modal - }; - } - - setup_handler() { - this.options.handler = (response) => { - if (response.error) { - this.order_fail(response); - this.on_fail && this.on_fail(response); - } - else if (response.razorpay_payment_id) { - this.order_success(response); - this.on_success && this.on_success(response); - } - } - } - } +frappe.require("https://checkout.razorpay.com/v1/checkout.js").then(() => { + frappe.checkout.razorpay = class RazorpayCheckout { + constructor(opts) { + Object.assign(this, opts); + } + + init() { + frappe.run_serially([ + () => this.get_key(), + () => this.make_order(), + () => this.prepare_options(), + () => this.setup_handler(), + () => this.show(), + ]); + } + + show() { + // eslint-disable-next-line no-undef + this.razorpay = new Razorpay(this.options); + this.razorpay.once("ready", (response) => { + this.on_open && this.on_open(response); + }); + this.razorpay.open(); + } + + get_key() { + return new Promise((resolve) => { + frappe + .call( + "payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.get_api_key" + ) + .then((res) => { + this.key = res.message; + resolve(true); + }); + }); + } + + make_order() { + return new Promise((resolve) => { + frappe + .call( + "payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.get_order", + { + doctype: this.doctype, + docname: this.docname, + } + ) + .then((res) => { + this.order = res.message; + resolve(true); + }); + }); + } + + order_success(response) { + frappe.call( + "payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.order_payment_success", + { + integration_request: this.order.integration_request, + params: { + razorpay_payment_id: response.razorpay_payment_id, + razorpay_order_id: response.razorpay_order_id, + razorpay_signature: response.razorpay_signature, + }, + } + ); + } + + order_fail(response) { + frappe.call( + "payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.order_payment_failure", + { + integration_request: this.order.integration_request, + params: response, + } + ); + } + + prepare_options() { + this.options = { + key: this.key, + amount: this.order.amount_due, + currency: this.order.currency, + name: this.name, + description: this.description, + image: this.image, + order_id: this.order.id, + prefill: this.prefill, + theme: this.theme, + modal: this.modal, + }; + } + + setup_handler() { + this.options.handler = (response) => { + if (response.error) { + this.order_fail(response); + this.on_fail && this.on_fail(response); + } else if (response.razorpay_payment_id) { + this.order_success(response); + this.on_success && this.on_success(response); + } + }; + } + }; }); diff --git a/payments/templates/includes/razorpay_checkout.js b/payments/templates/includes/razorpay_checkout.js index a79a3dec..efbd2191 100644 --- a/payments/templates/includes/razorpay_checkout.js +++ b/payments/templates/includes/razorpay_checkout.js @@ -7,13 +7,13 @@ $(document).ready(function(){ "name": "{{ title }}", "description": "{{ description }}", "subscription_id": "{{ subscription_id }}", + "order_id": "{{ order_id }}", "handler": function (response){ razorpay.make_payment_log(response, options, "{{ reference_doctype }}", "{{ reference_docname }}", "{{ token }}"); }, "prefill": { "name": "{{ payer_name }}", - "email": "{{ payer_email }}", - "order_id": "{{ order_id }}" + "email": "{{ payer_email }}" }, "notes": {{ frappe.form_dict|json }} }; diff --git a/payments/templates/pages/braintree_checkout.py b/payments/templates/pages/braintree_checkout.py index 12b01369..bb7aa6dd 100644 --- a/payments/templates/pages/braintree_checkout.py +++ b/payments/templates/pages/braintree_checkout.py @@ -12,6 +12,11 @@ get_gateway_controller, ) +from payments.payment_gateways.doctype.braintree_settings.braintree_settings import ( + get_client_token, + get_gateway_controller, +) + no_cache = 1 expected_keys = ( @@ -40,9 +45,7 @@ def get_context(context): context["amount"] = flt(context["amount"]) gateway_controller = get_gateway_controller(context.reference_docname) - context["header_img"] = frappe.db.get_value( - "Braintree Settings", gateway_controller, "header_img" - ) + context["header_img"] = frappe.db.get_value("Braintree Settings", gateway_controller, "header_img") else: frappe.redirect_to_message( diff --git a/payments/templates/pages/gocardless_checkout.py b/payments/templates/pages/gocardless_checkout.py index fa780d23..89665995 100644 --- a/payments/templates/pages/gocardless_checkout.py +++ b/payments/templates/pages/gocardless_checkout.py @@ -38,9 +38,7 @@ def get_context(context): 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" - ) + context["header_img"] = frappe.db.get_value("GoCardless Settings", gateway_controller, "header_img") else: frappe.redirect_to_message( @@ -95,6 +93,6 @@ def check_mandate(data, reference_doctype, reference_docname): return {"redirect_to": redirect_flow.redirect_url} - except Exception as e: + except Exception: frappe.log_error("GoCardless Payment Error") return {"redirect_to": "payment-failed"} diff --git a/payments/templates/pages/gocardless_confirmation.py b/payments/templates/pages/gocardless_confirmation.py index 3fe7d99b..02420641 100644 --- a/payments/templates/pages/gocardless_confirmation.py +++ b/payments/templates/pages/gocardless_confirmation.py @@ -33,7 +33,6 @@ def get_context(context): @frappe.whitelist(allow_guest=True) def confirm_payment(redirect_flow_id, reference_doctype, reference_docname): - client = gocardless_initialization(reference_docname) try: @@ -59,7 +58,7 @@ def confirm_payment(redirect_flow_id, reference_doctype, reference_docname): try: create_mandate(data) - except Exception as e: + except Exception: frappe.log_error("GoCardless Mandate Registration Error") gateway_controller = get_gateway_controller(reference_docname) @@ -67,7 +66,7 @@ def confirm_payment(redirect_flow_id, reference_doctype, reference_docname): return {"redirect_to": confirmation_url} - except Exception as e: + except Exception: frappe.log_error("GoCardless Payment Error") return {"redirect_to": "payment-failed"} diff --git a/payments/templates/pages/payment_success.py b/payments/templates/pages/payment_success.py index 8985850a..700d258c 100644 --- a/payments/templates/pages/payment_success.py +++ b/payments/templates/pages/payment_success.py @@ -5,9 +5,10 @@ no_cache = True +no_cache = True + def get_context(context): - token = frappe.local.form_dict.token doc = frappe.get_doc(frappe.local.form_dict.doctype, frappe.local.form_dict.docname) context.payment_message = "" diff --git a/payments/templates/pages/razorpay_checkout.py b/payments/templates/pages/razorpay_checkout.py index 0b2e9adf..521a6d22 100644 --- a/payments/templates/pages/razorpay_checkout.py +++ b/payments/templates/pages/razorpay_checkout.py @@ -43,7 +43,7 @@ def get_context(context): payment_details["subscription_id"] if payment_details.get("subscription_id") else "" ) - except Exception as e: + except Exception: frappe.redirect_to_message( _("Invalid Token"), _("Seems token you are using is invalid!"), diff --git a/payments/templates/pages/stripe_checkout.html b/payments/templates/pages/stripe_checkout.html index e2cd58bb..b8b56065 100644 --- a/payments/templates/pages/stripe_checkout.html +++ b/payments/templates/pages/stripe_checkout.html @@ -17,23 +17,25 @@ {% if image %} {% endif %} -

{{description}}

+

+ {{description}} +

+ +
+ +
diff --git a/payments/templates/pages/stripe_checkout.py b/payments/templates/pages/stripe_checkout.py index afe25aa7..e4256867 100644 --- a/payments/templates/pages/stripe_checkout.py +++ b/payments/templates/pages/stripe_checkout.py @@ -1,6 +1,8 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import json +from frappe.integrations.doctype.stripe_settings.stripe_settings import get_gateway_controller +from frappe.utils import cint, fmt_money import frappe from frappe import _ @@ -49,6 +51,10 @@ def get_context(context): context["amount"] = context["amount"] + " " + _(recurrence) else: + frappe.log_error( + "Missing keys in form_dict", + "Expected keys: {}," "Received keys: {}".format(expected_keys, list(frappe.form_dict)), + ) frappe.redirect_to_message( _("Some information is missing"), _("Looks like someone sent you to an incomplete URL. Please ask them to look into it."), @@ -69,6 +75,7 @@ def get_header_image(doc, gateway_controller): return frappe.db.get_value("Stripe Settings", gateway_controller, "header_img") + @frappe.whitelist(allow_guest=True) def make_payment( stripe_token_id, data, reference_doctype=None, reference_docname=None, payment_gateway=None diff --git a/payments/utils/__init__.py b/payments/utils/__init__.py index fb540bd5..21e8ffde 100644 --- a/payments/utils/__init__.py +++ b/payments/utils/__init__.py @@ -2,6 +2,7 @@ before_install, create_payment_gateway, delete_custom_fields, + erpnext_app_import_guard, get_payment_gateway_controller, make_custom_fields, erpnext_app_import_guard, diff --git a/payments/utils/utils.py b/payments/utils/utils.py index 48ac7486..6e5abf5b 100644 --- a/payments/utils/utils.py +++ b/payments/utils/utils.py @@ -1,8 +1,21 @@ +from contextlib import contextmanager + import click import frappe from frappe import _ -from contextlib import contextmanager -from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +from frappe.custom.doctype.custom_field.custom_field import \ + create_custom_fields +from frappe.utils.data import cint + + +def validate_integration_request(docname: str | None): + if frappe.db.get_value("Integration Request", docname, "status") == "Cancelled": + frappe.throw(_("Expired Token")) + + +def validate_integration_request(docname: str | None): + if frappe.db.get_value("Integration Request", docname, "status") == "Cancelled": + frappe.throw(_("Expired Token")) def validate_integration_request(docname: str | None): @@ -159,6 +172,8 @@ def make_custom_fields(): create_custom_fields(custom_fields) + frappe.clear_cache(doctype="Web Form") + def delete_custom_fields(): if not frappe.get_meta("Web Form").has_field("payments_tab"): diff --git a/pyproject.toml b/pyproject.toml index f6105091..12b522c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,20 +14,50 @@ dependencies = [ "braintree~=4.20.0", "pycryptodome>=3.18.0,<4.0.0", "gocardless-pro~=1.22.0", + "setuptools==80.9.0", ] [build-system] requires = ["flit_core >=3.4,<4"] build-backend = "flit_core.buildapi" -[tool.black] -line-length = 99 +[tool.ruff] +line-length = 110 +target-version = "py310" -[tool.isort] -line_length = 99 -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true -ensure_newline_before_comments = true -indent = "\t" +[tool.ruff.lint] +select = [ + "F", + "E", + "W", + "I", + "UP", + "B", + "RUF", +] +ignore = [ + "B017", # assertRaises(Exception) - should be more specific + "B018", # useless expression, not assigned to anything + "B023", # function doesn't bind loop variable - will have last iteration's value + "B904", # raise inside except without from + "E101", # indentation contains mixed spaces and tabs + "E402", # module level import not at top of file + "E501", # line too long + "E741", # ambiguous variable name + "F401", # "unused" imports + "F403", # can't detect undefined names from * import + "F405", # can't detect undefined names from * import + "F722", # syntax error in forward type annotation + "W191", # indentation contains tabs + "RUF001", # string contains ambiguous unicode character +] +typing-modules = ["frappe.types.DF"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "tab" +docstring-code-format = true + +[project.urls] +Repository = "https://github.com/frappe/payments.git" +"Bug Reports" = "https://github.com/frappe/payments/issues"