From 89262d41a75ba2b1771415fc7930a6ca1efb9ba6 Mon Sep 17 00:00:00 2001 From: Akumbom Wesley Date: Mon, 16 Mar 2026 12:04:22 +0000 Subject: [PATCH 1/3] feat: Add MTN MoMo payment gateway integration - Add MoMo Settings DocType with credentials management - Add MomoConnector class wrapping MTN MoMo Collections API - Add momo_checkout page for customer phone number entry - Add verify_transaction IPN callback endpoint - Add poll_pending_transactions scheduler job - Add Payment Request JS button to open checkout page - Register gateway via payments.utils.create_payment_gateway --- payments/hooks.py | 4 + .../doctype/momo_settings/__init__.py | 0 .../doctype/momo_settings/momo_connector.json | 196 +++++++++ .../doctype/momo_settings/momo_connector.py | 186 +++++++++ .../doctype/momo_settings/momo_settings.js | 101 +++++ .../doctype/momo_settings/momo_settings.json | 114 ++++++ .../doctype/momo_settings/momo_settings.py | 379 ++++++++++++++++++ payments/public/js/payment_request.js | 24 ++ payments/templates/pages/momo_checkout.html | 295 ++++++++++++++ payments/templates/pages/momo_checkout.py | 59 +++ 10 files changed, 1358 insertions(+) create mode 100644 payments/payment_gateways/doctype/momo_settings/__init__.py create mode 100644 payments/payment_gateways/doctype/momo_settings/momo_connector.json create mode 100644 payments/payment_gateways/doctype/momo_settings/momo_connector.py create mode 100644 payments/payment_gateways/doctype/momo_settings/momo_settings.js create mode 100644 payments/payment_gateways/doctype/momo_settings/momo_settings.json create mode 100644 payments/payment_gateways/doctype/momo_settings/momo_settings.py create mode 100644 payments/public/js/payment_request.js create mode 100644 payments/templates/pages/momo_checkout.html create mode 100644 payments/templates/pages/momo_checkout.py diff --git a/payments/hooks.py b/payments/hooks.py index c6039c777..fbaf4d3d1 100644 --- a/payments/hooks.py +++ b/payments/hooks.py @@ -30,6 +30,7 @@ # include js in doctype views # doctype_js = {"doctype" : "public/js/doctype.js"} +doctype_js = {"Payment Request": "public/js/payment_request.js"} # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} @@ -115,6 +116,9 @@ "all": [ "payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.capture_payment", ], + "hourly": [ + "payments.payment_gateways.doctype.momo_settings.momo_settings.poll_pending_transactions", + ], } # Testing diff --git a/payments/payment_gateways/doctype/momo_settings/__init__.py b/payments/payment_gateways/doctype/momo_settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/payments/payment_gateways/doctype/momo_settings/momo_connector.json b/payments/payment_gateways/doctype/momo_settings/momo_connector.json new file mode 100644 index 000000000..2543fa2da --- /dev/null +++ b/payments/payment_gateways/doctype/momo_settings/momo_connector.json @@ -0,0 +1,196 @@ +""" +MomoConnector — MTN Mobile Money Collection API client. + +Mirrors the MpesaConnector pattern from: +payments/payment_gateways/doctype/mpesa_settings/mpesa_connector.py + +MTN MoMo API reference: +https://momodeveloper.mtn.com/docs/services/collection +""" + +import base64 +import uuid + +import requests + + +class MomoConnector: + """ + Wraps the MTN MoMo Collection API. + + Usage:: + + conn = MomoConnector( + env="sandbox", + api_user_id="", + api_key="", + subscription_key="", + target_environment="sandbox", + ) + + result = conn.request_to_pay( + amount=5000, + currency="UGX", + payer_msisdn="256700000000", + external_id="INV-0001", + callback_url="https://your-site.com/api/method/...verify_transaction", + ) + + # result = {"referenceId": "", "status": "PENDING"} + """ + + SANDBOX_URL = "https://sandbox.momodeveloper.mtn.com" + PRODUCTION_URL = "https://proxy.momoapi.mtn.com" + + def __init__( + self, + env="sandbox", + api_user_id=None, + api_key=None, + subscription_key=None, + target_environment="sandbox", + ): + self.env = env + self.api_user_id = api_user_id + self.api_key = api_key + self.subscription_key = subscription_key + self.target_environment = target_environment + + self.base_url = ( + self.SANDBOX_URL if env == "sandbox" else self.PRODUCTION_URL + ) + + self.access_token = None + + # Authenticate immediately + self.authenticate() + + # ── Authentication ────────────────────────────────────────────── + + def authenticate(self): + """ + Obtain a Bearer token from the MTN MoMo token endpoint. + + POST /collection/token/ + + Headers: + Authorization: Basic base64(api_user_id:api_key) + Ocp-Apim-Subscription-Key: + + Returns: + access_token (str) + """ + url = f"{self.base_url}/collection/token/" + + credentials = base64.b64encode( + f"{self.api_user_id}:{self.api_key}".encode() + ).decode() + + headers = { + "Authorization": f"Basic {credentials}", + "Ocp-Apim-Subscription-Key": self.subscription_key, + } + + response = requests.post(url, headers=headers) + response.raise_for_status() + + self.access_token = response.json()["access_token"] + return self.access_token + + # ── Collection endpoints ───────────────────────────────────────── + + def request_to_pay( + self, + amount, + currency, + payer_msisdn, + external_id, + callback_url, + payer_message="Payment", + payee_note="Thank you", + ): + """ + Initiate a Request-to-Pay (debit) from the payer's mobile wallet. + + POST /collection/v1_0/requesttopay + + Returns: + {"referenceId": "", "status": "PENDING"} on 202 Accepted. + + Args: + amount: Numeric amount (int or Decimal) + currency: ISO 4217 currency code, e.g. "UGX" + payer_msisdn: Customer mobile number in international format + e.g. "256700123456" + external_id: Your internal reference (invoice/order ID) + callback_url: HTTPS endpoint MTN will POST transaction results + payer_message: Text shown to payer during USSD prompt + payee_note: Internal note stored on the MTN side + """ + + reference_id = str(uuid.uuid4()) + url = f"{self.base_url}/collection/v1_0/requesttopay" + + headers = { + "Authorization": f"Bearer {self.access_token}", + "X-Reference-Id": reference_id, + "X-Target-Environment": self.target_environment, + "Ocp-Apim-Subscription-Key": self.subscription_key, + "X-Callback-Url": callback_url, + "Content-Type": "application/json", + } + + payload = { + "amount": str(amount), # MTN API requires string + "currency": currency, + "externalId": external_id, + "payer": { + "partyIdType": "MSISDN", + "partyId": payer_msisdn, + }, + "payerMessage": payer_message, + "payeeNote": payee_note, + } + + response = requests.post(url, headers=headers, json=payload) + + if response.status_code == 202: + return {"referenceId": reference_id, "status": "PENDING"} + + # For any other status code, raise exception + response.raise_for_status() + + def get_transaction_status(self, reference_id): + """ + Poll the status of a previously initiated Request-to-Pay. + + GET /collection/v1_0/requesttopay/{referenceId} + + Example response: + { + "financialTransactionId": "...", + "externalId": "...", + "amount": "5000", + "currency": "UGX", + "payer": {"partyIdType": "MSISDN", "partyId": "256700..."}, + "status": "SUCCESSFUL" + } + + Status values: + SUCCESSFUL + FAILED + PENDING + """ + + url = f"{self.base_url}/collection/v1_0/requesttopay/{reference_id}" + + headers = { + "Authorization": f"Bearer {self.access_token}", + "X-Target-Environment": self.target_environment, + "Ocp-Apim-Subscription-Key": self.subscription_key, + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + return response.json() diff --git a/payments/payment_gateways/doctype/momo_settings/momo_connector.py b/payments/payment_gateways/doctype/momo_settings/momo_connector.py new file mode 100644 index 000000000..aea8ca12a --- /dev/null +++ b/payments/payment_gateways/doctype/momo_settings/momo_connector.py @@ -0,0 +1,186 @@ +""" +MomoConnector — MTN Mobile Money Collection API client. + +Mirrors the MpesaConnector pattern from: + payments/payment_gateways/doctype/mpesa_settings/mpesa_connector.py + +MTN MoMo API reference: + https://momodeveloper.mtn.com/docs/services/collection +""" + +import base64 +import uuid + +import requests + + +class MomoConnector: + """ + Wraps the MTN MoMo Collection API. + + Usage:: + + conn = MomoConnector( + env="sandbox", + api_user_id="", + api_key="", + subscription_key="", + target_environment="sandbox", + ) + + result = conn.request_to_pay( + amount=5000, + currency="UGX", + payer_msisdn="256700000000", + external_id="INV-0001", + callback_url="https://your-site.com/api/method/...verify_transaction", + ) + + # result = {"referenceId": "", "status": "PENDING"} + """ + + SANDBOX_URL = "https://sandbox.momodeveloper.mtn.com" + PRODUCTION_URL = "https://proxy.momoapi.mtn.com" + + def __init__( + self, + env="sandbox", + api_user_id=None, + api_key=None, + subscription_key=None, + target_environment="sandbox", + ): + self.env = env + self.api_user_id = api_user_id + self.api_key = api_key + self.subscription_key = subscription_key + self.target_environment = target_environment + self.base_url = ( + self.SANDBOX_URL if env == "sandbox" else self.PRODUCTION_URL + ) + self.access_token = None + self.authenticate() + + # ── Authentication ────────────────────────────────────────────── + + def authenticate(self): + """ + Obtain a Bearer token from the MTN MoMo token endpoint. + + POST /collection/token/ + + Authorization: Basic base64(api_user_id:api_key) + Ocp-Apim-Subscription-Key: + + Returns the access_token string and stores it in self.access_token. + """ + + url = f"{self.base_url}/collection/token/" + + credentials = base64.b64encode( + f"{self.api_user_id}:{self.api_key}".encode() + ).decode() + + headers = { + "Authorization": f"Basic {credentials}", + "Ocp-Apim-Subscription-Key": self.subscription_key, + } + + response = requests.post(url, headers=headers) + response.raise_for_status() + + self.access_token = response.json()["access_token"] + + return self.access_token + + # ── Collection endpoints ───────────────────────────────────────── + + def request_to_pay( + self, + amount, + currency, + payer_msisdn, + external_id, + callback_url, + payer_message="Payment", + payee_note="Thank you", + ): + """ + Initiate a Request-to-Pay (debit) from the payer's mobile wallet. + + POST /collection/v1_0/requesttopay + + Returns {"referenceId": "", "status": "PENDING"} on 202 Accepted. + + :param amount: Numeric amount (int or Decimal) + :param currency: ISO 4217 currency code, e.g. "UGX" + :param payer_msisdn: Customer's mobile number in international format, e.g. "256700123456" + :param external_id: Your internal reference (invoice number, order ID, etc.) + :param callback_url: HTTPS URL MTN will POST the result to + :param payer_message: Text shown to payer in the USSD prompt + :param payee_note: Internal note stored on the MTN side + """ + + reference_id = str(uuid.uuid4()) + + url = f"{self.base_url}/collection/v1_0/requesttopay" + + headers = { + "Authorization": f"Bearer {self.access_token}", + "X-Reference-Id": reference_id, + "X-Target-Environment": self.target_environment, + "Ocp-Apim-Subscription-Key": self.subscription_key, + "X-Callback-Url": callback_url, + "Content-Type": "application/json", + } + + payload = { + "amount": str(amount), # MTN API requires string + "currency": currency, + "externalId": external_id, + "payer": { + "partyIdType": "MSISDN", + "partyId": payer_msisdn, + }, + "payerMessage": payer_message, + "payeeNote": payee_note, + } + + response = requests.post(url, headers=headers, json=payload) + + if response.status_code == 202: + return {"referenceId": reference_id, "status": "PENDING"} + + # For any other status code, raise so callers can handle it + response.raise_for_status() + + def get_transaction_status(self, reference_id): + """ + Poll the status of a previously initiated Request-to-Pay. + + GET /collection/v1_0/requesttopay/{referenceId} + + Returns the full MTN status object, e.g.: + + { + "financialTransactionId": "...", + "externalId": "...", + "amount": "5000", + "currency": "UGX", + "payer": {"partyIdType": "MSISDN", "partyId": "256700..."}, + "status": "SUCCESSFUL" # or "FAILED" / "PENDING" + } + """ + + url = f"{self.base_url}/collection/v1_0/requesttopay/{reference_id}" + + headers = { + "Authorization": f"Bearer {self.access_token}", + "X-Target-Environment": self.target_environment, + "Ocp-Apim-Subscription-Key": self.subscription_key, + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + return response.json() diff --git a/payments/payment_gateways/doctype/momo_settings/momo_settings.js b/payments/payment_gateways/doctype/momo_settings/momo_settings.js new file mode 100644 index 000000000..8139fdbde --- /dev/null +++ b/payments/payment_gateways/doctype/momo_settings/momo_settings.js @@ -0,0 +1,101 @@ +// MoMo Settings — form controller +// Mirrors mpesa_settings.js pattern + +frappe.ui.form.on("MoMo Settings", { + + refresh: function (frm) { + + // Show the callback URL read-only field + frm.set_df_property("callback_url", "read_only", 1); + + if (!frm.doc.__islocal) { + + // ── Test Connection button ────────────────────────────── + frm.add_custom_button(__("Test Connection"), function () { + + // We poll a dummy reference to verify credentials are valid. + // A 404 from MTN (not a 401) means credentials are correct — + // the reference just doesn't exist yet. + + frappe.confirm( + __("This will make a live API call to MTN MoMo to verify your credentials. Continue?"), + function () { + + frappe.call({ + method: + "payments.payment_gateways.doctype.momo_settings" + + ".momo_settings.poll_transaction_status", + + args: { + reference_id: "00000000-0000-0000-0000-000000000000", + gateway_name: frm.doc.name, + }, + + freeze: true, + freeze_message: __("Connecting to MTN MoMo API..."), + + callback: function (r) { + if (!r.exc) { + frappe.msgprint({ + title: __("Connection Successful"), + message: + __("MTN MoMo credentials are valid. Response: ") + + JSON.stringify(r.message || {}), + indicator: "green", + }); + } + }, + + error: function (r) { + frappe.msgprint({ + title: __("Connection Failed"), + message: __( + "Could not connect to MTN MoMo. Check API User ID, API Key, and Subscription Key. See Error Log for details." + ), + indicator: "red", + }); + }, + }); + } + ); + }, __("MTN MoMo")); + + // ── Poll Pending Transactions button ───────────────────── + frm.add_custom_button(__("Poll Pending Transactions"), function () { + + frappe.call({ + method: + "payments.payment_gateways.doctype.momo_settings" + + ".momo_settings.poll_pending_transactions", + + freeze: true, + freeze_message: __("Polling MTN MoMo for pending transactions..."), + + callback: function (r) { + frappe.msgprint( + __("Polling complete. Check the Integration Request list for updated statuses.") + ); + }, + }); + + }, __("MTN MoMo")); + } + + // Sandbox warning + if (frm.doc.use_sandbox) { + frm.dashboard.set_headline_alert( + __( + "⚠ Sandbox mode is ON. Payments are not real. " + + "Switch off 'Use Sandbox' and set Target Environment for production." + ), + "orange" + ); + } + }, + + use_sandbox: function (frm) { + if (frm.doc.use_sandbox) { + frm.set_value("target_environment", "sandbox"); + } + }, +}); diff --git a/payments/payment_gateways/doctype/momo_settings/momo_settings.json b/payments/payment_gateways/doctype/momo_settings/momo_settings.json new file mode 100644 index 000000000..221ad065f --- /dev/null +++ b/payments/payment_gateways/doctype/momo_settings/momo_settings.json @@ -0,0 +1,114 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:gateway_name", + "creation": "2024-01-01 00:00:00.000000", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "gateway_name", + "api_user_id", + "api_key", + "subscription_key", + "target_environment", + "callback_url", + "enabled", + "column_break_1", + "use_sandbox", + "supported_currencies" + ], + "fields": [ + { + "fieldname": "gateway_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Gateway Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "api_user_id", + "fieldtype": "Data", + "label": "API User ID", + "reqd": 1 + }, + { + "fieldname": "api_key", + "fieldtype": "Password", + "label": "API Key", + "reqd": 1 + }, + { + "fieldname": "subscription_key", + "fieldtype": "Password", + "label": "Subscription Key (Ocp-Apim-Subscription-Key)", + "reqd": 1 + }, + { + "fieldname": "target_environment", + "fieldtype": "Select", + "label": "Target Environment", + "options": "sandbox\nmtnuganda\nmtnghana\nmtncameroon\nmtnzambia\nmtnliberia\nmtnrwanda\nmtnbenin\nmtncongodrc", + "default": "sandbox" + }, + { + "fieldname": "callback_url", + "fieldtype": "Data", + "label": "Callback URL", + "read_only": 1, + "description": "Auto-populated on save. Must be publicly reachable (HTTPS) for MTN to call back." + }, + { + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled", + "default": "1" + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "use_sandbox", + "fieldtype": "Check", + "label": "Use Sandbox", + "default": "1", + "description": "When checked, all requests go to sandbox.momodeveloper.mtn.com and target_environment is forced to 'sandbox'." + }, + { + "fieldname": "supported_currencies", + "fieldtype": "Small Text", + "label": "Supported Currencies", + "description": "Comma-separated ISO 4217 currency codes accepted by this gateway, e.g.: UGX,GHS,XAF,ZMW,LRD,RWF,BIF,EUR", + "default": "UGX,GHS,XAF,ZMW,LRD,RWF,BIF,EUR" + } + ], + "links": [], + "modified": "2024-01-01 00:00:00.000000", + "modified_by": "Administrator", + "module": "Payment Gateways", +"name": "MoMo Settings", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} + diff --git a/payments/payment_gateways/doctype/momo_settings/momo_settings.py b/payments/payment_gateways/doctype/momo_settings/momo_settings.py new file mode 100644 index 000000000..b3d63a32b --- /dev/null +++ b/payments/payment_gateways/doctype/momo_settings/momo_settings.py @@ -0,0 +1,379 @@ +""" +MoMo Settings — DocType controller for MTN Mobile Money gateway. + +Architecture: + Mirrors mpesa_settings.py (on_update, request_for_payment, create_mode_of_payment) + and gocardless_settings.py (validate_transaction_currency, get_payment_url). + + Key hook into payments.utils: + create_payment_gateway("MoMo-" + self.gateway_name, ...) + → inserts a Payment Gateway doc that points back to this Settings doc, + so get_payment_gateway_controller("MoMo-Uganda") returns + frappe.get_doc("MoMo Settings", "Uganda"). +""" + +import frappe +from frappe import _ +from frappe.integrations.utils import create_request_log +from frappe.model.document import Document +from frappe.utils import call_hook_method, get_request_site_address, get_url +from urllib.parse import urlencode + +from payments.payment_gateways.doctype.momo_settings.momo_connector import MomoConnector +from payments.utils import erpnext_app_import_guard + + +class MoMoSettings(Document): + """ + Controller for the 'MoMo Settings' DocType. + + One document per MTN MoMo gateway instance (one per country/environment). + The document name equals gateway_name (autoname: field:gateway_name). + + Example document: + gateway_name = "Uganda" + api_user_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + api_key = "••••••••••••" + subscription_key = "••••••••••••••••••••••••••••••••" + target_environment = "mtnuganda" + use_sandbox = 0 + supported_currencies = "UGX" + """ + + # ── Currency validation ────────────────────────────────────────── + + @property + def supported_currencies_list(self): + """Return the supported_currencies field as a clean list.""" + raw = self.supported_currencies or "UGX,GHS,XAF,ZMW,LRD,RWF,BIF,EUR" + return [c.strip() for c in raw.split(",") if c.strip()] + + def validate_transaction_currency(self, currency): + """ + Called by ERPNext Payment Request before rendering the payment URL. + Mirrors GoCardless pattern from gocardless_settings.py. + """ + if currency not in self.supported_currencies_list: + frappe.throw( + _( + "Please select another payment method. " + "MTN MoMo does not support transactions in currency '{0}'. " + "Supported: {1}" + ).format(currency, ", ".join(self.supported_currencies_list)) + ) + + # ── Gateway registration ───────────────────────────────────────── + + def on_update(self): + """ + Registers this gateway in the Payment Gateway DocType registry and + creates a Mode of Payment entry. + + Mirrors mpesa_settings.py on_update() exactly. + + After this runs, get_payment_gateway_controller("MoMo-Uganda") works. + """ + from payments.utils import create_payment_gateway + + create_payment_gateway( + "MoMo-" + self.gateway_name, + settings="MoMo Settings", + controller=self.gateway_name, + ) + + call_hook_method( + "payment_gateway_enabled", + gateway="MoMo-" + self.gateway_name, + payment_channel="", + ) + + create_mode_of_payment( + "MoMo-" + self.gateway_name, + payment_type="Phone", + ) + + callback = ( + frappe.utils.get_url() + + "/api/method/payments.payment_gateways.doctype.momo_settings" + ".momo_settings.verify_transaction" + ) + + self.db_set("callback_url", callback, update_modified=False) + + frappe.db.commit() + + # ── Checkout URL ───────────────────────────────────────────────── + + def get_payment_url(self, **kwargs): + """ + Returns the URL of the MoMo checkout page, with all payment parameters + encoded as query-string arguments. + + Mirrors GoCardless pattern: + return get_url(f"gocardless_checkout?{urlencode(kwargs)}") + """ + kwargs.setdefault("payment_request_name", kwargs.get("order_id", "")) + return get_url(f"momo_checkout?{urlencode(kwargs)}") + + # ── Payment initiation ──────────────────────────────────────────── + + def request_for_payment(self, **kwargs): + """ + Initiates a Request-to-Pay via MomoConnector and creates an + Integration Request record to track the transaction. + + Expected kwargs (populated by momo_checkout.html form post): + - request_amount + - currency + - sender + - order_id + - reference_doctype / reference_docname + + Returns: + {"referenceId": "", "status": "PENDING"} + """ + + args = frappe._dict(kwargs) + + try: + callback_url = ( + frappe.utils.get_url() + + "/api/method/payments.payment_gateways.doctype.momo_settings" + ".momo_settings.verify_transaction" + ) + + connector = self._get_connector() + + response = connector.request_to_pay( + amount=args.request_amount, + currency=args.currency, + payer_msisdn=args.sender, + external_id=args.order_id, + callback_url=callback_url, + ) + + if response: + if not frappe.db.exists("Integration Request", response["referenceId"]): + create_request_log(args, "Host", "MoMo", response["referenceId"]) + + return response + + except Exception: + frappe.log_error("MoMo Request to Pay Error") + + frappe.throw( + _("MoMo payment initiation failed. Check the error log for details."), + title=_("MoMo Error"), + ) + + # ── Internal helpers ────────────────────────────────────────────── + + def _get_connector(self): + """Instantiate MomoConnector using stored (decrypted) credentials.""" + + env = "sandbox" if self.use_sandbox else "production" + target_env = "sandbox" if self.use_sandbox else self.target_environment + + return MomoConnector( + env=env, + api_user_id=self.api_user_id, + api_key=self.get_password("api_key"), + subscription_key=self.get_password("subscription_key"), + target_environment=target_env, + ) + + +# ════════════════════════ Whitelisted API methods ══════════════════════════ + + +@frappe.whitelist(allow_guest=True) +def verify_transaction(**kwargs): + """ + Callback endpoint — MTN MoMo POSTs here after a payment attempt. + + URL: + /api/method/payments.payment_gateways.doctype.momo_settings + .momo_settings.verify_transaction + """ + + data = frappe._dict(kwargs) + + reference_id = data.get("referenceId") or data.get("externalId") + + if not reference_id or not isinstance(reference_id, str): + frappe.throw(_("MoMo callback: invalid or missing reference ID")) + + if not frappe.db.exists("Integration Request", reference_id): + frappe.log_error( + f"MoMo: Integration Request not found for reference_id={reference_id}", + "MoMo Callback Error", + ) + + return { + "error": "Integration Request not found", + "reference_id": reference_id, + } + + integration_request = frappe.get_doc("Integration Request", reference_id) + + if data.get("status") == "SUCCESSFUL": + try: + integration_request.handle_success(data) + + if ( + integration_request.reference_doctype + and integration_request.reference_docname + ): + doc = frappe.get_doc( + integration_request.reference_doctype, + integration_request.reference_docname, + ) + + doc.run_method("on_payment_authorized", "Completed") + + except Exception: + integration_request.handle_failure(data) + frappe.log_error( + "MoMo: Failed to finalize transaction", + "MoMo Error", + ) + else: + integration_request.handle_failure(data) + + return { + "status": "processed", + "reference_id": reference_id, + } + + +@frappe.whitelist(allow_guest=True) +def poll_transaction_status(reference_id, gateway_name): + """ + Manually poll the MTN API for the current status of a pending transaction. + """ + + if not isinstance(reference_id, str): + frappe.throw(_("Invalid reference_id: must be a string")) + + settings = frappe.get_doc("MoMo Settings", gateway_name) + connector = settings._get_connector() + + status_response = connector.get_transaction_status(reference_id) + + if status_response.get("status") == "SUCCESSFUL": + verify_transaction(**status_response, referenceId=reference_id) + + return status_response + + +def poll_pending_transactions(): + """ + Scheduled job (hourly) — polls MTN for all Integration Requests + that are still in Queued/Pending state for the MoMo gateway. + """ + + pending = frappe.get_all( + "Integration Request", + filters={ + "integration_request_service": "MoMo", + "status": ["in", ["Queued", "Authorized"]], + }, + fields=["name", "data"], + ) + + for req in pending: + try: + import json + + data = json.loads(req.data or "{}") + + gateway_name = data.get("gateway_name") or data.get( + "payment_gateway", "" + ).replace("MoMo-", "") + + if not gateway_name: + continue + + poll_transaction_status( + reference_id=req.name, + gateway_name=gateway_name, + ) + + except Exception: + frappe.log_error( + f"MoMo: Error polling {req.name}", + "MoMo Scheduler", + ) + + +# ════════════════════════ Helpers ═════════════════════════════════════════ + + +def create_mode_of_payment(gateway, payment_type="General"): + """ + Creates a Mode of Payment entry for this gateway if one does not + already exist. Mirrors the same function in mpesa_settings.py. + """ + + with erpnext_app_import_guard(): + from erpnext import get_default_company + + payment_gateway_account = frappe.db.get_value( + "Payment Gateway Account", + {"payment_gateway": gateway}, + "payment_account", + ) + + if frappe.db.exists("Mode of Payment", gateway): + return + + if payment_gateway_account: + mode_of_payment = frappe.get_doc( + { + "doctype": "Mode of Payment", + "mode_of_payment": gateway, + "enabled": 1, + "type": payment_type, + "accounts": [ + { + "doctype": "Mode of Payment Account", + "company": get_default_company(), + "default_account": payment_gateway_account, + } + ], + } + ) + + mode_of_payment.insert(ignore_permissions=True) + + + +@frappe.whitelist(allow_guest=True) +def request_for_payment_by_gateway(gateway_name, **kwargs): + """Standalone wrapper called from momo_checkout.html""" + settings = frappe.get_doc("MoMo Settings", gateway_name) + return settings.request_for_payment(**kwargs) + + +@frappe.whitelist() +def generate_payment_url(payment_request_name): + """Generate MoMo checkout URL for a Payment Request — called from desk button.""" + pr = frappe.get_doc("Payment Request", payment_request_name) + from payments.utils import get_payment_gateway_controller + controller = get_payment_gateway_controller(pr.payment_gateway) + url = controller.get_payment_url( + amount=pr.grand_total, + currency=pr.currency, + order_id=pr.name, + reference_doctype="Payment Request", + reference_docname=pr.name, + payer_name=pr.party_name, + payer_email=pr.party, + title=f"Payment for {pr.reference_name}", + description=f"Payment for {pr.reference_name}", + payment_gateway=pr.payment_gateway, + payment_request_name=pr.name + ) + frappe.db.set_value("Payment Request", payment_request_name, "payment_url", url) + frappe.db.commit() + return url diff --git a/payments/public/js/payment_request.js b/payments/public/js/payment_request.js new file mode 100644 index 000000000..87ee7bc42 --- /dev/null +++ b/payments/public/js/payment_request.js @@ -0,0 +1,24 @@ +frappe.ui.form.on("Payment Request", { + refresh(frm) { + if ( + frm.doc.docstatus === 1 && + frm.doc.status !== "Paid" && + frm.doc.payment_gateway && + frm.doc.payment_gateway.startsWith("MoMo-") + ) { + frm.add_custom_button(__("Open MoMo Checkout"), function() { + frappe.call({ + method: "payments.payment_gateways.doctype.momo_settings.momo_settings.generate_payment_url", + args: { + payment_request_name: frm.doc.name + }, + callback(r) { + if (r.message) { + window.open(r.message, "_blank"); + } + } + }); + }, __("MoMo")); + } + } +}); diff --git a/payments/templates/pages/momo_checkout.html b/payments/templates/pages/momo_checkout.html new file mode 100644 index 000000000..93f268f2b --- /dev/null +++ b/payments/templates/pages/momo_checkout.html @@ -0,0 +1,295 @@ +{% extends "templates/web.html" %} + +{% block page_content %} + +
+ + +
+

