diff --git a/payments/payment_gateways/chapa_integration.py b/payments/payment_gateways/chapa_integration.py new file mode 100644 index 00000000..3699178b --- /dev/null +++ b/payments/payment_gateways/chapa_integration.py @@ -0,0 +1,29 @@ +import frappe +import requests + + +class ChapaGateway: + BASE_URL = "https://api.chapa.co/v1" + + def __init__(self): + settings = frappe.get_single("Chapa Settings") + + self.secret = settings.get_password("secret_key") + + self.headers = {"Authorization": f"Bearer {self.secret}"} + + def initialize(self, data): + + payload = { + "amount": str(data.amount), + "currency": "ETB", + "email": data.payer_email, + "first_name": data.payer_name, + "tx_ref": data.reference_doctype + "-" + data.reference_docname, + "callback_url": data.callback_url, + "return_url": data.return_url, + } + + r = requests.post(self.BASE_URL + "/transaction/initialize", json=payload, headers=self.headers) + + return r.json() diff --git a/payments/payment_gateways/doctype/chapa_settings/__init__.py b/payments/payment_gateways/doctype/chapa_settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/payment_gateways/doctype/chapa_settings/chapa.py b/payments/payment_gateways/doctype/chapa_settings/chapa.py new file mode 100644 index 00000000..c935456b --- /dev/null +++ b/payments/payment_gateways/doctype/chapa_settings/chapa.py @@ -0,0 +1,15 @@ +import frappe + + +class Chapa: + def __init__(self): + self.settings = frappe.get_cached_doc("Chapa Settings") + + def validate_transaction_currency(self, currency): + self.settings.validate_transaction_currency(currency) + + def get_payment_url(self, **kwargs): + return self.settings.get_payment_url(**kwargs) + + def create_request(self, data): + return self.settings.create_request(data) diff --git a/payments/payment_gateways/doctype/chapa_settings/chapa_settings.js b/payments/payment_gateways/doctype/chapa_settings/chapa_settings.js new file mode 100644 index 00000000..96612bd2 --- /dev/null +++ b/payments/payment_gateways/doctype/chapa_settings/chapa_settings.js @@ -0,0 +1,4 @@ +// Copyright (c) 2018, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Chapa Settings", {}); diff --git a/payments/payment_gateways/doctype/chapa_settings/chapa_settings.json b/payments/payment_gateways/doctype/chapa_settings/chapa_settings.json new file mode 100644 index 00000000..d7a384b2 --- /dev/null +++ b/payments/payment_gateways/doctype/chapa_settings/chapa_settings.json @@ -0,0 +1,303 @@ +{ + "allow_copy": 0, + "allow_guest_to_view": 0, + "allow_import": 0, + "allow_rename": 0, + "autoname": "field:gateway_name", + "beta": 0, + "creation": "2018-02-05 13:46:12.101852", + "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": 0, + "in_standard_filter": 0, + "label": "Payment Gateway Name", + "length": 0, + "no_copy": 0, + "options": "Company", + "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": "merchant_id", + "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": "Merchant ID", + "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": "public_key", + "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": "Public Key", + "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": "secret_key", + "fieldtype": "Password", + "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": "Secret Key", + "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": "webhook_secret", + "fieldtype": "Password", + "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": "Webhook 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": 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": "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 + }, + { + "allow_bulk_edit": 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, + "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": "2022-07-24 14:33:06.050377", + "modified_by": "Administrator", + "module": "Payment Gateways", + "name": "Chapa Settings", + "name_case": "", + "owner": "Administrator", + "permissions": [ + { + "amend": 0, + "apply_user_permissions": 0, + "cancel": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 0, + "if_owner": 0, + "import": 0, + "permlevel": 0, + "print": 1, + "read": 1, + "report": 0, + "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": 0, + "track_seen": 0 +} \ No newline at end of file diff --git a/payments/payment_gateways/doctype/chapa_settings/chapa_settings.py b/payments/payment_gateways/doctype/chapa_settings/chapa_settings.py new file mode 100644 index 00000000..3d2c8401 --- /dev/null +++ b/payments/payment_gateways/doctype/chapa_settings/chapa_settings.py @@ -0,0 +1,167 @@ +# Copyright (c) 2026, TechVision and contributors +# License: MIT + +import uuid +import requests +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import get_url + +from payments.utils import create_payment_gateway + + +class ChapaSettings(Document): + + supported_currencies = ("ETB",) + + + # ----------------------------- + # REGISTER GATEWAY + # ----------------------------- + def on_update(self): + create_payment_gateway( + "Chapa", + settings="Chapa Settings", + controller="ChapaSettings", + ) + + self.validate_credentials() + + # ----------------------------- + # VALIDATE SECRET KEY + # ----------------------------- + def validate_credentials(self): + if not self.secret_key: + return + + headers = { + "Authorization": f"Bearer {self.get_password('secret_key')}" + } + + try: + r = requests.get( + "https://api.chapa.co/v1/banks", + headers=headers, + timeout=10, + ) + + if r.status_code != 200: + frappe.throw(_("Invalid Chapa Secret Key")) + + except requests.RequestException: + frappe.throw(_("Unable to connect to Chapa API")) + + # ----------------------------- + # CALLED BY LMS / PAYMENT FLOW + # ----------------------------- + def get_payment_url(self, **kwargs): + self.validate_transaction_currency(kwargs.get("currency")) + + self.data = frappe._dict(kwargs) + + return self.initialize_transaction() + + # ----------------------------- + # INIT PAYMENT (CHAPA API) + # ----------------------------- + def initialize_transaction(self): + url = "https://api.chapa.co/v1/transaction/initialize" + + headers = { + "Authorization": f"Bearer {self.get_password('secret_key')}", + "Content-Type": "application/json", + } + + tx_ref = f"{self.data.reference_docname}-{uuid.uuid4().hex[:10]}" + + payload = { + "amount": str(self.data.amount), + "currency": "ETB", + "email": self.data.payer_email or "mehariwamlake@gmail.com", + "first_name": self.data.payer_name or "User", + "last_name": "", + "phone_number": self.data.get("phone_number", ""), + + "tx_ref": tx_ref, + + "callback_url": get_url( + "/api/method/payments.payment_gateways.doctype.chapa_settings.chapa_settings.verify_payment" + ), + + "return_url": get_url(self.data.redirect_to or "/"), + + "customization": { + "title": self.data.title or "LMS Payment", + "description": self.data.description or "", + }, + } + + try: + response = requests.post( + url, + json=payload, + headers=headers, + timeout=30, + ) + + # IMPORTANT DEBUG + if response.status_code != 200: + frappe.log_error(response.text, "Chapa API Error") + frappe.throw(_("Chapa API request failed")) + + result = response.json() + print(result) + + if result.get("status") != "success": + frappe.log_error(str(result), "Chapa Init Error") + frappe.throw(_("Chapa rejected the request")) + + # 🔥 CRITICAL: redirect LMS directly + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = result["data"]["checkout_url"] + + return + + except Exception: + frappe.log_error(frappe.get_traceback(), "Chapa Initialize Error") + frappe.throw(_("Unable to initialize Chapa payment")) + + # ----------------------------- + # VERIFY PAYMENT (WEBHOOK) + # ----------------------------- + @frappe.whitelist(allow_guest=True) + def verify_payment(self): + tx_ref = frappe.form_dict.get("tx_ref") + + if not tx_ref: + frappe.throw(_("Missing tx_ref")) + + headers = { + "Authorization": f"Bearer {self.get_password('secret_key')}" + } + + r = requests.get( + f"https://api.chapa.co/v1/transaction/verify/{tx_ref}", + headers=headers, + timeout=15, + ) + + r.raise_for_status() + data = r.json() + + if data.get("status") != "success": + frappe.throw(_("Payment verification failed")) + + return { + "status": "success", + "tx_ref": tx_ref, + "data": data.get("data"), + } + + # ----------------------------- + # VALIDATION + # ----------------------------- + def validate_transaction_currency(self, currency): + if currency and currency not in self.supported_currencies: + frappe.throw(_("Chapa only supports ETB transactions")) \ No newline at end of file diff --git a/payments/payment_gateways/doctype/chapa_settings/gateway_controller.json b/payments/payment_gateways/doctype/chapa_settings/gateway_controller.json new file mode 100644 index 00000000..81fea659 --- /dev/null +++ b/payments/payment_gateways/doctype/chapa_settings/gateway_controller.json @@ -0,0 +1,6 @@ +[ + { + "doctype": "Gateway Controller", + "name": "Chapa" + } +] \ No newline at end of file diff --git a/payments/payment_gateways/doctype/chapa_settings/test_chapa_settings.py b/payments/payment_gateways/doctype/chapa_settings/test_chapa_settings.py new file mode 100644 index 00000000..4b35ca53 --- /dev/null +++ b/payments/payment_gateways/doctype/chapa_settings/test_chapa_settings.py @@ -0,0 +1,7 @@ +# Copyright (c) 2018, Frappe Technologies and Contributors +# License: MIT. See LICENSE +import unittest + + +class TestChapaSettings(unittest.TestCase): + pass diff --git a/payments/templates/pages/chapa_checkout.html b/payments/templates/pages/chapa_checkout.html new file mode 100644 index 00000000..e69de29b diff --git a/payments/templates/pages/chapa_checkout.py b/payments/templates/pages/chapa_checkout.py new file mode 100644 index 00000000..797d2650 --- /dev/null +++ b/payments/templates/pages/chapa_checkout.py @@ -0,0 +1,57 @@ +import frappe +import requests +import uuid + + +CHAPA_URL = "https://api.chapa.co/v1/transaction/initialize" + + +@frappe.whitelist(allow_guest=True) +def chapa_checkout( + amount, + title, + description, + reference_doctype=None, + reference_docname=None, + payer_email=None, + payer_name=None, + currency="ETB", + redirect_to="/", +): + """ + Create Chapa payment session and return checkout URL + """ + + # 🔐 Get API key from settings (create doctype if needed) + settings = frappe.get_single("Chapa Settings") + secret_key = settings.secret_key + + # 🔑 unique transaction reference + tx_ref = f"{self.data.reference_docname}-{uuid.uuid4().hex[:10]}" + + payload = { + "amount": float(amount), + "currency": currency, + "email": payer_email or "mehariwamlake@gmail.com", + "first_name": payer_name or "Guest", + "last_name": "User", + "tx_ref": tx_ref, + "callback_url": frappe.utils.get_url( + f"/api/method/payments.payment_gateways.doctype.chapa_settings.chapa_settings.chapa_callback" + ), + "return_url": frappe.utils.get_url(redirect_to), + "customization": {"title": title, "description": description}, + } + + headers = {"Authorization": f"Bearer {secret_key}", "Content-Type": "application/json"} + + response = requests.post(CHAPA_URL, json=payload, headers=headers) + data = response.json() + + if response.status_code != 200 or data.get("status") != "success": + frappe.throw(f"Chapa Error: {data}") + + # 🔥 Redirect user to checkout page + checkout_url = data["data"]["checkout_url"] + + return {"checkout_url": checkout_url, "tx_ref": tx_ref} diff --git a/pyproject.toml b/pyproject.toml index 541f6434..725abb51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ authors = [ { name = "Frappe Technologies Pvt Ltd", email = "hello@frappe.io"} ] description = "Payments app for frappe" -requires-python = ">=3.14" +requires-python = ">=3.11" readme = "README.md" dynamic = ["version"] dependencies = [ @@ -63,4 +63,4 @@ Repository = "https://github.com/frappe/payments.git" "Bug Reports" = "https://github.com/frappe/payments/issues" [tool.bench.frappe-dependencies] -frappe = ">=17.0.0-dev,<18.0.0" +frappe = ">=15.0.0,<18.0.0"