diff --git a/payments/hooks.py b/payments/hooks.py index c6039c777..2199cf9ab 100644 --- a/payments/hooks.py +++ b/payments/hooks.py @@ -29,10 +29,7 @@ # page_js = {"page" : "public/js/file.js"} # include js in doctype views -# doctype_js = {"doctype" : "public/js/doctype.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"} +doctype_js = {"Payment Request": "public/js/payment_request.js"} # Home Pages # ---------- @@ -42,7 +39,7 @@ # website user home page (by Role) # role_home_page = { -# "Role": "home_page" +# "Role": "home_page" # } # Generators @@ -56,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 @@ -70,51 +67,46 @@ # ------------ 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", - ], + "all": [ + "payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.capture_payment", + ], + "hourly": [ + "payments.payment_gateways.doctype.momo_settings.momo_settings.poll_pending_transactions", + ], } # Testing @@ -124,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/__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_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.py b/payments/payment_gateways/doctype/momo_settings/momo_connector.py new file mode 100644 index 000000000..9e334b5d0 --- /dev/null +++ b/payments/payment_gateways/doctype/momo_settings/momo_connector.py @@ -0,0 +1,121 @@ +""" +MomoConnector — MTN Mobile Money Collection API client. +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: + 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): + 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", + reference_id=None, + ): + reference_id = reference_id or 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), + "currency": currency, + "externalId": external_id or reference_id, + "payer": { + "partyIdType": "MSISDN", + "partyId": payer_msisdn, + }, + "payerMessage": payer_message, + "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"} + + response.raise_for_status() + + def get_transaction_status(self, reference_id): + 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..c50296f0f --- /dev/null +++ b/payments/payment_gateways/doctype/momo_settings/momo_settings.py @@ -0,0 +1,642 @@ +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 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} + + 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") + + # 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} + + original_user = frappe.session.user + try: + frappe.set_user("Administrator") + + # 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 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() + 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: + 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) + + +@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() +@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/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..35a59f680 --- /dev/null +++ b/payments/templates/pages/momo_checkout.html @@ -0,0 +1,431 @@ +{% extends "templates/web.html" %} + +{% block page_content %} + + +
+ {{ title or "Complete Your Payment" }}
+The MTN number that will receive the payment prompt.
+ + +Payment for: {{ title or payment_request_name }}
+