+ {{ card_title }} +

+ +

+ {{ description }} +

+
+ + +
+
+ Reference + {{ order_id }} +
+ +
+ Total + + + {{ currency }} {{ amount | int }} + +
+
+ + +
+ + + + + + + + + +
+ + + + + + + Enter in international format without + (e.g. 256700123456 for Uganda) + + +
+ + + +
+ +
+ +
+ + + + +{% endblock %} diff --git a/payments/templates/pages/momo_checkout.py b/payments/templates/pages/momo_checkout.py new file mode 100644 index 000000000..5b9e16de7 --- /dev/null +++ b/payments/templates/pages/momo_checkout.py @@ -0,0 +1,59 @@ +""" +MoMo checkout page — context builder. +Mirrors payments/templates/pages/gocardless_checkout.py. +""" + +import frappe +from frappe import _ +from frappe.utils import flt +from payments.utils import get_payment_gateway_controller + +no_cache = 1 + +EXPECTED_KEYS = ( + "amount", + "title", + "description", + "reference_doctype", + "reference_docname", + "payer_name", + "payer_email", + "order_id", + "currency", + "payment_request_name", +) + + +def get_context(context): + context.no_cache = 1 + + missing = set(EXPECTED_KEYS) - set(frappe.form_dict.keys()) + + if missing: + frappe.redirect_to_message( + _("Missing parameters"), + _("The payment link is incomplete. Missing: {0}").format( + ", ".join(missing) + ), + ) + raise frappe.Redirect + + for key in EXPECTED_KEYS: + context[key] = frappe.form_dict.get(key, "") + + context["amount"] = flt(context["amount"]) + + # Resolve the gateway controller (MoMo Settings document) + try: + gateway_controller = get_payment_gateway_controller( + frappe.form_dict.get("payment_gateway", "") + ) + context["gateway_name"] = gateway_controller.gateway_name + + except Exception: + context["gateway_name"] = "" + + # Page metadata + context["title"] = context.get("title") or _("MTN MoMo Payment") + context["card_title"] = _("Pay with MTN MoMo") + context["frappe_csrf_token"] = frappe.session.csrf_token From ea54f46e08908ced4944e3a605262c197164a91f Mon Sep 17 00:00:00 2001 From: Akumbom Wesley Date: Wed, 18 Mar 2026 07:02:27 +0000 Subject: [PATCH 2/3] Implement MTN MoMo payment as a payment gateway --- .../doctype/momo_settings/momo_connector.py | 2 +- .../doctype/momo_settings/momo_settings.py | 296 ++------------ payments/templates/pages/momo_checkout.html | 365 +++++------------- 3 files changed, 121 insertions(+), 542 deletions(-) diff --git a/payments/payment_gateways/doctype/momo_settings/momo_connector.py b/payments/payment_gateways/doctype/momo_settings/momo_connector.py index aea8ca12a..9d2fc7fef 100644 --- a/payments/payment_gateways/doctype/momo_settings/momo_connector.py +++ b/payments/payment_gateways/doctype/momo_settings/momo_connector.py @@ -137,7 +137,7 @@ def request_to_pay( payload = { "amount": str(amount), # MTN API requires string "currency": currency, - "externalId": external_id, + "externalId": external_id or reference_id, "payer": { "partyIdType": "MSISDN", "partyId": payer_msisdn, diff --git a/payments/payment_gateways/doctype/momo_settings/momo_settings.py b/payments/payment_gateways/doctype/momo_settings/momo_settings.py index b3d63a32b..030f8f074 100644 --- a/payments/payment_gateways/doctype/momo_settings/momo_settings.py +++ b/payments/payment_gateways/doctype/momo_settings/momo_settings.py @@ -1,22 +1,8 @@ -""" -MoMo Settings — DocType controller for MTN Mobile Money gateway. - -Architecture: - Mirrors mpesa_settings.py (on_update, request_for_payment, create_mode_of_payment) - and gocardless_settings.py (validate_transaction_currency, get_payment_url). - - Key hook into payments.utils: - create_payment_gateway("MoMo-" + self.gateway_name, ...) - → inserts a Payment Gateway doc that points back to this Settings doc, - so get_payment_gateway_controller("MoMo-Uganda") returns - frappe.get_doc("MoMo Settings", "Uganda"). -""" - import frappe from frappe import _ from frappe.integrations.utils import create_request_log from frappe.model.document import Document -from frappe.utils import call_hook_method, get_request_site_address, get_url +from frappe.utils import call_hook_method, get_url from urllib.parse import urlencode from payments.payment_gateways.doctype.momo_settings.momo_connector import MomoConnector @@ -24,126 +10,54 @@ class MoMoSettings(Document): - """ - Controller for the 'MoMo Settings' DocType. - - One document per MTN MoMo gateway instance (one per country/environment). - The document name equals gateway_name (autoname: field:gateway_name). - - Example document: - gateway_name = "Uganda" - api_user_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - api_key = "••••••••••••" - subscription_key = "••••••••••••••••••••••••••••••••" - target_environment = "mtnuganda" - use_sandbox = 0 - supported_currencies = "UGX" - """ - - # ── Currency validation ────────────────────────────────────────── - @property def supported_currencies_list(self): - """Return the supported_currencies field as a clean list.""" raw = self.supported_currencies or "UGX,GHS,XAF,ZMW,LRD,RWF,BIF,EUR" return [c.strip() for c in raw.split(",") if c.strip()] def validate_transaction_currency(self, currency): - """ - Called by ERPNext Payment Request before rendering the payment URL. - Mirrors GoCardless pattern from gocardless_settings.py. - """ if currency not in self.supported_currencies_list: frappe.throw( _( - "Please select another payment method. " "MTN MoMo does not support transactions in currency '{0}'. " "Supported: {1}" ).format(currency, ", ".join(self.supported_currencies_list)) ) - # ── Gateway registration ───────────────────────────────────────── - def on_update(self): - """ - Registers this gateway in the Payment Gateway DocType registry and - creates a Mode of Payment entry. - - Mirrors mpesa_settings.py on_update() exactly. - - After this runs, get_payment_gateway_controller("MoMo-Uganda") works. - """ from payments.utils import create_payment_gateway - create_payment_gateway( "MoMo-" + self.gateway_name, settings="MoMo Settings", controller=self.gateway_name, ) - call_hook_method( "payment_gateway_enabled", gateway="MoMo-" + self.gateway_name, payment_channel="", ) - - create_mode_of_payment( - "MoMo-" + self.gateway_name, - payment_type="Phone", - ) - + create_mode_of_payment("MoMo-" + self.gateway_name, payment_type="Phone") callback = ( frappe.utils.get_url() + "/api/method/payments.payment_gateways.doctype.momo_settings" - ".momo_settings.verify_transaction" + ".momo_settings.verify_transaction" ) - self.db_set("callback_url", callback, update_modified=False) - frappe.db.commit() - # ── Checkout URL ───────────────────────────────────────────────── - def get_payment_url(self, **kwargs): - """ - Returns the URL of the MoMo checkout page, with all payment parameters - encoded as query-string arguments. - - Mirrors GoCardless pattern: - return get_url(f"gocardless_checkout?{urlencode(kwargs)}") - """ kwargs.setdefault("payment_request_name", kwargs.get("order_id", "")) return get_url(f"momo_checkout?{urlencode(kwargs)}") - # ── Payment initiation ──────────────────────────────────────────── - def request_for_payment(self, **kwargs): - """ - Initiates a Request-to-Pay via MomoConnector and creates an - Integration Request record to track the transaction. - - Expected kwargs (populated by momo_checkout.html form post): - - request_amount - - currency - - sender - - order_id - - reference_doctype / reference_docname - - Returns: - {"referenceId": "", "status": "PENDING"} - """ - args = frappe._dict(kwargs) - try: callback_url = ( frappe.utils.get_url() + "/api/method/payments.payment_gateways.doctype.momo_settings" - ".momo_settings.verify_transaction" + ".momo_settings.verify_transaction" ) - connector = self._get_connector() - response = connector.request_to_pay( amount=args.request_amount, currency=args.currency, @@ -151,29 +65,16 @@ def request_for_payment(self, **kwargs): external_id=args.order_id, callback_url=callback_url, ) - - if response: - if not frappe.db.exists("Integration Request", response["referenceId"]): - create_request_log(args, "Host", "MoMo", response["referenceId"]) - + if response and not frappe.db.exists("Integration Request", response["referenceId"]): + create_request_log(args, "Host", "MoMo", response["referenceId"]) return response - except Exception: - frappe.log_error("MoMo Request to Pay Error") - - frappe.throw( - _("MoMo payment initiation failed. Check the error log for details."), - title=_("MoMo Error"), - ) - - # ── Internal helpers ────────────────────────────────────────────── + frappe.log_error(frappe.get_traceback(), "MoMo Request to Pay Error") + frappe.throw(_("MoMo payment initiation failed."), title=_("MoMo Error")) def _get_connector(self): - """Instantiate MomoConnector using stored (decrypted) credentials.""" - env = "sandbox" if self.use_sandbox else "production" target_env = "sandbox" if self.use_sandbox else self.target_environment - return MomoConnector( env=env, api_user_id=self.api_user_id, @@ -182,185 +83,52 @@ def _get_connector(self): target_environment=target_env, ) - -# ════════════════════════ Whitelisted API methods ══════════════════════════ - - @frappe.whitelist(allow_guest=True) def verify_transaction(**kwargs): - """ - Callback endpoint — MTN MoMo POSTs here after a payment attempt. - - URL: - /api/method/payments.payment_gateways.doctype.momo_settings - .momo_settings.verify_transaction - """ - data = frappe._dict(kwargs) - reference_id = data.get("referenceId") or data.get("externalId") - if not reference_id or not isinstance(reference_id, str): - frappe.throw(_("MoMo callback: invalid or missing reference ID")) + if not reference_id: + return {"error": "Missing reference ID"} if not frappe.db.exists("Integration Request", reference_id): - frappe.log_error( - f"MoMo: Integration Request not found for reference_id={reference_id}", - "MoMo Callback Error", - ) - - return { - "error": "Integration Request not found", - "reference_id": reference_id, - } + return {"error": "Integration Request not found"} integration_request = frappe.get_doc("Integration Request", reference_id) if data.get("status") == "SUCCESSFUL": + original_user = frappe.session.user try: + # 1. Elevate permissions + frappe.set_user("Administrator") + + # 2. Update Integration Request status explicitly for polling integration_request.handle_success(data) + integration_request.db_set("status", "Completed") - if ( - integration_request.reference_doctype - and integration_request.reference_docname - ): - doc = frappe.get_doc( - integration_request.reference_doctype, - integration_request.reference_docname, - ) - - doc.run_method("on_payment_authorized", "Completed") + if integration_request.reference_doctype == "Payment Request": + pr = frappe.get_doc("Payment Request", integration_request.reference_docname) + if pr.status != "Paid": + pr.create_payment_entry() + pr.add_comment("Info", f"MoMo Confirmed. ID: {data.get('financialTransactionId')}") + frappe.db.commit() except Exception: integration_request.handle_failure(data) - frappe.log_error( - "MoMo: Failed to finalize transaction", - "MoMo Error", - ) + frappe.log_error(frappe.get_traceback(), "MoMo Callback Error") + finally: + frappe.set_user(original_user) else: integration_request.handle_failure(data) - return { - "status": "processed", - "reference_id": reference_id, - } - - -@frappe.whitelist(allow_guest=True) -def poll_transaction_status(reference_id, gateway_name): - """ - Manually poll the MTN API for the current status of a pending transaction. - """ - - if not isinstance(reference_id, str): - frappe.throw(_("Invalid reference_id: must be a string")) - - settings = frappe.get_doc("MoMo Settings", gateway_name) - connector = settings._get_connector() - - status_response = connector.get_transaction_status(reference_id) - - if status_response.get("status") == "SUCCESSFUL": - verify_transaction(**status_response, referenceId=reference_id) - - return status_response - - -def poll_pending_transactions(): - """ - Scheduled job (hourly) — polls MTN for all Integration Requests - that are still in Queued/Pending state for the MoMo gateway. - """ - - pending = frappe.get_all( - "Integration Request", - filters={ - "integration_request_service": "MoMo", - "status": ["in", ["Queued", "Authorized"]], - }, - fields=["name", "data"], - ) - - for req in pending: - try: - import json - - data = json.loads(req.data or "{}") - - gateway_name = data.get("gateway_name") or data.get( - "payment_gateway", "" - ).replace("MoMo-", "") - - if not gateway_name: - continue - - poll_transaction_status( - reference_id=req.name, - gateway_name=gateway_name, - ) - - except Exception: - frappe.log_error( - f"MoMo: Error polling {req.name}", - "MoMo Scheduler", - ) - - -# ════════════════════════ Helpers ═════════════════════════════════════════ - - -def create_mode_of_payment(gateway, payment_type="General"): - """ - Creates a Mode of Payment entry for this gateway if one does not - already exist. Mirrors the same function in mpesa_settings.py. - """ - - with erpnext_app_import_guard(): - from erpnext import get_default_company - - payment_gateway_account = frappe.db.get_value( - "Payment Gateway Account", - {"payment_gateway": gateway}, - "payment_account", - ) - - if frappe.db.exists("Mode of Payment", gateway): - return - - if payment_gateway_account: - mode_of_payment = frappe.get_doc( - { - "doctype": "Mode of Payment", - "mode_of_payment": gateway, - "enabled": 1, - "type": payment_type, - "accounts": [ - { - "doctype": "Mode of Payment Account", - "company": get_default_company(), - "default_account": payment_gateway_account, - } - ], - } - ) - - mode_of_payment.insert(ignore_permissions=True) - - - -@frappe.whitelist(allow_guest=True) -def request_for_payment_by_gateway(gateway_name, **kwargs): - """Standalone wrapper called from momo_checkout.html""" - settings = frappe.get_doc("MoMo Settings", gateway_name) - return settings.request_for_payment(**kwargs) - + return {"status": "processed", "reference_id": reference_id} @frappe.whitelist() def generate_payment_url(payment_request_name): - """Generate MoMo checkout URL for a Payment Request — called from desk button.""" pr = frappe.get_doc("Payment Request", payment_request_name) from payments.utils import get_payment_gateway_controller controller = get_payment_gateway_controller(pr.payment_gateway) + url = controller.get_payment_url( amount=pr.grand_total, currency=pr.currency, @@ -370,10 +138,14 @@ def generate_payment_url(payment_request_name): payer_name=pr.party_name, payer_email=pr.party, title=f"Payment for {pr.reference_name}", - description=f"Payment for {pr.reference_name}", + description=f"MTN MoMo Payment for {pr.party_name}", payment_gateway=pr.payment_gateway, - payment_request_name=pr.name + payment_request_name=pr.name, ) frappe.db.set_value("Payment Request", payment_request_name, "payment_url", url) frappe.db.commit() return url + +@frappe.whitelist(allow_guest=True) +def request_for_payment_by_gateway(gateway_name, **kwargs): + return frappe.get_doc("MoMo Settings", gateway_name).request_for_payment(**kwargs) diff --git a/payments/templates/pages/momo_checkout.html b/payments/templates/pages/momo_checkout.html index 93f268f2b..41718dd03 100644 --- a/payments/templates/pages/momo_checkout.html +++ b/payments/templates/pages/momo_checkout.html @@ -1,295 +1,102 @@ {% extends "templates/web.html" %} {% block page_content %} - -
- - -
-

