Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 27 additions & 67 deletions payments/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ----------
Expand All @@ -42,7 +39,7 @@

# website user home page (by Role)
# role_home_page = {
# "Role": "home_page"
# "Role": "home_page"
# }

# Generators
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 = []
Empty file.
28 changes: 28 additions & 0 deletions payments/payment_gateways/doctype/momo_settings/momo_checkout.py
Original file line number Diff line number Diff line change
@@ -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}",
})
121 changes: 121 additions & 0 deletions payments/payment_gateways/doctype/momo_settings/momo_connector.py
Original file line number Diff line number Diff line change
@@ -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()
Loading