- {{ card_title }} -

- -

- {{ description }} -

-
- - -
-
- Reference - {{ order_id }} +
+

MTN Mobile Money Payment

+

Payment for: {{ title or payment_request_name }}

+
+ +
+ + + Include country code without "+" (e.g., 237 for Cameroon).
-
- Total - - - {{ currency }} {{ amount | int }} - -
-
- - -
- - - - - - - - - -
- - - - - - - Enter in international format without + (e.g. 256700123456 for Uganda) - - +
+
- - - -
- - - + +
- - {% endblock %} From de5727bba2e5b8ff6ef76e889098ee33fb54cdb6 Mon Sep 17 00:00:00 2001 From: Akumbom Wesley Date: Fri, 27 Mar 2026 14:41:22 +0100 Subject: [PATCH 3/3] Implement MTN MoMo webshop integration logic --- payments/hooks.py | 94 +- .../doctype/momo_settings/momo_checkout.py | 28 + .../doctype/momo_settings/momo_connector.json | 196 ----- .../doctype/momo_settings/momo_connector.py | 103 +-- .../doctype/momo_settings/momo_settings.py | 629 ++++++++++++-- .../momo_settings.py.bak_1774600341 | 802 ++++++++++++++++++ payments/templates/pages/momo_checkout.html | 473 +++++++++-- .../templates/pages/momo_checkout.html_bk | 102 +++ payments/templates/pages/momo_checkout.py | 76 +- payments/templates/pages/momo_checkout.py_bk | 59 ++ 10 files changed, 2016 insertions(+), 546 deletions(-) create mode 100755 payments/payment_gateways/doctype/momo_settings/momo_checkout.py delete mode 100644 payments/payment_gateways/doctype/momo_settings/momo_connector.json create mode 100644 payments/payment_gateways/doctype/momo_settings/momo_settings.py.bak_1774600341 create mode 100644 payments/templates/pages/momo_checkout.html_bk create mode 100644 payments/templates/pages/momo_checkout.py_bk diff --git a/payments/hooks.py b/payments/hooks.py index fbaf4d3d1..2199cf9ab 100644 --- a/payments/hooks.py +++ b/payments/hooks.py @@ -29,11 +29,7 @@ # page_js = {"page" : "public/js/file.js"} # include js in doctype views -# doctype_js = {"doctype" : "public/js/doctype.js"} doctype_js = {"Payment Request": "public/js/payment_request.js"} -# doctype_list_js = {"doctype" : "public/js/doctype_list.js"} -# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} -# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} # Home Pages # ---------- @@ -43,7 +39,7 @@ # website user home page (by Role) # role_home_page = { -# "Role": "home_page" +# "Role": "home_page" # } # Generators @@ -57,8 +53,8 @@ # add methods and filters to jinja environment # jinja = { -# "methods": "pay.utils.jinja_methods", -# "filters": "pay.utils.jinja_filters" +# "methods": "pay.utils.jinja_methods", +# "filters": "pay.utils.jinja_filters" # } # Installation @@ -71,53 +67,45 @@ # ------------ before_uninstall = "payments.utils.delete_custom_fields" -# after_uninstall = "pay.uninstall.after_uninstall" # Desk Notifications # ------------------ # See frappe.core.notifications.get_notification_config - # notification_config = "pay.notifications.get_notification_config" # Permissions # ----------- -# Permissions evaluated in scripted ways - # permission_query_conditions = { -# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", +# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", # } # # has_permission = { -# "Event": "frappe.desk.doctype.event.event.has_permission", +# "Event": "frappe.desk.doctype.event.event.has_permission", # } # DocType Class # --------------- -# Extend standard doctype classes extend_doctype_class = {"Web Form": "payments.overrides.payment_webform.PaymentWebForm"} # Document Events # --------------- -# Hook on document methods and events - -# doc_events = { -# "*": { -# "on_update": "method", -# "on_cancel": "method", -# "on_trash": "method" -# } -# } + +doc_events = { + "Sales Invoice": { + "on_submit": "payments.payment_gateways.doctype.momo_settings.momo_settings.on_sales_invoice_submit", + } +} # Scheduled Tasks # --------------- scheduler_events = { - "all": [ - "payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.capture_payment", - ], - "hourly": [ - "payments.payment_gateways.doctype.momo_settings.momo_settings.poll_pending_transactions", + "all": [ + "payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.capture_payment", + ], + "hourly": [ + "payments.payment_gateways.doctype.momo_settings.momo_settings.poll_pending_transactions", ], } @@ -128,58 +116,26 @@ # Overriding Methods # ------------------------------ -# + override_whitelisted_methods = { - "frappe.website.doctype.web_form.web_form.accept": "payments.overrides.payment_webform.accept" + "frappe.website.doctype.web_form.web_form.accept": "payments.overrides.payment_webform.accept" } -# -# each overriding function accepts a `data` argument; -# generated from the base implementation of the doctype dashboard, -# along with any modifications made in other Frappe apps -# override_doctype_dashboards = { -# "Task": "pay.task.get_dashboard_data" -# } - -# exempt linked doctypes from being automatically cancelled -# -# auto_cancel_exempted_doctypes = ["Auto Repeat"] - # User Data Protection # -------------------- # user_data_fields = [ -# { -# "doctype": "{doctype_1}", -# "filter_by": "{filter_by}", -# "redact_fields": ["{field_1}", "{field_2}"], -# "partial": 1, -# }, -# { -# "doctype": "{doctype_2}", -# "filter_by": "{filter_by}", -# "partial": 1, -# }, -# { -# "doctype": "{doctype_3}", -# "strict": False, -# }, -# { -# "doctype": "{doctype_4}" -# } +# { +# "doctype": "{doctype_1}", +# "filter_by": "{filter_by}", +# "redact_fields": ["{field_1}", "{field_2}"], +# "partial": 1, +# } # ] # Authentication and authorization # -------------------------------- # auth_hooks = [ -# "pay.auth.validate" +# "pay.auth.validate" # ] - -# Translation -# -------------------------------- - -# Make link fields search translated document names for these DocTypes -# Recommended only for DocTypes which have limited documents with untranslated names -# For example: Role, Gender, etc. -# translated_search_doctypes = [] diff --git a/payments/payment_gateways/doctype/momo_settings/momo_checkout.py b/payments/payment_gateways/doctype/momo_settings/momo_checkout.py new file mode 100755 index 000000000..b93217a1a --- /dev/null +++ b/payments/payment_gateways/doctype/momo_settings/momo_checkout.py @@ -0,0 +1,28 @@ +import frappe +from frappe import _ + +def get_context(context): + form_dict = frappe.form_dict + + pr_name = form_dict.get("payment_request_name") or form_dict.get("order_id") + if not pr_name: + frappe.throw(_("Invalid payment link")) + + pr = frappe.get_doc("Payment Request", pr_name) + + # Resolve gateway name (strip "MoMo-" prefix) + gateway_full = pr.payment_gateway or "" + gateway_name = gateway_full.replace("MoMo-", "") if gateway_full.startswith("MoMo-") else gateway_full + + context.update({ + "title": form_dict.get("title") or f"Payment for {pr.reference_name}", + "amount": pr.grand_total, + "currency": pr.currency, + "order_id": pr_name, + "payment_request_name": pr_name, + "gateway_name": gateway_name, + "reference_doctype": "Payment Request", + "reference_docname": pr_name, + "payer_name": pr.party_name, + "success_url": f"/orders?name={pr.reference_name}", + }) diff --git a/payments/payment_gateways/doctype/momo_settings/momo_connector.json b/payments/payment_gateways/doctype/momo_settings/momo_connector.json deleted file mode 100644 index 2543fa2da..000000000 --- a/payments/payment_gateways/doctype/momo_settings/momo_connector.json +++ /dev/null @@ -1,196 +0,0 @@ -""" -MomoConnector — MTN Mobile Money Collection API client. - -Mirrors the MpesaConnector pattern from: -payments/payment_gateways/doctype/mpesa_settings/mpesa_connector.py - -MTN MoMo API reference: -https://momodeveloper.mtn.com/docs/services/collection -""" - -import base64 -import uuid - -import requests - - -class MomoConnector: - """ - Wraps the MTN MoMo Collection API. - - Usage:: - - conn = MomoConnector( - env="sandbox", - api_user_id="", - api_key="", - subscription_key="", - target_environment="sandbox", - ) - - result = conn.request_to_pay( - amount=5000, - currency="UGX", - payer_msisdn="256700000000", - external_id="INV-0001", - callback_url="https://your-site.com/api/method/...verify_transaction", - ) - - # result = {"referenceId": "", "status": "PENDING"} - """ - - SANDBOX_URL = "https://sandbox.momodeveloper.mtn.com" - PRODUCTION_URL = "https://proxy.momoapi.mtn.com" - - def __init__( - self, - env="sandbox", - api_user_id=None, - api_key=None, - subscription_key=None, - target_environment="sandbox", - ): - self.env = env - self.api_user_id = api_user_id - self.api_key = api_key - self.subscription_key = subscription_key - self.target_environment = target_environment - - self.base_url = ( - self.SANDBOX_URL if env == "sandbox" else self.PRODUCTION_URL - ) - - self.access_token = None - - # Authenticate immediately - self.authenticate() - - # ── Authentication ────────────────────────────────────────────── - - def authenticate(self): - """ - Obtain a Bearer token from the MTN MoMo token endpoint. - - POST /collection/token/ - - Headers: - Authorization: Basic base64(api_user_id:api_key) - Ocp-Apim-Subscription-Key: - - Returns: - access_token (str) - """ - url = f"{self.base_url}/collection/token/" - - credentials = base64.b64encode( - f"{self.api_user_id}:{self.api_key}".encode() - ).decode() - - headers = { - "Authorization": f"Basic {credentials}", - "Ocp-Apim-Subscription-Key": self.subscription_key, - } - - response = requests.post(url, headers=headers) - response.raise_for_status() - - self.access_token = response.json()["access_token"] - return self.access_token - - # ── Collection endpoints ───────────────────────────────────────── - - def request_to_pay( - self, - amount, - currency, - payer_msisdn, - external_id, - callback_url, - payer_message="Payment", - payee_note="Thank you", - ): - """ - Initiate a Request-to-Pay (debit) from the payer's mobile wallet. - - POST /collection/v1_0/requesttopay - - Returns: - {"referenceId": "", "status": "PENDING"} on 202 Accepted. - - Args: - amount: Numeric amount (int or Decimal) - currency: ISO 4217 currency code, e.g. "UGX" - payer_msisdn: Customer mobile number in international format - e.g. "256700123456" - external_id: Your internal reference (invoice/order ID) - callback_url: HTTPS endpoint MTN will POST transaction results - payer_message: Text shown to payer during USSD prompt - payee_note: Internal note stored on the MTN side - """ - - reference_id = str(uuid.uuid4()) - url = f"{self.base_url}/collection/v1_0/requesttopay" - - headers = { - "Authorization": f"Bearer {self.access_token}", - "X-Reference-Id": reference_id, - "X-Target-Environment": self.target_environment, - "Ocp-Apim-Subscription-Key": self.subscription_key, - "X-Callback-Url": callback_url, - "Content-Type": "application/json", - } - - payload = { - "amount": str(amount), # MTN API requires string - "currency": currency, - "externalId": external_id, - "payer": { - "partyIdType": "MSISDN", - "partyId": payer_msisdn, - }, - "payerMessage": payer_message, - "payeeNote": payee_note, - } - - response = requests.post(url, headers=headers, json=payload) - - if response.status_code == 202: - return {"referenceId": reference_id, "status": "PENDING"} - - # For any other status code, raise exception - response.raise_for_status() - - def get_transaction_status(self, reference_id): - """ - Poll the status of a previously initiated Request-to-Pay. - - GET /collection/v1_0/requesttopay/{referenceId} - - Example response: - { - "financialTransactionId": "...", - "externalId": "...", - "amount": "5000", - "currency": "UGX", - "payer": {"partyIdType": "MSISDN", "partyId": "256700..."}, - "status": "SUCCESSFUL" - } - - Status values: - SUCCESSFUL - FAILED - PENDING - """ - - url = f"{self.base_url}/collection/v1_0/requesttopay/{reference_id}" - - headers = { - "Authorization": f"Bearer {self.access_token}", - "X-Target-Environment": self.target_environment, - "Ocp-Apim-Subscription-Key": self.subscription_key, - } - - response = requests.get(url, headers=headers) - response.raise_for_status() - - return response.json() diff --git a/payments/payment_gateways/doctype/momo_settings/momo_connector.py b/payments/payment_gateways/doctype/momo_settings/momo_connector.py index 9d2fc7fef..9e334b5d0 100644 --- a/payments/payment_gateways/doctype/momo_settings/momo_connector.py +++ b/payments/payment_gateways/doctype/momo_settings/momo_connector.py @@ -1,44 +1,15 @@ """ MomoConnector — MTN Mobile Money Collection API client. - -Mirrors the MpesaConnector pattern from: - payments/payment_gateways/doctype/mpesa_settings/mpesa_connector.py - -MTN MoMo API reference: - https://momodeveloper.mtn.com/docs/services/collection +Updated with debug logging for payload and API response. """ import base64 import uuid - import requests - +import frappe # Added frappe for logging +import json # Added json for formatting logs class MomoConnector: - """ - Wraps the MTN MoMo Collection API. - - Usage:: - - conn = MomoConnector( - env="sandbox", - api_user_id="", - api_key="", - subscription_key="", - target_environment="sandbox", - ) - - result = conn.request_to_pay( - amount=5000, - currency="UGX", - payer_msisdn="256700000000", - external_id="INV-0001", - callback_url="https://your-site.com/api/method/...verify_transaction", - ) - - # result = {"referenceId": "", "status": "PENDING"} - """ - SANDBOX_URL = "https://sandbox.momodeveloper.mtn.com" PRODUCTION_URL = "https://proxy.momoapi.mtn.com" @@ -64,19 +35,7 @@ def __init__( # ── Authentication ────────────────────────────────────────────── def authenticate(self): - """ - Obtain a Bearer token from the MTN MoMo token endpoint. - - POST /collection/token/ - - Authorization: Basic base64(api_user_id:api_key) - Ocp-Apim-Subscription-Key: - - Returns the access_token string and stores it in self.access_token. - """ - url = f"{self.base_url}/collection/token/" - credentials = base64.b64encode( f"{self.api_user_id}:{self.api_key}".encode() ).decode() @@ -90,7 +49,6 @@ def authenticate(self): response.raise_for_status() self.access_token = response.json()["access_token"] - return self.access_token # ── Collection endpoints ───────────────────────────────────────── @@ -104,25 +62,9 @@ def request_to_pay( callback_url, payer_message="Payment", payee_note="Thank you", + reference_id=None, ): - """ - Initiate a Request-to-Pay (debit) from the payer's mobile wallet. - - POST /collection/v1_0/requesttopay - - Returns {"referenceId": "", "status": "PENDING"} on 202 Accepted. - - :param amount: Numeric amount (int or Decimal) - :param currency: ISO 4217 currency code, e.g. "UGX" - :param payer_msisdn: Customer's mobile number in international format, e.g. "256700123456" - :param external_id: Your internal reference (invoice number, order ID, etc.) - :param callback_url: HTTPS URL MTN will POST the result to - :param payer_message: Text shown to payer in the USSD prompt - :param payee_note: Internal note stored on the MTN side - """ - - reference_id = str(uuid.uuid4()) - + reference_id = reference_id or str(uuid.uuid4()) url = f"{self.base_url}/collection/v1_0/requesttopay" headers = { @@ -135,7 +77,7 @@ def request_to_pay( } payload = { - "amount": str(amount), # MTN API requires string + "amount": str(amount), "currency": currency, "externalId": external_id or reference_id, "payer": { @@ -146,34 +88,28 @@ def request_to_pay( "payeeNote": payee_note, } + # ── LOG THE OUTGOING PAYLOAD ── + frappe.log_error( + title="MTN Outgoing Payload", + message=json.dumps({"url": url, "payload": payload}, indent=4) + ) + response = requests.post(url, headers=headers, json=payload) + # ── LOG THE API RESPONSE ON FAILURE ── + if response.status_code != 202: + frappe.log_error( + title="MTN API Rejection Detail", + message=f"Status Code: {response.status_code}\nResponse Body: {response.text}\nSent Payload: {json.dumps(payload)}" + ) + if response.status_code == 202: return {"referenceId": reference_id, "status": "PENDING"} - # For any other status code, raise so callers can handle it response.raise_for_status() def get_transaction_status(self, reference_id): - """ - Poll the status of a previously initiated Request-to-Pay. - - GET /collection/v1_0/requesttopay/{referenceId} - - Returns the full MTN status object, e.g.: - - { - "financialTransactionId": "...", - "externalId": "...", - "amount": "5000", - "currency": "UGX", - "payer": {"partyIdType": "MSISDN", "partyId": "256700..."}, - "status": "SUCCESSFUL" # or "FAILED" / "PENDING" - } - """ - url = f"{self.base_url}/collection/v1_0/requesttopay/{reference_id}" - headers = { "Authorization": f"Bearer {self.access_token}", "X-Target-Environment": self.target_environment, @@ -182,5 +118,4 @@ def get_transaction_status(self, reference_id): response = requests.get(url, headers=headers) response.raise_for_status() - return response.json() diff --git a/payments/payment_gateways/doctype/momo_settings/momo_settings.py b/payments/payment_gateways/doctype/momo_settings/momo_settings.py index 030f8f074..c50296f0f 100644 --- a/payments/payment_gateways/doctype/momo_settings/momo_settings.py +++ b/payments/payment_gateways/doctype/momo_settings/momo_settings.py @@ -6,36 +6,21 @@ from urllib.parse import urlencode from payments.payment_gateways.doctype.momo_settings.momo_connector import MomoConnector -from payments.utils import erpnext_app_import_guard - class MoMoSettings(Document): - @property - def supported_currencies_list(self): - raw = self.supported_currencies or "UGX,GHS,XAF,ZMW,LRD,RWF,BIF,EUR" - return [c.strip() for c in raw.split(",") if c.strip()] - def validate_transaction_currency(self, currency): - if currency not in self.supported_currencies_list: - frappe.throw( - _( - "MTN MoMo does not support transactions in currency '{0}'. " - "Supported: {1}" - ).format(currency, ", ".join(self.supported_currencies_list)) - ) + supported = self.supported_currencies or "UGX,GHS,XAF,ZMW,RWF" + if currency not in supported.split(','): + frappe.throw(_("Currency {0} not supported by MoMo").format(currency)) def on_update(self): + """Syncs the Gateway and Mode of Payment on save.""" from payments.utils import create_payment_gateway create_payment_gateway( "MoMo-" + self.gateway_name, settings="MoMo Settings", controller=self.gateway_name, ) - call_hook_method( - "payment_gateway_enabled", - gateway="MoMo-" + self.gateway_name, - payment_channel="", - ) create_mode_of_payment("MoMo-" + self.gateway_name, payment_type="Phone") callback = ( frappe.utils.get_url() @@ -45,86 +30,263 @@ def on_update(self): self.db_set("callback_url", callback, update_modified=False) frappe.db.commit() + def _get_connector(self): + return MomoConnector( + env="sandbox" if self.use_sandbox else "production", + api_user_id=self.api_user_id, + api_key=self.get_password("api_key"), + subscription_key=self.get_password("subscription_key"), + target_environment=self.target_environment + ) + def get_payment_url(self, **kwargs): + """Used for Webshop/Checkout redirects.""" kwargs.setdefault("payment_request_name", kwargs.get("order_id", "")) return get_url(f"momo_checkout?{urlencode(kwargs)}") + @frappe.whitelist(allow_guest=True) def request_for_payment(self, **kwargs): + """Unified entry point for POS, Webshop, and Desk.""" args = frappe._dict(kwargs) - try: - callback_url = ( - frappe.utils.get_url() - + "/api/method/payments.payment_gateways.doctype.momo_settings" - ".momo_settings.verify_transaction" + + # 1. IDENTIFY THE SENDER (Handles multiple possible keys from POS/Desk) + raw_sender = args.sender or args.payer_msisdn or "" + + # 2. THE POS "WALKIN" GATEKEEPER + # Blocks the request if 'WalkIn' is passed instead of a number + clean_digits = "".join(filter(str.isdigit, str(raw_sender))) + + if not clean_digits or str(raw_sender).lower() in ["walkin", "walk-in"]: + frappe.throw( + _("MoMo requires a valid phone number. Please enter a phone number in the payment field instead of '{0}'.").format(raw_sender), + title=_("Invalid Phone Number") ) + + # 3. AUTO-FORMAT MSISDN (Standardizing to 237...) + sender = clean_digits + if sender.startswith("0"): + sender = "237" + sender[1:] + elif len(sender) == 9: + sender = "237" + sender + + # 4. CLEAN THE AMOUNT (MTN XAF often rejects decimals like 50.0) + try: + # Converts "50.0" -> 50 + request_amount = str(int(float(args.request_amount or args.amount or 0))) + except (ValueError, TypeError): + request_amount = args.request_amount or args.amount + + try: connector = self._get_connector() + callback_url = get_url("/api/method/payments.payment_gateways.doctype.momo_settings.momo_settings.verify_transaction") + response = connector.request_to_pay( - amount=args.request_amount, + amount=request_amount, currency=args.currency, - payer_msisdn=args.sender, - external_id=args.order_id, + payer_msisdn=sender, + external_id=args.order_id or args.payment_request_name, callback_url=callback_url, ) - if response and not frappe.db.exists("Integration Request", response["referenceId"]): + + if response and response.get("referenceId"): + # Logs the attempt in 'Integration Request' create_request_log(args, "Host", "MoMo", response["referenceId"]) + return response except Exception: - frappe.log_error(frappe.get_traceback(), "MoMo Request to Pay Error") - frappe.throw(_("MoMo payment initiation failed."), title=_("MoMo Error")) + frappe.log_error(frappe.get_traceback(), "MoMo Request Error") + frappe.throw(_("MoMo Service Unavailable. Check API credentials."), title=_("Payment Error")) - def _get_connector(self): - env = "sandbox" if self.use_sandbox else "production" - target_env = "sandbox" if self.use_sandbox else self.target_environment - return MomoConnector( - env=env, - api_user_id=self.api_user_id, - api_key=self.get_password("api_key"), - subscription_key=self.get_password("subscription_key"), - target_environment=target_env, +# ────────────────────────────────────────────────────────────────────────────── +# GLOBAL WHITELISTED METHODS +# ────────────────────────────────────────────────────────────────────────────── + +@frappe.whitelist() +def finalize_webshop_order(token, reference_id): + """ + Called after polling confirms MTN payment is Completed. + Flow: SO (To Deliver) -> PR (Paid) -> PE (against SO) + SI is created from SO by warehouse staff after delivery. + """ + import json + + raw = frappe.cache().get_value(f"momo_pending_{token}") + if not raw: + frappe.log_error( + f"Token: {token}\nRef: {reference_id}", + "MoMo Finalize: Token Missing" ) + return {"error": "Session expired. Contact support with ref: " + reference_id} -@frappe.whitelist(allow_guest=True) -def verify_transaction(**kwargs): - data = frappe._dict(kwargs) - reference_id = data.get("referenceId") or data.get("externalId") + cart_data = json.loads(raw) # plain dict — NOT frappe._dict + pr_name = cart_data.get("payment_request") + paid_amount = float(cart_data.get("grand_total") or 0) + quotation = cart_data.get("quotation_name") - if not reference_id: - return {"error": "Missing reference ID"} + # DUPLICATE GUARD: only skip if SO already has a submitted PE + existing_so = frappe.db.get_value( + "Sales Order", + {"po_no": quotation, "docstatus": 1}, + "name" + ) + if existing_so: + has_pe = frappe.db.exists("Payment Entry Reference", { + "reference_doctype": "Sales Order", + "reference_name": existing_so, + }) + if has_pe: + frappe.cache().delete_value(f"momo_pending_{token}") + return {"sales_order": existing_so} - if not frappe.db.exists("Integration Request", reference_id): - return {"error": "Integration Request not found"} + original_user = frappe.session.user + try: + frappe.set_user("Administrator") - integration_request = frappe.get_doc("Integration Request", reference_id) + # 1. SALES ORDER + if not existing_so: + so = frappe.new_doc("Sales Order") + so.customer = cart_data["customer"] + so.company = cart_data["company"] + so.currency = cart_data["currency"] + so.delivery_date = frappe.utils.nowdate() + so.transaction_date = frappe.utils.nowdate() + so.order_type = "Sales" + so.po_no = quotation - if data.get("status") == "SUCCESSFUL": - original_user = frappe.session.user - try: - # 1. Elevate permissions - frappe.set_user("Administrator") - - # 2. Update Integration Request status explicitly for polling - integration_request.handle_success(data) - integration_request.db_set("status", "Completed") + if cart_data.get("shipping_address_name"): + so.shipping_address_name = cart_data["shipping_address_name"] + if cart_data.get("customer_address"): + so.customer_address = cart_data["customer_address"] - if integration_request.reference_doctype == "Payment Request": - pr = frappe.get_doc("Payment Request", integration_request.reference_docname) - if pr.status != "Paid": - pr.create_payment_entry() - pr.add_comment("Info", f"MoMo Confirmed. ID: {data.get('financialTransactionId')}") + for item in cart_data.get("items") or []: + so.append("items", { + "item_code": item["item_code"], + "qty": item.get("qty", 1), + "rate": item.get("rate", 0), + "warehouse": item.get("warehouse"), + "uom": item.get("uom"), + }) + for tax in cart_data.get("taxes") or []: + so.append("taxes", { + "charge_type": tax.get("charge_type", "On Net Total"), + "account_head": tax["account_head"], + "description": tax.get("description") or tax["account_head"], + "rate": tax.get("rate", 0), + }) + so.flags.ignore_permissions = True + so.insert() + so.submit() frappe.db.commit() + else: + so = frappe.get_doc("Sales Order", existing_so) + + frappe.log_error(f"SO: {so.name}", "MoMo Finalize: Step 1 OK") + + # 2. PAYMENT REQUEST: mark Paid, link to SO + if pr_name and frappe.db.exists("Payment Request", pr_name): + frappe.db.set_value("Payment Request", pr_name, { + "reference_doctype": "Sales Order", + "reference_name": so.name, + "status": "Paid", + }) + frappe.db.commit() + + frappe.log_error(f"PR: {pr_name}", "MoMo Finalize: Step 2 OK") + + # 3. PAYMENT ENTRY against SO + gw_full = ( + frappe.db.get_value("Payment Request", pr_name, "payment_gateway") + if pr_name else "" + ) + pga = frappe.db.get_value( + "Payment Gateway Account", + {"payment_gateway": gw_full}, + ["payment_account", "currency"], + as_dict=True, + ) if gw_full else None + + receivable_account = ( + frappe.db.get_value("Party Account", { + "parenttype": "Customer", + "parent": so.customer, + "company": so.company, + }, "account") + or frappe.db.get_value("Account", { + "account_type": "Receivable", + "company": so.company, + "is_group": 0, + }, "name") + or frappe.db.get_value("Company", so.company, "default_receivable_account") + ) + + if not pga or not pga.payment_account: + frappe.throw(f"Payment Gateway Account not configured for {gw_full}") + + pe = frappe.new_doc("Payment Entry") + pe.payment_type = "Receive" + pe.posting_date = frappe.utils.nowdate() + pe.company = so.company + pe.party_type = "Customer" + pe.party = so.customer + pe.party_name = so.customer_name + pe.paid_from = receivable_account + pe.paid_from_account_currency = so.currency + pe.paid_to = pga.payment_account + pe.paid_to_account_currency = so.currency + pe.paid_amount = paid_amount + pe.received_amount = paid_amount + pe.source_exchange_rate = 1 + pe.target_exchange_rate = 1 + pe.reference_no = reference_id + pe.reference_date = frappe.utils.nowdate() + pe.remarks = ( + f"MoMo payment received.\n" + f"PR: {pr_name} | SO: {so.name} | MTN Ref: {reference_id}" + ) + pe.append("references", { + "reference_doctype": "Sales Order", + "reference_name": so.name, + "allocated_amount": paid_amount, + "total_amount": so.grand_total, + "outstanding_amount": so.grand_total, + }) + pe.flags.ignore_permissions = True + pe.insert() + pe.submit() + frappe.db.commit() + + frappe.log_error(f"PE: {pe.name}", "MoMo Finalize: Step 3 OK") + + # 4. SUBMIT QUOTATION so the cart is cleared and cannot be reused + try: + quot = frappe.get_doc("Quotation", quotation) + if quot.docstatus == 0: + quot.flags.ignore_permissions = True + quot.submit() + frappe.db.commit() + frappe.log_error(f"Quotation {quotation} submitted", "MoMo Finalize: Step 4 OK") except Exception: - integration_request.handle_failure(data) - frappe.log_error(frappe.get_traceback(), "MoMo Callback Error") - finally: - frappe.set_user(original_user) - else: - integration_request.handle_failure(data) + frappe.log_error(frappe.get_traceback(), "MoMo Finalize: quotation submit (non-critical)") + + frappe.cache().delete_value(f"momo_pending_{token}") + frappe.log_error(f"SO: {so.name} | PE: {pe.name}", "MoMo Finalize: COMPLETE") + + return {"sales_order": so.name, "payment_entry": pe.name} + + except Exception: + frappe.log_error(frappe.get_traceback(), "MoMo Webshop Finalize Error") + return {"error": "Order creation failed. Contact support with ref: " + reference_id} + finally: + frappe.set_user(original_user) - return {"status": "processed", "reference_id": reference_id} @frappe.whitelist() def generate_payment_url(payment_request_name): + """Generates the URL for the 'Request MoMo Payment' button on Invoices.""" + if not payment_request_name: + frappe.throw(_("Payment Request name is required")) + pr = frappe.get_doc("Payment Request", payment_request_name) from payments.utils import get_payment_gateway_controller controller = get_payment_gateway_controller(pr.payment_gateway) @@ -142,10 +304,339 @@ def generate_payment_url(payment_request_name): payment_gateway=pr.payment_gateway, payment_request_name=pr.name, ) + frappe.db.set_value("Payment Request", payment_request_name, "payment_url", url) frappe.db.commit() return url +@frappe.whitelist(allow_guest=True) +def verify_transaction(**kwargs): + """Unified Webhook Handler for all MTN MoMo Callbacks.""" + data = frappe._dict(kwargs) + ref_id = data.get("referenceId") or data.get("externalId") + + if not ref_id or not frappe.db.exists("Integration Request", ref_id): + return {"status": "error", "message": "Reference not found"} + + integration_request = frappe.get_doc("Integration Request", ref_id) + + if data.get("status") == "SUCCESSFUL": + original_user = frappe.session.user + try: + frappe.set_user("Administrator") + integration_request.handle_success(data) + integration_request.db_set("status", "Completed") + + momo_id = data.get("financialTransactionId") or "Confirmed" + + if integration_request.reference_doctype == "Sales Invoice": + frappe.db.set_value("Sales Invoice", integration_request.reference_docname, { + "momo_transaction_verified": 1, + "momo_transaction_id": momo_id + }) + + if integration_request.reference_doctype == "Payment Request": + pr = frappe.get_doc("Payment Request", integration_request.reference_docname) + if pr.status != "Paid": + pr.create_payment_entry() + finalize_webshop_payment(pr.name) + + frappe.db.commit() + except Exception: + frappe.log_error(frappe.get_traceback(), "MoMo Callback Processing Error") + finally: + frappe.set_user(original_user) + else: + integration_request.handle_failure(data) + + return {"status": "processed"} + @frappe.whitelist(allow_guest=True) def request_for_payment_by_gateway(gateway_name, **kwargs): return frappe.get_doc("MoMo Settings", gateway_name).request_for_payment(**kwargs) + +def create_mode_of_payment(gateway, payment_type="General"): + if not frappe.db.exists("Mode of Payment", gateway): + mo_pay = frappe.get_doc({ + "doctype": "Mode of Payment", + "mode_of_payment": gateway, + "enabled": 1, + "type": payment_type + }) + mo_pay.insert(ignore_permissions=True) + +@frappe.whitelist(allow_guest=True) +def get_integration_request_status(reference_id): + if not frappe.db.exists("Integration Request", reference_id): + return {"status": "Not Found"} + status = frappe.db.get_value("Integration Request", reference_id, "status") + return {"status": status} + +@frappe.whitelist() +def poll_transaction_status(reference_id, gateway_name=None): + if not gateway_name: + gateway_name = frappe.db.get_value("MoMo Settings", {}, "name") + + if not gateway_name: + frappe.throw(_("Please provide a MoMo Settings Gateway Name.")) + + if reference_id == "00000000-0000-0000-0000-000000000000": + try: + doc = frappe.get_doc("MoMo Settings", gateway_name) + connector = doc._get_connector() + return {"status": "AUTH_SUCCESS", "message": "Credentials verified successfully."} + except Exception as e: + frappe.log_error(frappe.get_traceback(), "MoMo Auth Test Failed") + frappe.throw(_("Authentication Failed: {0}").format(str(e))) + + doc = frappe.get_doc("MoMo Settings", gateway_name) + connector = doc._get_connector() + return connector.get_transaction_status(reference_id) + +@frappe.whitelist() +def poll_pending_transactions(gateway_name=None): + pending = frappe.get_all("Integration Request", + filters={"status": "Queued", "integration_request_service": "MoMo"}, + fields=["name"] + ) + for entry in pending: + poll_transaction_status(reference_id=entry.name, gateway_name=gateway_name) + return True + +@frappe.whitelist() +def initiate_webshop_payment(token, gateway_name, phone): + """ + Called from the MoMo checkout page. + Reads cart data from cache, creates a Payment Request, sends STK push. + Does NOT create SO/SI yet — that happens only after payment confirmed. + """ + import json + raw = frappe.cache().get_value(f"momo_pending_{token}") + if not raw: + return {"error": "Payment session expired. Please return to your cart."} + + cart_data = frappe._dict(json.loads(raw)) + + # Build full gateway name + gw_full = "MoMo-" + gateway_name if not gateway_name.startswith("MoMo-") else gateway_name + + pga = frappe.db.get_value( + "Payment Gateway Account", + {"payment_gateway": gw_full}, + ["name", "payment_account", "currency"], + as_dict=True, + ) + + # Create a lightweight Payment Request (no SO yet — reference is the token) + pr = frappe.new_doc("Payment Request") + pr.payment_request_type = "Inward" + pr.party_type = "Customer" + pr.party = cart_data.customer + pr.reference_doctype = "Quotation" + pr.reference_name = cart_data.quotation_name + pr.payment_gateway_account = pga.name if pga else "" + pr.payment_gateway = gw_full + pr.payment_account = pga.payment_account if pga else "" + pr.currency = cart_data.currency + pr.grand_total = cart_data.grand_total + pr.base_grand_total = cart_data.grand_total + pr.outstanding_amount = cart_data.grand_total + pr.email_to = cart_data.contact_email or cart_data.customer + pr.subject = f"Webshop payment for {cart_data.customer_name}" + pr.flags.ignore_permissions = True + pr.insert() + frappe.db.set_value("Payment Request", pr.name, {"docstatus": 1, "status": "Requested"}) + + # Store PR name back into the token cache so finalize can find it + cart_data["payment_request"] = pr.name + frappe.cache().set_value(f"momo_pending_{token}", json.dumps(cart_data), expires_in_sec=1800) + + # Clean and format phone + clean_phone = "".join(filter(str.isdigit, str(phone))) + if len(clean_phone) == 9: + clean_phone = "237" + clean_phone + + # Get connector and send STK push + settings_name = frappe.db.get_value("MoMo Settings", {"gateway_name": gateway_name}, "name") + if not settings_name: + return {"error": f"MoMo Settings not found for: {gateway_name}"} + + momo_doc = frappe.get_doc("MoMo Settings", settings_name) + connector = momo_doc._get_connector() + + int_amount = str(int(float(cart_data.grand_total))) + callback_url = frappe.utils.get_url( + "/api/method/payments.payment_gateways.doctype.momo_settings.momo_settings.verify_transaction" + ) + + response = connector.request_to_pay( + amount=int_amount, + currency=cart_data.currency, + payer_msisdn=clean_phone, + external_id=pr.name, + callback_url=callback_url, + ) + + if not response or not response.get("referenceId"): + return {"error": "MTN did not return a reference ID. Check phone number and try again."} + + reference_id = response["referenceId"] + + from frappe.integrations.utils import create_request_log + args = frappe._dict( + sender=clean_phone, request_amount=int_amount, + currency=cart_data.currency, order_id=pr.name, + reference_doctype="Payment Request", reference_docname=pr.name, + ) + create_request_log(args, "Host", "MoMo", reference_id) + frappe.db.set_value("Integration Request", reference_id, { + "reference_doctype": "Payment Request", + "reference_docname": pr.name, + }) + frappe.db.commit() + + return {"reference_id": reference_id, "payment_request": pr.name} + + +@frappe.whitelist() +@frappe.whitelist() +def poll_webshop_transaction(reference_id, gateway_name=None): + """ + Active poll: checks DB first, then asks MTN directly if still pending. + Returns {"status": "SUCCESSFUL" | "FAILED" | "PENDING" | "TIMEOUT"} + Safe to call repeatedly — never creates Payment Entries itself. + """ + if not frappe.db.exists("Integration Request", reference_id): + return {"status": "PENDING"} # not logged yet, still in flight + + db_status = frappe.db.get_value("Integration Request", reference_id, "status") + + # Fast path — already resolved in DB + if db_status == "Completed": + return {"status": "SUCCESSFUL"} + if db_status in ("Failed", "Cancelled"): + return {"status": "FAILED"} + + # Still Queued/Pending — ask MTN directly + try: + # Resolve gateway name + bare_name = (gateway_name or "").replace("MoMo-", "") if gateway_name else None + if not bare_name: + # Try to find it from the Integration Request -> Payment Request -> gateway + ir = frappe.get_doc("Integration Request", reference_id) + pr_name = ir.reference_docname + if pr_name and frappe.db.exists("Payment Request", pr_name): + gw_full = frappe.db.get_value("Payment Request", pr_name, "payment_gateway") or "" + bare_name = gw_full.replace("MoMo-", "") + + if not bare_name: + return {"status": "PENDING"} + + settings_name = frappe.db.get_value("MoMo Settings", {"gateway_name": bare_name}, "name") + if not settings_name: + return {"status": "PENDING"} + + momo_doc = frappe.get_doc("MoMo Settings", settings_name) + connector = momo_doc._get_connector() + mtn_resp = connector.get_transaction_status(reference_id) + mtn_status = (mtn_resp or {}).get("status", "PENDING") + + if mtn_status == "SUCCESSFUL": + # Update DB so callback isn't needed + frappe.db.set_value("Integration Request", reference_id, "status", "Completed") + frappe.db.commit() + return {"status": "SUCCESSFUL"} + + if mtn_status == "FAILED": + frappe.db.set_value("Integration Request", reference_id, "status", "Failed") + frappe.db.commit() + return {"status": "FAILED"} + + return {"status": "PENDING"} + + except Exception: + frappe.log_error(frappe.get_traceback(), "MoMo poll_webshop_transaction Error") + return {"status": "PENDING"} # don't surface errors to frontend, just keep polling + + +def on_sales_invoice_submit(doc, method=None): + """ + Triggered when a Sales Invoice is submitted. + If the SI was created from a Sales Order that has a MoMo Payment Entry, + automatically relinks the PE from the SO to the SI and marks SI as Paid. + """ + # Find all SOs linked to this SI + so_names = list(set( + row.sales_order + for row in doc.get("items") or [] + if row.get("sales_order") + )) + if not so_names: + return + + for so_name in so_names: + # Find a submitted PE referencing this SO + pe_ref = frappe.db.get_value( + "Payment Entry Reference", + { + "reference_doctype": "Sales Order", + "reference_name": so_name, + }, + ["parent", "allocated_amount"], + as_dict=True, + ) + if not pe_ref: + continue + + pe_name = pe_ref.parent + paid_amount = float(pe_ref.allocated_amount or 0) + + # Confirm PE is submitted + pe_status = frappe.db.get_value("Payment Entry", pe_name, "docstatus") + if pe_status != 1: + continue + + try: + # Remove old SO reference row directly in DB + frappe.db.delete("Payment Entry Reference", { + "parent": pe_name, + "reference_doctype": "Sales Order", + "reference_name": so_name, + }) + + # Insert SI reference row directly + frappe.db.sql(""" + INSERT INTO `tabPayment Entry Reference` + (name, parent, parenttype, parentfield, + reference_doctype, reference_name, + allocated_amount, total_amount, outstanding_amount) + VALUES (%s, %s, 'Payment Entry', 'references', + 'Sales Invoice', %s, %s, %s, %s) + """, ( + frappe.generate_hash(length=10), + pe_name, + doc.name, + paid_amount, + doc.grand_total, + doc.grand_total, + )) + + # Force SI to Paid + outstanding = max(0.0, float(doc.grand_total) - paid_amount) + status = "Paid" if outstanding == 0 else "Partly Paid" + frappe.db.set_value("Sales Invoice", doc.name, { + "outstanding_amount": outstanding, + "status": status, + }) + + frappe.db.commit() + frappe.log_error( + f"PE {pe_name} relinked from SO {so_name} to SI {doc.name}", + "MoMo: SI Auto-Reconcile OK" + ) + + except Exception: + frappe.log_error( + frappe.get_traceback(), + f"MoMo: SI Auto-Reconcile Failed — PE {pe_name} / SI {doc.name}" + ) diff --git a/payments/payment_gateways/doctype/momo_settings/momo_settings.py.bak_1774600341 b/payments/payment_gateways/doctype/momo_settings/momo_settings.py.bak_1774600341 new file mode 100644 index 000000000..d72c67b26 --- /dev/null +++ b/payments/payment_gateways/doctype/momo_settings/momo_settings.py.bak_1774600341 @@ -0,0 +1,802 @@ +import frappe +from frappe import _ +from frappe.integrations.utils import create_request_log +from frappe.model.document import Document +from frappe.utils import call_hook_method, get_url +from urllib.parse import urlencode + +from payments.payment_gateways.doctype.momo_settings.momo_connector import MomoConnector + +class MoMoSettings(Document): + def validate_transaction_currency(self, currency): + supported = self.supported_currencies or "UGX,GHS,XAF,ZMW,RWF" + if currency not in supported.split(','): + frappe.throw(_("Currency {0} not supported by MoMo").format(currency)) + + def on_update(self): + """Syncs the Gateway and Mode of Payment on save.""" + from payments.utils import create_payment_gateway + create_payment_gateway( + "MoMo-" + self.gateway_name, + settings="MoMo Settings", + controller=self.gateway_name, + ) + create_mode_of_payment("MoMo-" + self.gateway_name, payment_type="Phone") + callback = ( + frappe.utils.get_url() + + "/api/method/payments.payment_gateways.doctype.momo_settings" + ".momo_settings.verify_transaction" + ) + self.db_set("callback_url", callback, update_modified=False) + frappe.db.commit() + + def _get_connector(self): + return MomoConnector( + env="sandbox" if self.use_sandbox else "production", + api_user_id=self.api_user_id, + api_key=self.get_password("api_key"), + subscription_key=self.get_password("subscription_key"), + target_environment=self.target_environment + ) + + def get_payment_url(self, **kwargs): + """Used for Webshop/Checkout redirects.""" + kwargs.setdefault("payment_request_name", kwargs.get("order_id", "")) + return get_url(f"momo_checkout?{urlencode(kwargs)}") + + @frappe.whitelist(allow_guest=True) + def request_for_payment(self, **kwargs): + """Unified entry point for POS, Webshop, and Desk.""" + args = frappe._dict(kwargs) + + # 1. IDENTIFY THE SENDER (Handles multiple possible keys from POS/Desk) + raw_sender = args.sender or args.payer_msisdn or "" + + # 2. THE POS "WALKIN" GATEKEEPER + # Blocks the request if 'WalkIn' is passed instead of a number + clean_digits = "".join(filter(str.isdigit, str(raw_sender))) + + if not clean_digits or str(raw_sender).lower() in ["walkin", "walk-in"]: + frappe.throw( + _("MoMo requires a valid phone number. Please enter a phone number in the payment field instead of '{0}'.").format(raw_sender), + title=_("Invalid Phone Number") + ) + + # 3. AUTO-FORMAT MSISDN (Standardizing to 237...) + sender = clean_digits + if sender.startswith("0"): + sender = "237" + sender[1:] + elif len(sender) == 9: + sender = "237" + sender + + # 4. CLEAN THE AMOUNT (MTN XAF often rejects decimals like 50.0) + try: + # Converts "50.0" -> 50 + request_amount = str(int(float(args.request_amount or args.amount or 0))) + except (ValueError, TypeError): + request_amount = args.request_amount or args.amount + + try: + connector = self._get_connector() + callback_url = get_url("/api/method/payments.payment_gateways.doctype.momo_settings.momo_settings.verify_transaction") + + response = connector.request_to_pay( + amount=request_amount, + currency=args.currency, + payer_msisdn=sender, + external_id=args.order_id or args.payment_request_name, + callback_url=callback_url, + ) + + if response and response.get("referenceId"): + # Logs the attempt in 'Integration Request' + create_request_log(args, "Host", "MoMo", response["referenceId"]) + + return response + except Exception: + frappe.log_error(frappe.get_traceback(), "MoMo Request Error") + frappe.throw(_("MoMo Service Unavailable. Check API credentials."), title=_("Payment Error")) + +# ────────────────────────────────────────────────────────────────────────────── +# GLOBAL WHITELISTED METHODS +# ────────────────────────────────────────────────────────────────────────────── + +@frappe.whitelist() +def finalize_webshop_order(token, reference_id): + """ + Called after polling confirms payment is Completed. + Creates SO → PE (against SO) → SI → reconcile PE to SI. + """ + import json + + raw = frappe.cache().get_value(f"momo_pending_{token}") + if not raw: + frappe.log_error(f"Token expired: {token}, ref: {reference_id}", "MoMo Finalize: Token Missing") + return {"error": "Session expired. Contact support with ref: " + reference_id} + + cart_data = json.loads(raw) # plain dict — avoid frappe._dict to prevent .items() conflict + pr_name = cart_data.get("payment_request") + paid_amount = float(cart_data.get("grand_total") or 0) + + # ── DUPLICATE GUARD ────────────────────────────────────────── + existing_so = frappe.db.get_value( + "Sales Order", + {"po_no": cart_data.get("quotation_name"), "docstatus": 1}, + "name" + ) + if existing_so: + frappe.cache().delete_value(f"momo_pending_{token}") + return {"sales_order": existing_so} + + original_user = frappe.session.user + try: + frappe.set_user("Administrator") + + # ── 1. SALES ORDER ─────────────────────────────────────── + so = frappe.new_doc("Sales Order") + so.customer = cart_data["customer"] + so.company = cart_data["company"] + so.currency = cart_data["currency"] + so.delivery_date = frappe.utils.nowdate() + so.transaction_date = frappe.utils.nowdate() + so.order_type = "Sales" + so.po_no = cart_data.get("quotation_name") + + if cart_data.get("shipping_address_name"): + so.shipping_address_name = cart_data["shipping_address_name"] + if cart_data.get("customer_address"): + so.customer_address = cart_data["customer_address"] + + for item in cart_data.get("items") or []: + so.append("items", { + "item_code": item["item_code"], + "qty": item.get("qty", 1), + "rate": item.get("rate", 0), + "warehouse": item.get("warehouse"), + "uom": item.get("uom"), + }) + + for tax in cart_data.get("taxes") or []: + so.append("taxes", { + "charge_type": tax.get("charge_type", "On Net Total"), + "account_head": tax["account_head"], + "description": tax.get("description") or tax["account_head"], + "rate": tax.get("rate", 0), + }) + + so.flags.ignore_permissions = True + so.insert() + so.submit() + frappe.db.commit() + frappe.log_error(f"SO created: {so.name}", "MoMo Finalize: Step 1 OK") + + # ── 2. PAYMENT ENTRY (against unbilled SO) ─────────────── + gw_full = frappe.db.get_value("Payment Request", pr_name, "payment_gateway") if pr_name else "" + pga = frappe.db.get_value( + "Payment Gateway Account", + {"payment_gateway": gw_full}, + ["payment_account", "currency"], + as_dict=True, + ) if gw_full else None + + receivable_account = ( + frappe.db.get_value("Party Account", + {"parenttype": "Customer", "parent": so.customer, "company": so.company}, + "account") + or frappe.db.get_value("Account", + {"account_type": "Receivable", "company": so.company, "is_group": 0}, + "name") + or frappe.db.get_value("Company", so.company, "default_receivable_account") + ) + + pe = frappe.new_doc("Payment Entry") + pe.payment_type = "Receive" + pe.posting_date = frappe.utils.nowdate() + pe.company = so.company + pe.party_type = "Customer" + pe.party = so.customer + pe.party_name = so.customer_name + pe.paid_from = receivable_account + pe.paid_from_account_currency = so.currency + pe.paid_to = pga.payment_account if pga else "" + pe.paid_to_account_currency = so.currency + pe.paid_amount = paid_amount + pe.received_amount = paid_amount + pe.source_exchange_rate = 1 + pe.target_exchange_rate = 1 + pe.reference_no = reference_id + pe.reference_date = frappe.utils.nowdate() + pe.remarks = f"MoMo payment. PR: {pr_name}, SO: {so.name}, MTN Ref: {reference_id}" + + pe.append("references", { + "reference_doctype": "Sales Order", + "reference_name": so.name, + "allocated_amount": paid_amount, + "total_amount": so.grand_total, + "outstanding_amount": so.grand_total, + }) + + pe.flags.ignore_permissions = True + pe.insert() + pe.submit() + frappe.db.commit() + frappe.log_error(f"PE created: {pe.name}", "MoMo Finalize: Step 2 OK") + + # ── 3. SALES INVOICE ───────────────────────────────────── + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + si = make_sales_invoice(so.name) + si.flags.ignore_permissions = True + si.insert() + si.submit() + frappe.db.commit() + frappe.log_error(f"SI created: {si.name}", "MoMo Finalize: Step 3 OK") + + # ── 4. RECONCILE PE → SI ───────────────────────────────── + try: + # Remove SO ref row directly in DB (avoids submitted doc validation) + frappe.db.delete("Payment Entry Reference", { + "parent": pe.name, + "reference_doctype": "Sales Order", + "reference_name": so.name, + }) + # Insert SI ref row directly + frappe.db.sql(""" + INSERT INTO `tabPayment Entry Reference` + (name, parent, parenttype, parentfield, + reference_doctype, reference_name, + allocated_amount, total_amount, outstanding_amount) + VALUES (%s, %s, 'Payment Entry', 'references', + 'Sales Invoice', %s, %s, %s, %s) + """, ( + frappe.generate_hash(length=10), + pe.name, si.name, + paid_amount, si.grand_total, si.grand_total, + )) + frappe.db.commit() + frappe.log_error(f"PE reconciled to SI {si.name}", "MoMo Finalize: Step 4 OK") + + except Exception: + frappe.log_error(frappe.get_traceback(), "MoMo Finalize: reconcile failed (non-critical)") + + # ── 5. FORCE SI TO PAID ────────────────────────────────── + frappe.db.set_value("Sales Invoice", si.name, { + "outstanding_amount": 0, + "status": "Paid", + }) + + # ── 6. UPDATE PR ───────────────────────────────────────── + if pr_name and frappe.db.exists("Payment Request", pr_name): + frappe.db.set_value("Payment Request", pr_name, { + "reference_doctype": "Sales Order", + "reference_name": so.name, + "status": "Paid", + }) + + # ── 7. SUBMIT QUOTATION (clears cart) ──────────────────── + try: + quot = frappe.get_doc("Quotation", cart_data.get("quotation_name")) + if quot.docstatus == 0: + quot.flags.ignore_permissions = True + quot.submit() + except Exception: + frappe.log_error(frappe.get_traceback(), "MoMo Finalize: quotation submit failed (non-critical)") + + frappe.db.commit() + frappe.cache().delete_value(f"momo_pending_{token}") + + return {"sales_order": so.name, "sales_invoice": si.name} + + except Exception: + frappe.log_error(frappe.get_traceback(), "MoMo Webshop Finalize Error") + return {"error": "Order creation failed. Contact support with ref: " + reference_id} + finally: + frappe.set_user(original_user) + +@frappe.whitelist() +def generate_payment_url(payment_request_name): + """Generates the URL for the 'Request MoMo Payment' button on Invoices.""" + if not payment_request_name: + frappe.throw(_("Payment Request name is required")) + + pr = frappe.get_doc("Payment Request", payment_request_name) + from payments.utils import get_payment_gateway_controller + controller = get_payment_gateway_controller(pr.payment_gateway) + + url = controller.get_payment_url( + amount=pr.grand_total, + currency=pr.currency, + order_id=pr.name, + reference_doctype="Payment Request", + reference_docname=pr.name, + payer_name=pr.party_name, + payer_email=pr.party, + title=f"Payment for {pr.reference_name}", + description=f"MTN MoMo Payment for {pr.party_name}", + payment_gateway=pr.payment_gateway, + payment_request_name=pr.name, + ) + + frappe.db.set_value("Payment Request", payment_request_name, "payment_url", url) + frappe.db.commit() + return url + +@frappe.whitelist(allow_guest=True) +def verify_transaction(**kwargs): + """Unified Webhook Handler for all MTN MoMo Callbacks.""" + data = frappe._dict(kwargs) + ref_id = data.get("referenceId") or data.get("externalId") + + if not ref_id or not frappe.db.exists("Integration Request", ref_id): + return {"status": "error", "message": "Reference not found"} + + integration_request = frappe.get_doc("Integration Request", ref_id) + + if data.get("status") == "SUCCESSFUL": + original_user = frappe.session.user + try: + frappe.set_user("Administrator") + integration_request.handle_success(data) + integration_request.db_set("status", "Completed") + + momo_id = data.get("financialTransactionId") or "Confirmed" + + if integration_request.reference_doctype == "Sales Invoice": + frappe.db.set_value("Sales Invoice", integration_request.reference_docname, { + "momo_transaction_verified": 1, + "momo_transaction_id": momo_id + }) + + if integration_request.reference_doctype == "Payment Request": + pr = frappe.get_doc("Payment Request", integration_request.reference_docname) + if pr.status != "Paid": + pr.create_payment_entry() + finalize_webshop_payment(pr.name) + + frappe.db.commit() + except Exception: + frappe.log_error(frappe.get_traceback(), "MoMo Callback Processing Error") + finally: + frappe.set_user(original_user) + else: + integration_request.handle_failure(data) + + return {"status": "processed"} + +@frappe.whitelist(allow_guest=True) +def request_for_payment_by_gateway(gateway_name, **kwargs): + return frappe.get_doc("MoMo Settings", gateway_name).request_for_payment(**kwargs) + +def create_mode_of_payment(gateway, payment_type="General"): + if not frappe.db.exists("Mode of Payment", gateway): + mo_pay = frappe.get_doc({ + "doctype": "Mode of Payment", + "mode_of_payment": gateway, + "enabled": 1, + "type": payment_type + }) + mo_pay.insert(ignore_permissions=True) + +@frappe.whitelist(allow_guest=True) +def get_integration_request_status(reference_id): + if not frappe.db.exists("Integration Request", reference_id): + return {"status": "Not Found"} + status = frappe.db.get_value("Integration Request", reference_id, "status") + return {"status": status} + +@frappe.whitelist() +def poll_transaction_status(reference_id, gateway_name=None): + if not gateway_name: + gateway_name = frappe.db.get_value("MoMo Settings", {}, "name") + + if not gateway_name: + frappe.throw(_("Please provide a MoMo Settings Gateway Name.")) + + if reference_id == "00000000-0000-0000-0000-000000000000": + try: + doc = frappe.get_doc("MoMo Settings", gateway_name) + connector = doc._get_connector() + return {"status": "AUTH_SUCCESS", "message": "Credentials verified successfully."} + except Exception as e: + frappe.log_error(frappe.get_traceback(), "MoMo Auth Test Failed") + frappe.throw(_("Authentication Failed: {0}").format(str(e))) + + doc = frappe.get_doc("MoMo Settings", gateway_name) + connector = doc._get_connector() + return connector.get_transaction_status(reference_id) + +@frappe.whitelist() +def poll_pending_transactions(gateway_name=None): + pending = frappe.get_all("Integration Request", + filters={"status": "Queued", "integration_request_service": "MoMo"}, + fields=["name"] + ) + for entry in pending: + poll_transaction_status(reference_id=entry.name, gateway_name=gateway_name) + return True + +@frappe.whitelist() +def initiate_webshop_payment(token, gateway_name, phone): + """ + Called from the MoMo checkout page. + Reads cart data from cache, creates a Payment Request, sends STK push. + Does NOT create SO/SI yet — that happens only after payment confirmed. + """ + import json + raw = frappe.cache().get_value(f"momo_pending_{token}") + if not raw: + return {"error": "Payment session expired. Please return to your cart."} + + cart_data = frappe._dict(json.loads(raw)) + + # Build full gateway name + gw_full = "MoMo-" + gateway_name if not gateway_name.startswith("MoMo-") else gateway_name + + pga = frappe.db.get_value( + "Payment Gateway Account", + {"payment_gateway": gw_full}, + ["name", "payment_account", "currency"], + as_dict=True, + ) + + # Create a lightweight Payment Request (no SO yet — reference is the token) + pr = frappe.new_doc("Payment Request") + pr.payment_request_type = "Inward" + pr.party_type = "Customer" + pr.party = cart_data.customer + pr.reference_doctype = "Quotation" + pr.reference_name = cart_data.quotation_name + pr.payment_gateway_account = pga.name if pga else "" + pr.payment_gateway = gw_full + pr.payment_account = pga.payment_account if pga else "" + pr.currency = cart_data.currency + pr.grand_total = cart_data.grand_total + pr.base_grand_total = cart_data.grand_total + pr.outstanding_amount = cart_data.grand_total + pr.email_to = cart_data.contact_email or cart_data.customer + pr.subject = f"Webshop payment for {cart_data.customer_name}" + pr.flags.ignore_permissions = True + pr.insert() + frappe.db.set_value("Payment Request", pr.name, {"docstatus": 1, "status": "Requested"}) + + # Store PR name back into the token cache so finalize can find it + cart_data["payment_request"] = pr.name + frappe.cache().set_value(f"momo_pending_{token}", json.dumps(cart_data), expires_in_sec=1800) + + # Clean and format phone + clean_phone = "".join(filter(str.isdigit, str(phone))) + if len(clean_phone) == 9: + clean_phone = "237" + clean_phone + + # Get connector and send STK push + settings_name = frappe.db.get_value("MoMo Settings", {"gateway_name": gateway_name}, "name") + if not settings_name: + return {"error": f"MoMo Settings not found for: {gateway_name}"} + + momo_doc = frappe.get_doc("MoMo Settings", settings_name) + connector = momo_doc._get_connector() + + int_amount = str(int(float(cart_data.grand_total))) + callback_url = frappe.utils.get_url( + "/api/method/payments.payment_gateways.doctype.momo_settings.momo_settings.verify_transaction" + ) + + response = connector.request_to_pay( + amount=int_amount, + currency=cart_data.currency, + payer_msisdn=clean_phone, + external_id=pr.name, + callback_url=callback_url, + ) + + if not response or not response.get("referenceId"): + return {"error": "MTN did not return a reference ID. Check phone number and try again."} + + reference_id = response["referenceId"] + + from frappe.integrations.utils import create_request_log + args = frappe._dict( + sender=clean_phone, request_amount=int_amount, + currency=cart_data.currency, order_id=pr.name, + reference_doctype="Payment Request", reference_docname=pr.name, + ) + create_request_log(args, "Host", "MoMo", reference_id) + frappe.db.set_value("Integration Request", reference_id, { + "reference_doctype": "Payment Request", + "reference_docname": pr.name, + }) + frappe.db.commit() + + return {"reference_id": reference_id, "payment_request": pr.name} + + +@frappe.whitelist() +def finalize_webshop_order(token, reference_id): + """ + Called after polling confirms payment is Completed. + Creates SO → SI → PE from the cached cart data. + """ + import json + raw = frappe.cache().get_value(f"momo_pending_{token}") + if not raw: + # Try to recover from Integration Request + ir = frappe.db.get_value("Integration Request", reference_id, + ["reference_docname"], as_dict=True) + if ir: + frappe.log_error("MoMo webshop: token expired but IR found", "Webshop Finalize") + return {"error": "Session data not found. Check your email for order confirmation."} + + cart_data = frappe._dict(json.loads(raw)) + pr_name = cart_data.get("payment_request") + paid_amount = float(cart_data.get("grand_total") or 0) + + # DUPLICATE GUARD + existing_so = frappe.db.get_value( + "Sales Order", + {"po_no": cart_data.quotation_name, "docstatus": 1}, + "name" + ) + if existing_so: + frappe.cache().delete_value(f"momo_pending_{token}") + return {"sales_order": existing_so} + + original_user = frappe.session.user + try: + frappe.set_user("Administrator") + + # 1. Create Sales Order from cart data + so = frappe.new_doc("Sales Order") + so.customer = cart_data.customer + so.company = cart_data.company + so.currency = cart_data.currency + so.delivery_date = frappe.utils.nowdate() + so.transaction_date = frappe.utils.nowdate() + so.order_type = "Sales" + so.po_no = cart_data.quotation_name # link back to quotation + + if cart_data.get("shipping_address_name"): + so.shipping_address_name = cart_data.shipping_address_name + if cart_data.get("customer_address"): + so.customer_address = cart_data.customer_address + + for item in cart_data.get("items") or []: + so.append("items", { + "item_code": item.get("item_code") if isinstance(item, dict) else item["item_code"], + "qty": item.get("qty", 1) if isinstance(item, dict) else item["qty"], + "rate": item.get("rate", 0) if isinstance(item, dict) else item["rate"], + "warehouse": item.get("warehouse") if isinstance(item, dict) else item.get("warehouse"), + "uom": item.get("uom") if isinstance(item, dict) else item.get("uom"), + }) + + for tax in cart_data.get("taxes") or []: + so.append("taxes", { + "charge_type": tax.get("charge_type", "On Net Total"), + "account_head": tax.get("account_head"), + "description": tax.get("description") or tax.get("account_head") or "", + "rate": tax.get("rate", 0), + }) + + so.flags.ignore_permissions = True + so.insert() + so.submit() + + # 2. Update Payment Request to reference SO + if pr_name and frappe.db.exists("Payment Request", pr_name): + frappe.db.set_value("Payment Request", pr_name, { + "reference_doctype": "Sales Order", + "reference_name": so.name, + "status": "Paid", + }) + + # 3. Create Payment Entry FIRST (against the unbilled SO) + pe = None + if pr_name and frappe.db.exists("Payment Request", pr_name): + pr_doc = frappe.get_doc("Payment Request", pr_name) + # Update PR to reference the SO so create_payment_entry works + frappe.db.set_value("Payment Request", pr_name, { + "reference_doctype": "Sales Order", + "reference_name": so.name, + "status": "Paid", + }) + pr_doc.reload() + try: + pe = pr_doc.create_payment_entry(submit=True) + except Exception: + # Fallback: create PE manually + frappe.log_error(frappe.get_traceback(), "MoMo: PR.create_payment_entry failed, using manual PE") + pe = None + + # If PR method failed, create PE manually against SO + if not pe: + gw_full = frappe.db.get_value("Payment Request", pr_name, "payment_gateway") if pr_name else "" + pga = frappe.db.get_value( + "Payment Gateway Account", + {"payment_gateway": gw_full}, + ["payment_account", "currency"], + as_dict=True, + ) if gw_full else None + + pe = frappe.new_doc("Payment Entry") + pe.payment_type = "Receive" + pe.posting_date = frappe.utils.nowdate() + pe.company = so.company + pe.party_type = "Customer" + pe.party = so.customer + pe.paid_amount = paid_amount or float(so.grand_total) + pe.received_amount = pe.paid_amount + pe.target_exchange_rate = 1 + pe.source_exchange_rate = 1 + pe.reference_no = pr_name or so.name + pe.reference_date = frappe.utils.nowdate() + pe.remarks = f"MoMo payment for {so.name}" + if pga: + pe.paid_to = pga.payment_account + pe.paid_from = frappe.db.get_value( + "Company", so.company, "default_receivable_account" + ) or frappe.db.get_value( + "Account", + {"account_type": "Receivable", "company": so.company, "is_group": 0}, + "name" + ) + pe.append("references", { + "reference_doctype": "Sales Order", + "reference_name": so.name, + "allocated_amount": pe.paid_amount, + "total_amount": so.grand_total, + "outstanding_amount": so.grand_total, + }) + pe.flags.ignore_permissions = True + pe.insert() + pe.submit() + + # 4. Create Sales Invoice AFTER PE + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + si = make_sales_invoice(so.name) + si.flags.ignore_permissions = True + si.insert() + si.submit() + + # 5. Reconcile PE → SI using ERPNext's reconcile utility + if pe: + try: + pe.reload() + + # Remove SO reference, add SI reference + pe.flags.ignore_permissions = True + pe.flags.ignore_validate_update_after_submit = True + pe.references = [] + pe.append("references", { + "reference_doctype": "Sales Invoice", + "reference_name": si.name, + "allocated_amount": paid_amount, + "total_amount": si.grand_total, + "outstanding_amount": si.grand_total, + }) + pe.save() + frappe.db.commit() + + except Exception: + frappe.log_error(frappe.get_traceback(), "MoMo finalize: PE.save() reconcile failed — trying direct SQL") + # Fallback: direct DB reconcile + try: + # Remove old SO reference row + frappe.db.delete("Payment Entry Reference", { + "parent": pe.name, + "reference_doctype": "Sales Order", + }) + # Insert SI reference row directly + ref_doc = frappe.new_doc("Payment Entry Reference") + ref_doc.parent = pe.name + ref_doc.parenttype = "Payment Entry" + ref_doc.parentfield = "references" + ref_doc.reference_doctype = "Sales Invoice" + ref_doc.reference_name = si.name + ref_doc.allocated_amount = paid_amount + ref_doc.total_amount = si.grand_total + ref_doc.outstanding_amount = si.grand_total + ref_doc.flags.ignore_permissions = True + ref_doc.db_insert() + frappe.db.commit() + except Exception: + frappe.log_error(frappe.get_traceback(), "MoMo finalize: direct SQL reconcile also failed") + + # Either way, force the SI to Paid status + try: + paid_via_pe = frappe.db.sql(""" + SELECT COALESCE(SUM(per.allocated_amount), 0) + FROM `tabPayment Entry Reference` per + JOIN `tabPayment Entry` pe ON pe.name = per.parent + WHERE per.reference_doctype = 'Sales Invoice' + AND per.reference_name = %s + AND pe.docstatus = 1 + """, si.name)[0][0] + + outstanding = max(0, float(si.grand_total) - float(paid_via_pe)) + status = "Paid" if outstanding == 0 else "Partly Paid" if paid_via_pe > 0 else "Unpaid" + frappe.db.set_value("Sales Invoice", si.name, { + "outstanding_amount": outstanding, + "status": status, + }) + frappe.db.commit() + except Exception: + frappe.log_error(frappe.get_traceback(), "MoMo finalize: SI status update failed") + + # Submit the original quotation so cart clears + try: + quot = frappe.get_doc("Quotation", cart_data.quotation_name) + if quot.docstatus == 0: + quot.flags.ignore_permissions = True + quot.submit() + except Exception: + pass + + frappe.db.commit() + + # Clear cache token + frappe.cache().delete_value(f"momo_pending_{token}") + + return {"sales_order": so.name, "sales_invoice": si.name} + + except Exception: + frappe.log_error(frappe.get_traceback(), "MoMo Webshop Finalize Error") + return {"error": "Order creation failed. Please contact support with reference: " + reference_id} + finally: + frappe.set_user(original_user) + +@frappe.whitelist() +def poll_webshop_transaction(reference_id, gateway_name=None): + """ + Active poll: checks DB first, then asks MTN directly if still pending. + Returns {"status": "SUCCESSFUL" | "FAILED" | "PENDING" | "TIMEOUT"} + Safe to call repeatedly — never creates Payment Entries itself. + """ + if not frappe.db.exists("Integration Request", reference_id): + return {"status": "PENDING"} # not logged yet, still in flight + + db_status = frappe.db.get_value("Integration Request", reference_id, "status") + + # Fast path — already resolved in DB + if db_status == "Completed": + return {"status": "SUCCESSFUL"} + if db_status in ("Failed", "Cancelled"): + return {"status": "FAILED"} + + # Still Queued/Pending — ask MTN directly + try: + # Resolve gateway name + bare_name = (gateway_name or "").replace("MoMo-", "") if gateway_name else None + if not bare_name: + # Try to find it from the Integration Request -> Payment Request -> gateway + ir = frappe.get_doc("Integration Request", reference_id) + pr_name = ir.reference_docname + if pr_name and frappe.db.exists("Payment Request", pr_name): + gw_full = frappe.db.get_value("Payment Request", pr_name, "payment_gateway") or "" + bare_name = gw_full.replace("MoMo-", "") + + if not bare_name: + return {"status": "PENDING"} + + settings_name = frappe.db.get_value("MoMo Settings", {"gateway_name": bare_name}, "name") + if not settings_name: + return {"status": "PENDING"} + + momo_doc = frappe.get_doc("MoMo Settings", settings_name) + connector = momo_doc._get_connector() + mtn_resp = connector.get_transaction_status(reference_id) + mtn_status = (mtn_resp or {}).get("status", "PENDING") + + if mtn_status == "SUCCESSFUL": + # Update DB so callback isn't needed + frappe.db.set_value("Integration Request", reference_id, "status", "Completed") + frappe.db.commit() + return {"status": "SUCCESSFUL"} + + if mtn_status == "FAILED": + frappe.db.set_value("Integration Request", reference_id, "status", "Failed") + frappe.db.commit() + return {"status": "FAILED"} + + return {"status": "PENDING"} + + except Exception: + frappe.log_error(frappe.get_traceback(), "MoMo poll_webshop_transaction Error") + return {"status": "PENDING"} # don't surface errors to frontend, just keep polling diff --git a/payments/templates/pages/momo_checkout.html b/payments/templates/pages/momo_checkout.html index 41718dd03..35a59f680 100644 --- a/payments/templates/pages/momo_checkout.html +++ b/payments/templates/pages/momo_checkout.html @@ -1,102 +1,431 @@ {% extends "templates/web.html" %} {% block page_content %} -
-

MTN Mobile Money Payment

-

Payment for: {{ title or payment_request_name }}

+ + +
+
+ +

MTN Mobile Money

+

{{ title or "Complete Your Payment" }}

+
{{ currency }} {{ "{:,.0f}".format(amount | float) }}
+
- -
- - - Include country code without "+" (e.g., 237 for Cameroon). + + +
+
+
1
+
Enter Number
+
+
+
2
+
Confirm on Phone
+
+
+
3
+
Done
+
+
+ +
+ + +
+ + +

The MTN number that will receive the payment prompt.

+ + +
+ + +
+
+
+
Sending request...
+
Please wait
+
+
0s elapsed
+
+ + + +
+
-
- + - - +
{% endblock %} diff --git a/payments/templates/pages/momo_checkout.html_bk b/payments/templates/pages/momo_checkout.html_bk new file mode 100644 index 000000000..41718dd03 --- /dev/null +++ b/payments/templates/pages/momo_checkout.html_bk @@ -0,0 +1,102 @@ +{% extends "templates/web.html" %} + +{% block page_content %} +
+

MTN Mobile Money Payment

+

Payment for: {{ title or payment_request_name }}

+
+ +
+ + + Include country code without "+" (e.g., 237 for Cameroon). +
+ +
+ +
+ + +
+ + +{% endblock %} diff --git a/payments/templates/pages/momo_checkout.py b/payments/templates/pages/momo_checkout.py index 5b9e16de7..7fdbc59bc 100644 --- a/payments/templates/pages/momo_checkout.py +++ b/payments/templates/pages/momo_checkout.py @@ -1,59 +1,23 @@ -""" -MoMo checkout page — context builder. -Mirrors payments/templates/pages/gocardless_checkout.py. -""" - -import frappe +import frappe, json from frappe import _ -from frappe.utils import flt -from payments.utils import get_payment_gateway_controller - -no_cache = 1 - -EXPECTED_KEYS = ( - "amount", - "title", - "description", - "reference_doctype", - "reference_docname", - "payer_name", - "payer_email", - "order_id", - "currency", - "payment_request_name", -) - def get_context(context): - context.no_cache = 1 - - missing = set(EXPECTED_KEYS) - set(frappe.form_dict.keys()) - - if missing: - frappe.redirect_to_message( - _("Missing parameters"), - _("The payment link is incomplete. Missing: {0}").format( - ", ".join(missing) - ), - ) - raise frappe.Redirect - - for key in EXPECTED_KEYS: - context[key] = frappe.form_dict.get(key, "") - - context["amount"] = flt(context["amount"]) - - # Resolve the gateway controller (MoMo Settings document) - try: - gateway_controller = get_payment_gateway_controller( - frappe.form_dict.get("payment_gateway", "") - ) - context["gateway_name"] = gateway_controller.gateway_name - - except Exception: - context["gateway_name"] = "" - - # Page metadata - context["title"] = context.get("title") or _("MTN MoMo Payment") - context["card_title"] = _("Pay with MTN MoMo") - context["frappe_csrf_token"] = frappe.session.csrf_token + token = frappe.form_dict.get("token") + if not token: + frappe.throw(_("Invalid or missing payment token"), frappe.PermissionError) + + raw = frappe.cache().get_value(f"momo_pending_{token}") + if not raw: + frappe.throw(_("Payment session expired. Please go back to your cart and try again.")) + + cart_data = json.loads(raw) + + context.update({ + "token": token, + "amount": frappe.form_dict.get("amount") or cart_data["grand_total"], + "currency": cart_data["currency"], + "gateway_name": frappe.form_dict.get("gateway_name"), + "title": frappe.form_dict.get("title") or "Complete Your Payment", + "customer_name": cart_data.get("customer_name", ""), + "no_cache": 1, + }) diff --git a/payments/templates/pages/momo_checkout.py_bk b/payments/templates/pages/momo_checkout.py_bk new file mode 100644 index 000000000..5b9e16de7 --- /dev/null +++ b/payments/templates/pages/momo_checkout.py_bk @@ -0,0 +1,59 @@ +""" +MoMo checkout page — context builder. +Mirrors payments/templates/pages/gocardless_checkout.py. +""" + +import frappe +from frappe import _ +from frappe.utils import flt +from payments.utils import get_payment_gateway_controller + +no_cache = 1 + +EXPECTED_KEYS = ( + "amount", + "title", + "description", + "reference_doctype", + "reference_docname", + "payer_name", + "payer_email", + "order_id", + "currency", + "payment_request_name", +) + + +def get_context(context): + context.no_cache = 1 + + missing = set(EXPECTED_KEYS) - set(frappe.form_dict.keys()) + + if missing: + frappe.redirect_to_message( + _("Missing parameters"), + _("The payment link is incomplete. Missing: {0}").format( + ", ".join(missing) + ), + ) + raise frappe.Redirect + + for key in EXPECTED_KEYS: + context[key] = frappe.form_dict.get(key, "") + + context["amount"] = flt(context["amount"]) + + # Resolve the gateway controller (MoMo Settings document) + try: + gateway_controller = get_payment_gateway_controller( + frappe.form_dict.get("payment_gateway", "") + ) + context["gateway_name"] = gateway_controller.gateway_name + + except Exception: + context["gateway_name"] = "" + + # Page metadata + context["title"] = context.get("title") or _("MTN MoMo Payment") + context["card_title"] = _("Pay with MTN MoMo") + context["frappe_csrf_token"] = frappe.session.csrf_token