diff --git a/dashboard/src/assets/mpesa.svg b/dashboard/src/assets/mpesa.svg new file mode 100644 index 0000000000..d24056df00 --- /dev/null +++ b/dashboard/src/assets/mpesa.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/src2/components/AddExchangeRate.vue b/dashboard/src2/components/AddExchangeRate.vue new file mode 100644 index 0000000000..9d5ba2fa66 --- /dev/null +++ b/dashboard/src2/components/AddExchangeRate.vue @@ -0,0 +1,157 @@ + + + diff --git a/dashboard/src2/components/AddMpesaCredentials.vue b/dashboard/src2/components/AddMpesaCredentials.vue new file mode 100644 index 0000000000..ab078b6eab --- /dev/null +++ b/dashboard/src2/components/AddMpesaCredentials.vue @@ -0,0 +1,180 @@ + + + \ No newline at end of file diff --git a/dashboard/src2/components/AddPaymentGateway.vue b/dashboard/src2/components/AddPaymentGateway.vue new file mode 100644 index 0000000000..6da54c40d1 --- /dev/null +++ b/dashboard/src2/components/AddPaymentGateway.vue @@ -0,0 +1,257 @@ + + + diff --git a/dashboard/src2/components/AddPaymobCredentials.vue b/dashboard/src2/components/AddPaymobCredentials.vue new file mode 100644 index 0000000000..cd7bad131f --- /dev/null +++ b/dashboard/src2/components/AddPaymobCredentials.vue @@ -0,0 +1,154 @@ + + + + \ No newline at end of file diff --git a/dashboard/src2/components/BuyPrepaidCreditMpesa.vue b/dashboard/src2/components/BuyPrepaidCreditMpesa.vue new file mode 100644 index 0000000000..9b4107fb05 --- /dev/null +++ b/dashboard/src2/components/BuyPrepaidCreditMpesa.vue @@ -0,0 +1,270 @@ + + + diff --git a/dashboard/src2/components/BuyPrepaidCreditsForm.vue b/dashboard/src2/components/BuyPrepaidCreditsForm.vue index 5d2882887f..2b153952c8 100644 --- a/dashboard/src2/components/BuyPrepaidCreditsForm.vue +++ b/dashboard/src2/components/BuyPrepaidCreditsForm.vue @@ -11,7 +11,26 @@ > + + + + @@ -31,11 +50,13 @@ {{ $team.doc.currency === 'INR' ? '₹' : '$' }} - + +
Select Payment Gateway
+

+ + + + +
@@ -87,24 +142,70 @@ @success="onSuccess" @cancel="onCancel" /> + + + + + + + + + diff --git a/dashboard/src2/components/ChangePaymentModeDialog.vue b/dashboard/src2/components/ChangePaymentModeDialog.vue index 537e8c5da4..ef26d60406 100644 --- a/dashboard/src2/components/ChangePaymentModeDialog.vue +++ b/dashboard/src2/components/ChangePaymentModeDialog.vue @@ -37,7 +37,7 @@ + + + + + + + + \ No newline at end of file diff --git a/dashboard/src2/components/partners/PartnerLocalPaymentSetUp.vue b/dashboard/src2/components/partners/PartnerLocalPaymentSetUp.vue new file mode 100644 index 0000000000..251616da7a --- /dev/null +++ b/dashboard/src2/components/partners/PartnerLocalPaymentSetUp.vue @@ -0,0 +1,118 @@ + + + + \ No newline at end of file diff --git a/dashboard/src2/pages/Billing.vue b/dashboard/src2/pages/Billing.vue index ad6402d03f..285bf78062 100644 --- a/dashboard/src2/pages/Billing.vue +++ b/dashboard/src2/pages/Billing.vue @@ -42,7 +42,10 @@ export default { { label: 'Marketplace Payouts', route: { name: 'BillingMarketplacePayouts' } - } + }, + { + label: 'Mpesa Invoices', route: { name: 'BillingMpesaInvoices' } + }, ] }; } diff --git a/dashboard/src2/pages/BillingMpesaInvoices.vue b/dashboard/src2/pages/BillingMpesaInvoices.vue new file mode 100644 index 0000000000..44a8ce10e8 --- /dev/null +++ b/dashboard/src2/pages/BillingMpesaInvoices.vue @@ -0,0 +1,75 @@ + + + + \ No newline at end of file diff --git a/dashboard/src2/pages/BillingOverview.vue b/dashboard/src2/pages/BillingOverview.vue index 4112fdd00f..0036ee418b 100644 --- a/dashboard/src2/pages/BillingOverview.vue +++ b/dashboard/src2/pages/BillingOverview.vue @@ -160,7 +160,8 @@ export default { ), StripeCardDialog: defineAsyncComponent(() => import('../components/StripeCardDialog.vue') - ) + ), + }, resources: { upcomingInvoice: { url: 'press.api.billing.upcoming_invoice', auto: true }, @@ -177,7 +178,8 @@ export default { showChangeModeDialog: false, showBillingDetailsDialog: false, showAddCardDialog: false, - showUpcomingInvoiceDialog: false + showUpcomingInvoiceDialog: false, + }; }, mounted() { diff --git a/dashboard/src2/pages/Partners.vue b/dashboard/src2/pages/Partners.vue index a97f308d25..ab54aded7c 100644 --- a/dashboard/src2/pages/Partners.vue +++ b/dashboard/src2/pages/Partners.vue @@ -45,9 +45,11 @@ export default { { label: 'Approval Requests', route: { name: 'PartnerApprovalRequests' } - } + }, + { label: 'Local Payment setUp', route: {name: 'LocalPaymentSetUp'}} ] }; } }; + diff --git a/dashboard/src2/router.js b/dashboard/src2/router.js index 6c426c1c10..35da55bdb3 100644 --- a/dashboard/src2/router.js +++ b/dashboard/src2/router.js @@ -157,6 +157,11 @@ let router = createRouter({ name: 'BillingMarketplacePayouts', path: 'payouts', component: () => import('./pages/BillingMarketplacePayouts.vue') + }, + { + name: 'BillingMpesaInvoices', + path: 'mpesa-invoices', + component: () => import('./pages/BillingMpesaInvoices.vue') } ] }, @@ -226,6 +231,12 @@ let router = createRouter({ path: 'approval-requests', component: () => import('./components/partners/PartnerApprovalRequests.vue') + }, + { + name: 'LocalPaymentSetUp', + path: 'local-payment-setup', + component: ()=> + import('./components/partners/PartnerLocalPaymentSetUp.vue') } ] }, diff --git a/press/api/billing.py b/press/api/billing.py index 929c191888..de551b25b8 100644 --- a/press/api/billing.py +++ b/press/api/billing.py @@ -4,9 +4,14 @@ from itertools import groupby from typing import Dict, List + import frappe from frappe.core.utils import find from frappe.utils import fmt_money +import json + +import frappe +from frappe.query_builder import DocType from press.press.doctype.team.team import ( has_unsettled_invoices, @@ -23,7 +28,26 @@ states_with_tin, validate_gstin_check_digit, ) +from frappe.utils import get_request_site_address +from press.press.doctype.mpesa_settings.mpesa_connector import MpesaConnector +from json import dumps, loads +from press.utils.mpesa_utils import create_request_log +from frappe import _ # Import this for translation functionality +import json +import requests +from press.api.local_payments.mpesa.utils import ( +update_tax_id_or_phone_no, +convert , +get_team_and_partner_from_integration_request, +get_payment_gateway, get_mpesa_settings_for_team, +sanitize_mobile_number, +split_request_amount_according_to_transaction_limit, +fetch_param_value, +) +from press.api.local_payments.mpesa.webhook import create_invoice_partner_site +from press.press.doctype.paymob_callback_log.paymob_callback_log import create_payment_partner_transaction +# other imports and the rest of your code... @frappe.whitelist() def get_publishable_key_and_setup_intent(): @@ -651,3 +675,229 @@ def total_unpaid_amount(): )[0] or 0 ) + negative_balance + + +#Mpesa integrations, mpesa express +'''Send stk push to the user''' +def generate_stk_push(**kwargs): + """Generate stk push by making a API call to the stk push API.""" + args = frappe._dict(kwargs) + partner_value = args.partner + + # Fetch the team document based on the extracted partner value + partner_ = frappe.get_all("Team", filters={"user": partner_value}, pluck="name") + if not partner_: + frappe.throw(_("Partner not found"), title=_("Mpesa Express Error")) + partner = frappe.get_doc("Team", partner_[0]) + + # Get Mpesa settings for the partner's team + mpesa_settings = get_mpesa_settings_for_team(partner.name) + try: + callback_url =( + get_request_site_address(True)+ + "/api/method/press.api.billing.verify_mpsa_transaction" + ) + env = "production" if not mpesa_settings.sandbox else "sandbox" + # for sandbox, business shortcode is same as till number + business_shortcode = ( + mpesa_settings.business_shortcode if env == "production" else mpesa_settings.till_number + ) + connector = MpesaConnector( + env=env, + app_key=mpesa_settings.consumer_key, + app_secret=mpesa_settings.get_password("consumer_secret"), + ) + + mobile_number = sanitize_mobile_number(args.sender) + response = connector.stk_push( + business_shortcode=business_shortcode, + amount= args.amount_with_tax, + passcode=mpesa_settings.get_password("online_passkey"), + callback_url=callback_url, + reference_code=mpesa_settings.till_number, + phone_number=mobile_number, + description="Frappe Cloud Payment", + ) + return response + + except Exception: + frappe.log_error("Mpesa Express Transaction Error") + frappe.throw( + _("Issue detected with Mpesa configuration, check the error logs for more details"), + title=_("Mpesa Express Error"), + ) + +@frappe.whitelist(allow_guest=True) +def verify_mpsa_transaction(**kwargs): + """Verify the transaction result received via callback from STK.""" + transaction_response, integration_request = parse_transaction_response(kwargs) + handle_transaction_result(transaction_response, integration_request) + save_integration_request(integration_request) + print("Response coming ----", transaction_response) + return { + "status": integration_request.status, + "ResultDesc": transaction_response.get("ResultDesc") + } + + +def parse_transaction_response(kwargs): + """Parse and validate the transaction response.""" + + if "Body" not in kwargs or "stkCallback" not in kwargs["Body"]: + frappe.throw(_("Invalid transaction response format")) + transaction_response = frappe._dict(kwargs["Body"]["stkCallback"]) + checkout_id = getattr(transaction_response, "CheckoutRequestID", "") + if not isinstance(checkout_id, str): + frappe.throw(_("Invalid Checkout Request ID")) + + # Retrieve the corresponding Mpesa Request Log document + integration_request = frappe.get_doc("Mpesa Request Log", checkout_id) + + return transaction_response, integration_request + + +def handle_transaction_result(transaction_response, integration_request): + """Handle the logic based on ResultCode in the transaction response.""" + + result_code = transaction_response.get("ResultCode") + + if result_code == 0: + try: + integration_request.handle_success(transaction_response) + integration_request.status = "Completed" + + create_mpesa_payment_record(transaction_response) + except Exception as e: + integration_request.handle_failure(transaction_response) + integration_request.status = "Failed" + frappe.log_error(f"Mpesa: Transaction failed with error {e}") + + + elif result_code == 1037: # User unreachable (Phone off or timeout) + integration_request.handle_failure(transaction_response) + integration_request.status = "Failed" + frappe.log_error("Mpesa: User cannot be reached (Phone off or timeout)") + elif result_code == 1032: # User cancelled the request + integration_request.handle_failure(transaction_response) + integration_request.status = "Cancelled" + frappe.log_error("Mpesa: Request cancelled by user") + else: # Other failure codes + integration_request.handle_failure(transaction_response) + integration_request.status = "Failed" + frappe.log_error(f"Mpesa: Transaction failed with ResultCode {result_code}") + + +def save_integration_request(integration_request): + """Save and commit the changes to the Mpesa Request Log.""" + integration_request.save(ignore_permissions=True) + frappe.db.commit() + +'''get completed Mpesa Request Log''' +def get_completed_integration_requests_info(reference_doctype, reference_docname, checkout_id): + output_of_other_completed_requests = frappe.get_all( + "Mpesa Request Log", + filters={ + "name": ["!=", checkout_id], + "reference_doctype": reference_doctype, + "reference_docname": reference_docname, + "status": "Completed", + }, + pluck="output", + ) + + mpesa_receipts, completed_payments = [], [] + + for out in output_of_other_completed_requests: + out = frappe._dict(loads(out)) + item_response = out["CallbackMetadata"]["Item"] + completed_amount = fetch_param_value(item_response, "Amount", "Name") + completed_mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name") + completed_payments.append(completed_amount) + mpesa_receipts.append(completed_mpesa_receipt) + + return mpesa_receipts, completed_payments + +'''request for payments''' +@frappe.whitelist(allow_guest=True) +def request_for_payment(**kwargs): + team=frappe.get_doc("Team", get_current_team()).user + #TODO get the team and transaction from mpesa setting doctype + kwargs.setdefault("transaction_limit", 150000) + kwargs.setdefault('team', team) + args = frappe._dict(kwargs) + update_tax_id_or_phone_no(args.team, args.tax_id, args.phone_number) + request_amounts = split_request_amount_according_to_transaction_limit(args.request_amount, args.transaction_limit) + for i, amount in enumerate(request_amounts): + args.request_amount = amount + if frappe.flags.in_test: + from press.press.doctype.mpesa_settings.test_mpesa_settings import ( + get_payment_request_response_payload, + ) + + response = frappe._dict(get_payment_request_response_payload(amount)) + else: + response = frappe._dict(generate_stk_push(**args)) + + handle_api_mpesa_response("CheckoutRequestID", args, response) + return response + +def handle_api_mpesa_response(global_id, request_dict, response): + """Response received from API calls returns a global identifier for each transaction, this code is returned during the callback.""" + # check error response + if getattr(response, "requestId"): + req_name = getattr(response, "requestId") + error = response + else: + # global checkout id used as request name + req_name = getattr(response, global_id) + error = None + + if not frappe.db.exists("Mpesa Request Log", req_name): + create_request_log(request_dict, "Host", "Mpesa Express", req_name, error) + + if error: + frappe.throw(_(getattr(response, "errorMessage")), title=_("Transaction Error")) + +def create_mpesa_payment_record(transaction_response): + """Create a new entry in the Mpesa Payment Record for a successful transaction.""" + item_response = transaction_response.get("CallbackMetadata", {}).get("Item", []) + transaction_id = fetch_param_value(item_response, "MpesaReceiptNumber", "Name") + trans_time = fetch_param_value(item_response, "TransactionDate", "Name") + msisdn = fetch_param_value(item_response, "PhoneNumber", "Name") + transaction_id=transaction_response.get("CheckoutRequestID") + amount = fetch_param_value(item_response, "Amount", "Name") + request_id=transaction_response.get("MerchantRequestID") + team, partner, requested_amount = get_team_and_partner_from_integration_request(transaction_id) + amount_usd, exchange_rate=convert("USD", "KES", requested_amount) + gateway_name=get_payment_gateway(partner) + # Create a new entry in M-Pesa Payment Record + data={ + "transaction_id": transaction_id, + "trans_amount": amount, + "team": frappe.get_value("Team", team, "user"), + "default_currency": "KES", + "rate":requested_amount +} + new_entry = frappe.get_doc({ + "doctype": "Mpesa Payment Record", + "transaction_id": transaction_id, + "trans_time": trans_time, + "transaction_type":"Mpesa Express", + "team": team, + "msisdn": str(msisdn), + "trans_amount": requested_amount, + "grand_total":amount, + "merchant_request_id": request_id, + "payment_partner": partner, + "amount_usd": amount_usd, + "exchange_rate": exchange_rate, + "payment_partner":partner, + "local_invoice":create_invoice_partner_site(data, gateway_name), + }) + new_entry.insert(ignore_permissions=True) + new_entry.submit() + '''create payment partner transaction which willl then create balance transaction''' + create_payment_partner_transaction(team,partner, exchange_rate, amount_usd, requested_amount, gateway_name) + + frappe.msgprint(_("Mpesa Payment Record entry created successfully")) + \ No newline at end of file diff --git a/press/api/local_payments/mpesa/mpesa_settings.py b/press/api/local_payments/mpesa/mpesa_settings.py new file mode 100644 index 0000000000..44ef5d69c8 --- /dev/null +++ b/press/api/local_payments/mpesa/mpesa_settings.py @@ -0,0 +1,30 @@ +from press.api.billing import get_current_team +import frappe + +@frappe.whitelist(allow_guest=True) +def create_mpesa_settings(**kwargs): + """Create Mpesa Settings for the team.""" + team = get_current_team() + + try: + mpesa_settings = frappe.get_doc({ + "doctype": "Mpesa Settings", + "team": team, + "payment_gateway_name": kwargs.get("payment_gateway_name"), + "api_type": "Mpesa Express", + "consumer_key": kwargs.get("consumer_key"), + "consumer_secret": kwargs.get("consumer_secret"), + "business_shortcode": kwargs.get("short_code"), + "till_number": kwargs.get("till_number"), + "online_passkey": kwargs.get("pass_key"), + "security_credential": kwargs.get("security_credential"), + "sandbox": 1 if kwargs.get("sandbox") else 0, + }) + + mpesa_settings.insert(ignore_permissions=True) + frappe.db.commit() + + return mpesa_settings.name + except Exception as e: + frappe.log_error(message=f"Error creating Mpesa Settings: {str(e)}", title="M-Pesa Settings Creation Error") + return None diff --git a/press/api/local_payments/mpesa/overrides/mpesa_record.py b/press/api/local_payments/mpesa/overrides/mpesa_record.py new file mode 100644 index 0000000000..2717ba27a0 --- /dev/null +++ b/press/api/local_payments/mpesa/overrides/mpesa_record.py @@ -0,0 +1,22 @@ +import frappe +from press.api.billing import create_balance_transaction +from frappe import _ # Import this for translation functionality + +def after_save_mpesa_payment_record(doc, method=None): + try: + team = doc.team + amount = doc.amount_usd + + balance_transaction_name = create_balance_transaction(team, amount) + + frappe.db.set_value('Mpesa Payment Record', doc.name, 'balance_transaction', balance_transaction_name) + + frappe.db.set_value('Mpesa Payment Record', doc.name, 'docstatus', 1) + doc.reload() + frappe.db.commit() + + frappe.msgprint(_("Mpesa Payment Record has been linked with Balance Transaction and submitted.")) + except Exception as e: + frappe.throw(_("An error occurred: ") + str(e)) + frappe.log_error(message=str(e), title="Mpesa Payment Submission Failed") + \ No newline at end of file diff --git a/press/api/local_payments/mpesa/utils.py b/press/api/local_payments/mpesa/utils.py new file mode 100644 index 0000000000..7fe0f4b4af --- /dev/null +++ b/press/api/local_payments/mpesa/utils.py @@ -0,0 +1,369 @@ +import frappe +from press.utils import get_current_team +from frappe.query_builder import DocType +import json +from frappe import _ + + +supported_mpesa_currencies = ["KES"] + + +@frappe.whitelist(allow_guest=True) +def get_tax_id(): + """Get the tax ID for the team.""" + team=get_current_team() + team_doc = frappe.get_doc("Team", team) + return team_doc.tax_id if team_doc.tax_id else '' + +@frappe.whitelist(allow_guest=True) +def get_phone_no(): + """Get the phone number for the team.""" + team=get_current_team() + team_doc = frappe.get_doc("Team", team) + return team_doc.phone_number if team_doc.phone_number else '' + +@frappe.whitelist(allow_guest=True) +def display_invoices_by_partner(): + """Display the list of invoices by partner.""" + team = get_current_team() + invoices = frappe.get_all("Mpesa Payment Record", filters={"team":team}, fields=["name","posting_date", "trans_amount", "default_currency","local_invoice"]) + return invoices + +@frappe.whitelist(allow_guest=True) +def get_exchange_rate(from_currency, to_currency): + """Get the latest exchange rate for the given currencies.""" + exchange_rate = frappe.db.get_value( + "Currency Exchange", + {"from_currency": from_currency, "to_currency": to_currency}, + "exchange_rate", + order_by="creation DESC" + ) + return exchange_rate + + +@frappe.whitelist(allow_guest=True) +def create_payment_gateway_settings(**kwargs): + """Create Payment Gateway Settings for the team.""" + team = get_current_team() + args = frappe._dict(kwargs) + + try: + + payment_gateway_settings = frappe.get_doc({ + "doctype": "Payment Gateway", + "team": team, + "gateway": args.get("gateway_name"), + "currency": args.get("currency"), + "gateway_settings": args.get("gateway_setting"), + "gateway_controller": args.get("gateway_controller"), + "url": args.get("url"), + "api_key": args.get("api_key"), + "api_secret": args.get("api_secret"), + "taxes_and_charges": args.get("taxes_and_charges"), + }) + + payment_gateway_settings.insert(ignore_permissions=True) + frappe.db.commit() + + return payment_gateway_settings.name + except Exception as e: + frappe.log_error(message=f"Error creating Payment Gateway Settings: {str(e)}", title="Payment Gateway Settings Creation Error") + return None + +@frappe.whitelist(allow_guest=True) +def get_currency_options(): + """Get the list of currencies supported by the system.""" + currencies=frappe.get_all("Currency", fields=["name"]) + names=[currency['name'] for currency in currencies] + return names + +@frappe.whitelist(allow_guest=True) +def get_gateway_settings(): + """Get the list of doctypes supported by the system.""" + doctypes=frappe.get_all("DocType", fields=["name"]) + names=[doc['name'] for doc in doctypes] + return names + +@frappe.whitelist(allow_guest=True) +def get_gateway_controllers(gateway_setting): + """Get the list of controllers for the given doctype.""" + controllers = frappe.get_all(gateway_setting, fields=["name"]) + + names = [doc['name'] for doc in controllers] + + return names + +@frappe.whitelist(allow_guest=True) +def get_tax_percentage(payment_partner): + team_doc = frappe.get_doc("Team", {"user": payment_partner}) + mpesa_settings=frappe.get_all("Mpesa Settings", filters={"api_type":"Mpesa Express", "team":team_doc.name}, fields=["name"]) + for mpesa_setting in mpesa_settings: + payment_gateways = frappe.get_all("Payment Gateway", filters={"gateway_settings":"Mpesa settings","gateway_controller":mpesa_setting }, fields=["taxes_and_charges"]) + if payment_gateways: + taxes_and_charges = payment_gateways[0].taxes_and_charges + return taxes_and_charges + + +def update_tax_id_or_phone_no(team, tax_id, phone_number): + """Update the tax ID or phone number for the team, only if they are different from existing values.""" + + doc_name = frappe.get_value("Team", {"user": team}, "name") + team_doc = frappe.get_doc("Team", doc_name) + + # Check if updates are needed + tax_id_needs_update = tax_id and team_doc.tax_id != tax_id + phone_number_needs_update = phone_number and team_doc.phone_number != phone_number + + # Update only if at least one value needs updating + if tax_id_needs_update or phone_number_needs_update: + if tax_id_needs_update: + team_doc.tax_id = tax_id + if phone_number_needs_update: + team_doc.phone_number = phone_number + team_doc.save() + + +def convert(from_currency, to_currency, amount): + """Convert the given amount from one currency to another.""" + exchange_rate = frappe.get_value("Currency Exchange", {"from_currency": from_currency, "to_currency": to_currency}, "exchange_rate") + converted_amount = amount / exchange_rate + return converted_amount, exchange_rate + +@frappe.whitelist(allow_guest=True) +def display_mpesa_payment_partners(): + """Display the list of partners in the system with Mpesa integration enabled.""" + + Team = DocType("Team") + MpesaSettings = DocType("Mpesa Settings") + + query = ( + frappe.qb.from_(Team) + .join(MpesaSettings) + .on(Team.name == MpesaSettings.team) + .select(Team.user) + .where((Team.country == "Kenya") ) #(MpesaSettings.sandbox == 1) + ) + + mpesa_partners = query.run(as_dict=True) + + return [partner['user'] for partner in mpesa_partners] + +@frappe.whitelist(allow_guest=True) +def display_payment_partners(): + """Display the list of partners in the system.""" + Team = DocType("Team") + query = ( + frappe.qb.from_(Team) + .select(Team.user) + .where(Team.erpnext_partner == 1) + ) + + partners = query.run(as_dict=True) + + return [partner['user'] for partner in partners] + +@frappe.whitelist(allow_guest=True) +def display_payment_gateway(): + """Display the payment gateway for the partner.""" + gateways=frappe.get_all("Payment Gateway", filters={}, fields=["gateway"]) + return [gateway["gateway"] for gateway in gateways] + +def get_team_and_partner_from_integration_request(transaction_id): + """Get the team and partner associated with the Mpesa Request Log.""" + integration_request = frappe.get_doc("Mpesa Request Log", transaction_id) + request_data = integration_request.data + # Parse the request_data as a dictionary + if request_data: + try: + + request_data_dict = json.loads(request_data) + team_ = request_data_dict.get("team") + team = frappe.get_value("Team", {"user": team_}, "name") + partner_ = request_data_dict.get("partner") + partner = frappe.get_value("Team", {"user": partner_}, "name") + requested_amount=request_data_dict.get("request_amount") + except json.JSONDecodeError: + frappe.throw(_("Invalid JSON format in request_data")) + team = None + partner = None + else: + team = None + partner = None + + return team, partner, requested_amount + +def get_payment_gateway(partner_value): + """Get the payment gateway for the partner.""" + partner = frappe.get_doc("Team", partner_value) + # Get Mpesa settings for the partner's team + mpesa_settings = get_mpesa_settings_for_team(partner.name) + payment_gateway = frappe.get_all("Payment Gateway", filters={"gateway_settings":"Mpesa Settings", "gateway_controller":mpesa_settings.name}, pluck="name") + if not payment_gateway: + frappe.throw(_("Payment Gateway not found"), title=_("Mpesa Express Error")) + gateway=frappe.get_doc("Payment Gateway", payment_gateway[0]) + return gateway.name + +def get_mpesa_settings_for_team(team_name): + """Fetch Mpesa settings for a given team.""" + + mpesa_settings = frappe.get_all("Mpesa Settings", filters={"team": team_name}, pluck="name") + if not mpesa_settings: + frappe.throw(_("Mpesa Settings not configured for this team"), title=_("Mpesa Express Error")) + return frappe.get_doc("Mpesa Settings", mpesa_settings[0]) + +'''ensures number take the right format''' +def sanitize_mobile_number(number): + """Add country code and strip leading zeroes from the phone number.""" + return "254" + str(number).lstrip("0") + +def validate_mpesa_transaction_currency(currency): + if currency not in supported_mpesa_currencies: + frappe.throw( + _( + "Please select another payment method. Mpesa does not support transactions in currency '{0}'" + ).format(currency) + ) + +'''split amount if it exceeds 150,000''' +def split_request_amount_according_to_transaction_limit(amount, transaction_limit): + request_amount = amount + if request_amount > transaction_limit: + # make multiple requests + request_amounts = [] + requests_to_be_made = frappe.utils.ceil( + request_amount / transaction_limit + ) # 480/150 = ceil(3.2) = 4 + for i in range(requests_to_be_made): + amount = transaction_limit + if i == requests_to_be_made - 1: + amount = request_amount - ( + transaction_limit * i + ) # for 4th request, 480 - (150 * 3) = 30 + request_amounts.append(amount) + else: + request_amounts = [request_amount] + + return request_amounts + +@frappe.whitelist(allow_guest=True) +def get_exchange_rate(from_currency, to_currency): + """Get the latest exchange rate for the given currencies.""" + exchange_rate = frappe.db.get_value( + "Currency Exchange", + {"from_currency": from_currency, "to_currency": to_currency}, + "exchange_rate", + order_by="creation DESC" + ) + return exchange_rate + + +'''fetch parameters from the args''' +def fetch_param_value(response, key, key_field): + """Fetch the specified key from list of dictionary. Key is identified via the key field.""" + for param in response: + if param[key_field] == key: + return param["Value"] + +@frappe.whitelist(allow_guest=True) +def create_exchange_rate(**kwargs): + """Create a new exchange rate record.""" + try: + from_currency = kwargs.get("from_currency", {}).get("value") + to_currency = kwargs.get("to_currency", {}).get("value") + exchange_rate = kwargs.get("exchange_rate") + date = kwargs.get("date") + + if not from_currency or not to_currency or not exchange_rate: + raise ValueError("Missing required fields.") + + exchange_rate_doc = frappe.get_doc({ + "doctype": "Currency Exchange", + "from_currency": from_currency, + "to_currency": to_currency, + "exchange_rate": exchange_rate, + "date": date, + }) + + exchange_rate_doc.insert(ignore_permissions=True) + return exchange_rate_doc.name + + except Exception as e: + print(f"Error: {e}") + return None + +def create_payment_partner_transaction(team, payment_partner, exchange_rate, amount, paid_amount,payment_gateway, payload=None): + """Create a Payment Partner Transaction record.""" + transaction_doc = frappe.get_doc({ + "doctype": "Payment Partner Transaction", + "team": team, + "payment_partner": payment_partner, + "exchange_rate": exchange_rate, + "payment_gateway": payment_gateway, + "amount": amount, + "actual_amount": paid_amount, + "payment_transaction_details": payload + }) + transaction_doc.insert() + transaction_doc.submit() + return transaction_doc.name + +@frappe.whitelist(allow_guest=True) +def fetch_currencies(): + """Fetch the list of currencies supported by the system.""" + currencies = frappe.get_all("Currency", fields=["name"]) + return [currency['name'] for currency in currencies] + + +@frappe.whitelist(allow_guest=True) +def fetch_payments(payment_gateway, partner, from_date, to_date): + print("fetching payments", payment_gateway) + partner = frappe.get_value("Team", {"user": partner}, "name") + filters = { + 'docstatus': 1, + 'submitted_to_frappe': 0, + 'payment_gateway': payment_gateway, + 'payment_partner':partner + } + + if from_date and to_date: + filters['posting_date'] = ['between', [from_date, to_date]] + + + partner_payments = frappe.get_all( + "Payment Partner Transaction", + filters=filters, + fields=['name', 'amount', 'posting_date'] + ) + frappe.response.message = partner_payments + return partner_payments + + +@frappe.whitelist(allow_guest=True) +def create_payment_partner_payout(from_date, to_date, payment_gateway, payment_partner, payments): + """Create a Payment Partner Payout record.""" + partner_commission = frappe.get_value("Team", {"user": payment_partner}, "partner_commission") + + # Initialize the main document + payout_doc = frappe.get_doc({ + "doctype": "Partner Payment Payout", + "from_date": from_date, + "to_date": to_date, + "payment_gateway": payment_gateway, + "partner": payment_partner, + "partner_commission": partner_commission, + "transfer_items": [] # Initialize an empty child table + }) + + # Add each payment to the child table + for payment in payments: + payout_doc.append("transfer_items", { + "transaction_id": payment.get("name"), + "amount": payment.get("amount"), + "posting_date": payment.get("posting_date"), + }) + # Save and submit the document + payout_doc.insert() + payout_doc.submit() + + return payout_doc.name + diff --git a/press/api/local_payments/mpesa/webhook.py b/press/api/local_payments/mpesa/webhook.py new file mode 100644 index 0000000000..60309d68a5 --- /dev/null +++ b/press/api/local_payments/mpesa/webhook.py @@ -0,0 +1,49 @@ + +import requests +import frappe +from frappe.utils.password import get_decrypted_password + +@frappe.whitelist(allow_guest=True) +def create_invoice_partner_site(data, gateway_controller): + gateway=frappe.get_doc("Payment Gateway", gateway_controller) + api_url_ = gateway.url + api_key = gateway.api_key + api_secret = get_decrypted_password("Payment Gateway", gateway.name, fieldname="api_secret") + + transaction_id = data.get("transaction_id") + trans_amount = data.get("trans_amount") + team = data.get("team") + default_currency = data.get("default_currency") + rate = data.get("rate") + + # Validate the necessary fields + if not transaction_id or not trans_amount: + frappe.throw(_("Invalid transaction data received")) + + api_url = api_url_ + + headers = { + "Authorization": f"token {api_key}:{api_secret}", + } + # Define the payload to send with the POST request + payload = { + "transaction_id": transaction_id, + "trans_amount": trans_amount, + "team": team, + "default_currency": default_currency, + "rate":rate + } + # Make the POST request to your API + try: + response = requests.post(api_url, data=payload, headers=headers) + if response.status_code == 200: + response_data = response.json() + download_link = response_data.get("message", "") + return download_link + else: + frappe.log_error(f"API Error: {response.status_code} - {response.text}") + frappe.throw(_("Failed to create the invoice via API")) + + except requests.exceptions.RequestException as e: + frappe.log_error(f"Error calling API: {e}") + frappe.throw(_("There was an issue connecting to the API.")) diff --git a/press/api/local_payments/paymob/accept_api.py b/press/api/local_payments/paymob/accept_api.py new file mode 100644 index 0000000000..b3480b8c66 --- /dev/null +++ b/press/api/local_payments/paymob/accept_api.py @@ -0,0 +1,85 @@ +from typing import Any, Dict, List, Tuple, Union +import frappe +import requests +import json + +from .paymob_urls import PaymobUrls +from .connection import AcceptConnection + +from .data_classes.response_feedback_dataclass import ResponseFeedBack +from .response_codes import SUCCESS + + +class AcceptAPI: + def __init__(self) -> None: + """Class for Accept APIs + By Initializing an Instance from This class, an auth token is obtained automatically + and You will be able to call The Following APIs: + - Create Payment Intention + - Get Transaction Details + """ + self.connection = AcceptConnection() + self.paymob_settings = frappe.get_doc("Paymob Settings") + self.paymob_urls = PaymobUrls() + + def retrieve_auth_token(self): + """ + Authentication Request: + :return: token: Authentication token, which is valid for one hour from the creation time. + """ + return self.connection.auth_token + + def create_payment_intent( + self, data: Dict + ) -> Tuple[str, Union[Dict, None], ResponseFeedBack]: + """ + Creates a Paymob Payment Intent + :param data: Dictionary containing payment intent details (refer to Paymob documentation) + :return: Tuple[str, Union[Dict, None], ResponseFeedBack]: (Code, Dict, ResponseFeedBack Instance) + + """ + + headers = { + "Authorization": f"Token {self.paymob_settings.get_password('secret_key')}", + "Content-Type": "application/json", + } + payload = json.dumps(data) + code, feedback = self.connection.post( + url=self.paymob_urls.get_url("intention"), headers=headers, data=payload + ) + + payment_intent = frappe._dict() + + if code == SUCCESS: + payment_intent = feedback.data + feedback.message = "Payment Intention Created Successfully" + + return code, payment_intent, feedback + + def retrieve_transaction( + self, transaction_id: int + ) -> Tuple[str, Union[Dict, None], ResponseFeedBack]: + """Retrieves Transaction Data by Transaction ID + + Args: + transaction_id (int): Paymob's Transaction ID + + Returns: + Tuple[str, Union[Dict, None], ResponseFeedBack]: (Code, Dict, ResponseFeedBack Instance) + """ + code, feedback = self.connection.get( + url=self.paymob_urls.get_url("retrieve_transaction", id=transaction_id) + ) + transaction = None + if code == SUCCESS: + transaction = feedback.data + feedback.message = ( + f"Transaction with id {transaction_id} retrieved Scuccessfully" + ) + return code, transaction, feedback + + def retrieve_iframe(self, iframe_id, payment_token): + iframe_url = self.paymob_urls.get_url( + "iframe", iframe_id=self.paymob_settings.iframe, payment_token=payment_token + ) + return iframe_url diff --git a/press/api/local_payments/paymob/billing.py b/press/api/local_payments/paymob/billing.py new file mode 100644 index 0000000000..8d9b9d0dfa --- /dev/null +++ b/press/api/local_payments/paymob/billing.py @@ -0,0 +1,130 @@ +import frappe +from press.utils import get_current_team +from press.api.billing import total_unpaid_amount +from press.api.local_payments.paymob.accept_api import AcceptAPI +from frappe.model.naming import _generate_random_string +from press.utils import log_error + +@frappe.whitelist() +def intent_to_buying_credits(amount, team, actual_amount, exchange_rate, tax_id): + try: + iframe = create_payment_intent_for_buying_credits(amount, team, actual_amount, exchange_rate, tax_id) + return iframe + except Exception as e: + log_error("Intent to Buying Credits using Paymob") + frappe.throw(e) + + +def create_payment_intent_for_buying_credits(amount, team, actual_amount, exchange_rate, tax_id): + payment_partner = team.get("value") + # get current team details + team = get_current_team(True) + total_unpaid = total_unpaid_amount() + + if amount < total_unpaid and not team.erpnext_partner: + frappe.throw(f"Amount {amount} is less than the total unpaid amount {total_unpaid}.") + + update_tax_id(tax_id.strip(), team) + + # build payment_data payload + payment_data = build_payment_data(team, payment_partner, amount=actual_amount, amount_in_usd=amount, exchange_rate=exchange_rate) + validate_billing_data(payment_data) + + # create paymob log + paymob_log = frappe.new_doc("Paymob Log") + paymob_log.event_type = "v1/intention" + paymob_log.payment_partner = payment_partner + paymob_log.team = team.name + paymob_log.amount = amount + paymob_log.actual_amount = actual_amount + paymob_log.exchange_rate = exchange_rate + paymob_log.special_reference = payment_data.get("special_reference") + + # create payment intention + accept = AcceptAPI() + code, intent, feedback = accept.create_payment_intent(payment_data) + + # build iframe url + iframe_url = None + if intent.get("payment_keys"): + intent.pop("client_secret", None) + paymob_log.payload = frappe.as_json(intent) + iframe_url = accept.retrieve_iframe(accept.paymob_settings.iframe, intent["payment_keys"][0]["key"]) + paymob_log.insert(ignore_permissions=True) + return iframe_url + + paymob_log.insert(ignore_permissions=True) + + # return iframe url to UI and rediret to it + return iframe_url + +def build_payment_data(team, payment_partner, amount, amount_in_usd, exchange_rate): + payment_integration_id = frappe.db.get_single_value("Paymob Settings", "payment_integration") + address_details = team.billing_details() + first_name, last_name = frappe.db.get_value("User", team.user, ["first_name", "last_name"]) + payment_data = { + "amount": int(amount * 100), + "currency": "EGP", + "expiration": 5800, + "payment_methods": [ + payment_integration_id, + "card", + ], + "items": [ + { + "name": "Frappe Cloud Prepaid Credit", + "amount": int(amount * 100), + "description": "Frappe Cloud Prepaid Credit", + "quantity": 1 + } + ], + "billing_data": { + "apartment": "6", # TODO make it dynamic + "first_name": first_name, + "last_name": last_name, + "street": address_details.get("address_line1") or address_details.get("address_line2"), + "building": "939", # TODO make it dynamic + "phone_number": address_details.get("phone"), + "country": address_details.get("country"), + "email": address_details.get("email_id"), + "floor": "1", # TODO make it dynamic + "state": address_details.get("state") + }, + "special_reference": _generate_random_string().upper(), + "customer": { + "first_name": first_name, + "last_name": last_name, + "email": address_details.get("email_id"), + "extras": { + "re": "22" + } + }, + "extras": { + "payment_partner": payment_partner, + "payment_partner_user": frappe.db.get_value("Team", payment_partner, "user"), + "team_name": team.name, + "team_user": frappe.db.get_value("Team", team.name, "user"), + "amount_in_usd": amount_in_usd, + "exchange_rate": exchange_rate, + } + } + + return payment_data + + +def validate_billing_data(payment_data: dict): + throw_msg = "" + for key, value in payment_data.get("billing_data").items(): + if not value: + throw_msg += f"Missing billing data {key}
" + if throw_msg: + frappe.throw(throw_msg) + +@frappe.whitelist() +def get_payment_getway(payment_getway): + return frappe.get_doc("Payment Gateway", payment_getway).as_dict() + +def update_tax_id(tax_id, team): + is_tax_id_need_to_update = tax_id and tax_id != team.get("tax_id") + if not team.get("tax_id") or is_tax_id_need_to_update: + frappe.db.set_value("Team", team.name, "tax_id", tax_id) diff --git a/press/api/local_payments/paymob/callbacks.py b/press/api/local_payments/paymob/callbacks.py new file mode 100644 index 0000000000..588789d620 --- /dev/null +++ b/press/api/local_payments/paymob/callbacks.py @@ -0,0 +1,143 @@ +import frappe +from .hmac_validator import HMACValidator +from press.utils import log_error + +@frappe.whitelist(allow_guest=True) +def paymob_callback_handler(): + current_user = frappe.session.user + form_dict = frappe.local.form_dict + query_params = frappe.local.request.args + + try: + payload = frappe.request.get_data(as_text=True) or "" + payload = frappe.parse_json(payload) + is_hmac_valid = HMACValidator(query_params.get("hmac"), payload).is_valid + + if not is_hmac_valid: + raise Exception("Invalid HMAC value") + + # set user to Administrator, to not have to do ignore_permissions everywhere + frappe.set_user("Administrator") + + paymob_order = frappe.db.exists("Paymob Log", {"event_type": "v1/intention", "special_reference": payload.get("obj", {}).get("order", {}).get("merchant_order_id")}) + + if not paymob_order: + log_error( + "Paymob payment record for given order does not exist", + special_reference=payload.get("obj", {}).get("order", {}).get("merchant_order_id"), + ) + return + + frappe.get_doc( + doctype="Paymob Callback Log", + payload=frappe.as_json(payload), + event_type=payload.get("type"), + transaction_id=payload.get("obj", {}).get("id"), + order_id=payload.get("obj", {}).get("payment_key_claims", {}).get("order_id"), + special_reference=payload.get("obj", {}).get("order", {}).get("merchant_order_id"), + success=payload.get("obj", {}).get("success") + ).insert(ignore_if_duplicate=True) + + except Exception as e: + frappe.db.rollback() + log_error( + title="Paymob Callback Handler", + transaction_id=form_dict.get("obj", {}).get("id"), + order_id=form_dict.get("obj", {}).get("order"), + special_reference=form_dict.get("obj", {}).get("order", {}).get("merchant_order_id") + ) + frappe.set_user(current_user) + raise + + +@frappe.whitelist(allow_guest=True) +def paymob_response_callback(**kwargs): + """ + Handle Paymob payment response callback, validate HMAC, and redirect with appropriate message. + """ + try: + query_params = get_query_params() + transaction_status = query_params.get("success") + order_id = query_params.get("order", {}).get("id") + transaction_id = query_params.get("id") + + if transaction_status is None or order_id is None or transaction_id is None: + frappe.log_error("Paymob Missing transaction parameters", f"Paymob Response Callback Error\n {query_params}") + return frappe.redirect_to_message( + title="Payment Error", + html="Required transaction parameters are missing.", + indicator_color="red" + ) + + # Validate HMAC to ensure data integrity + is_hmac_valid = validate_hmac(query_params) + if not is_hmac_valid: + frappe.log_error("Paymob HMAC validation failed", f"Paymob Response Callback Error\n {query_params}") + return frappe.redirect_to_message( + title="Payment Error", + html="Invalid transaction signature. Possible tampering detected.", + indicator_color="red" + ) + + # Get the response message based on the transaction status and HMAC validation + response_message = get_response_message(transaction_status, is_hmac_valid, order_id, transaction_id) + + except Exception as e: + # Log the error and redirect to a generic error message + frappe.log_error(f"Paymob Unexpected error: {str(e)}", "Paymob Response Callback Error") + response_message = { + "title": "Payment Processing Error", + "html": "An unexpected error occurred while processing your payment. Please try again.", + "indicator_color": "red" + } + + return frappe.redirect_to_message(**response_message) + + + +def get_query_params(): + """ + Retrieve and parse query parameters from the request. + """ + query_params = frappe.local.request.args + query_params = frappe.parse_json(query_params) + + # Update nested data structure for order and source_data, to validated HMAC + query_params.update({ + "order": {"id": query_params.get("order")}, + "source_data": { + "pan": query_params.get("source_data.pan"), + "type": query_params.get("source_data.type"), + "sub_type": query_params.get("source_data.sub_type"), + }, + }) + return query_params + + +def validate_hmac(query_params): + """ + Validate the HMAC for the given query parameters. + """ + call_back_dict = frappe._dict({ + "type": "TRANSACTION", + "obj": frappe.parse_json(query_params), + }) + hmac_value = query_params.get("hmac") + return HMACValidator(hmac_value, call_back_dict).is_valid + + +def get_response_message(transaction_status, is_hmac_valid, order_id, transaction_id): + """ + Generate a response message based on the payment status and HMAC validation. + """ + if transaction_status == "true" and is_hmac_valid: + title = "Thank you" + message = f"Payment was successful! Your Order ID is {order_id} and Transaction ID is {transaction_id}." + indicator_color = "green" + else: + title = "Payment failed" + message = f"Payment failed. Please try again or contact support with Order ID {order_id}." + indicator_color = "red" + + return {"title": title, "html": message, "indicator_color": indicator_color} + diff --git a/press/api/local_payments/paymob/connection.py b/press/api/local_payments/paymob/connection.py new file mode 100644 index 0000000000..a88f27dc26 --- /dev/null +++ b/press/api/local_payments/paymob/connection.py @@ -0,0 +1,138 @@ +from typing import Any, Dict, Tuple, Union + +import requests +from requests import HTTPError, JSONDecodeError, RequestException + +from .data_classes.response_feedback_dataclass import ResponseFeedBack +from .response_codes import ( + HTTP_EXCEPTION, + HTTP_EXCEPTION_MESSAGE, + JSON_DECODE_EXCEPTION, + JSON_DECODE_EXCEPTION_MESSAGE, + REQUEST_EXCEPTION, + REQUEST_EXCEPTION_MESSAGE, + SUCCESS, + SUCCESS_MESSAGE, + UNHANDLED_EXCEPTION, + UNHANDLED_EXCEPTION_MESSAGE, +) +from frappe.utils.password import get_decrypted_password +from .paymob_urls import PaymobUrls + + + +class AcceptConnection: + def __init__(self) -> None: + """Initializing the Following: + 1- Requests Session + 2- Auth Token + 3- Set Headers + 4- Paymob Urls + """ + self.session = requests.Session() + self.paymob_urls = PaymobUrls() + self.auth_token = self._get_auth_token() + self.session.headers.update(self._get_headers()) + + def _get_headers(self) -> Dict[str, Any]: + """Initialize Header for Requests + + Returns: + Dict[str, Any]: Initialized Header Dict + """ + return { + "Content-Type": "application/json", + "Authorization": f"{self.auth_token}", + } + + def _get_auth_token(self) -> Union[str, None]: + """Retrieve an Auth Token + + Returns: + Union[str, None]: Auth Token + """ + api_key = get_decrypted_password("Paymob Settings", "Paymob Settings", "api_key") + request_body = {"api_key": api_key} + + code, feedback = self.post( + url=self.paymob_urls.get_url("auth"), + json=request_body, + ) + + token = None + if code == SUCCESS: + token = feedback.data.get("token") + return token + + def _process_request(self, call, *args, **kwargs) -> Tuple[str, Dict[str, Any], ResponseFeedBack]: + """Process the Request + + Args: + call (Session.get/Session.post): Session.get/Session.post + *args, **kwargs: Same Args of requests.post/requests.get methods + + Returns: + Tuple[str, Dict[str, Any], ResponseFeedBack]: Tuple containes the Following (Code, Data, Success/Error Message) + """ + + reponse_data = None + try: + response = call(timeout=90, *args, **kwargs) + reponse_data = response.json() + response.raise_for_status() + except JSONDecodeError as error: + reponse_feedback = ResponseFeedBack( + message=JSON_DECODE_EXCEPTION_MESSAGE, + status_code=response.status_code, + exception_error=error, + ) + return JSON_DECODE_EXCEPTION, reponse_feedback + except HTTPError as error: + reponse_feedback = ResponseFeedBack( + message=HTTP_EXCEPTION_MESSAGE, + data=reponse_data, + status_code=response.status_code, + exception_error=error, + ) + return HTTP_EXCEPTION, reponse_feedback + except RequestException as error: + reponse_feedback = ResponseFeedBack( + message=REQUEST_EXCEPTION_MESSAGE, + exception_error=error, + ) + return REQUEST_EXCEPTION, reponse_feedback + except Exception as error: + reponse_feedback = ResponseFeedBack( + message=UNHANDLED_EXCEPTION_MESSAGE, + exception_error=error, + ) + return UNHANDLED_EXCEPTION, reponse_feedback + + reponse_feedback = ResponseFeedBack( + message=SUCCESS_MESSAGE, + data=reponse_data, + status_code=response.status_code, + ) + return SUCCESS, reponse_feedback + + def get(self, *args, **kwargs) -> Tuple[str, Dict[str, Any], ResponseFeedBack]: + """Wrapper for requests.get method + + Args: + Same Args of requests.post/requests.get methods + + Returns: + Tuple[str, Dict[str, Any], ResponseFeedBack]: Tuple containes the Following (Code, Data, Success/Error Message) + """ + return self._process_request(call=self.session.get, *args, **kwargs) + + def post(self, *args, **kwargs) -> Tuple[str, Dict[str, Any], ResponseFeedBack]: + """Wrapper for requests.get method + + Args: + Same Args of requests.post/requests.get methods + + Returns: + Tuple[str, Dict[str, Any], ResponseFeedBack]: Tuple containes the Following (Code, Data, Success/Error Message) + """ + return self._process_request(call=self.session.post, *args, **kwargs) diff --git a/press/api/local_payments/paymob/constants.py b/press/api/local_payments/paymob/constants.py new file mode 100644 index 0000000000..73ff8d2fc2 --- /dev/null +++ b/press/api/local_payments/paymob/constants.py @@ -0,0 +1,4 @@ +class AcceptCallbackTypes: + TRANSACTION = "TRANSACTION" + CARD_TOKEN = "TOKEN" + DELIVERY_STATUS = "DELIVERY_STATUS" diff --git a/press/api/local_payments/paymob/data_classes/response_feedback_dataclass.py b/press/api/local_payments/paymob/data_classes/response_feedback_dataclass.py new file mode 100644 index 0000000000..7b486fa30a --- /dev/null +++ b/press/api/local_payments/paymob/data_classes/response_feedback_dataclass.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from typing import Any, Optional + + +@dataclass +class ResponseFeedBack: + + message: Optional[str] + data: Any = None + status_code: int = None + exception_error: str = None diff --git a/press/api/local_payments/paymob/hmac_validator.py b/press/api/local_payments/paymob/hmac_validator.py new file mode 100644 index 0000000000..dea676ce1e --- /dev/null +++ b/press/api/local_payments/paymob/hmac_validator.py @@ -0,0 +1,166 @@ +import hashlib +import hmac +from typing import Any, Dict +from .constants import AcceptCallbackTypes +import frappe + + +class HMACValidator: + def __init__(self, incoming_hmac: str, callback_dict: Dict[str, Any], **kwargs) -> None: + """Initialize HMAC Attributes + + Args: + incoming_hmac (str): Incoming Paymob's HMAC + callback_dict Dict[str, Any]: Incoming Callback Dict + """ + self.incoming_hmac = incoming_hmac + self.callback_dict = callback_dict + if isinstance(self.callback_dict, dict): + self.callback_obj_dict = self.callback_dict.get("obj") + + super().__init__(**kwargs) + + @staticmethod + def _calculate_hmac(message: str) -> str: + """Calculates HMAC + + Args: + message (str): GeneratedHMAC Message + + Returns: + str: Calculated HMAC + """ + hmac_secret = frappe.get_doc("Paymob Settings").get_password("hmac").encode("utf-8") + return ( + hmac.new( + hmac_secret, + message.encode("utf-8"), + hashlib.sha512, + ) + .hexdigest() + .lower() + ) + + @classmethod + def _generate_processed_hmac(cls, hmac_dict: Dict[str, Any]) -> str: + """Creates HMAC from sent self.callback_obj_dict + + Args: + hmac_dict (Dict[str, Any]): Hmac Dict + + Returns: + str: Generated HMAC + """ + if not isinstance(hmac_dict, dict): + return "" + + message = "" + for value in hmac_dict.values(): + if isinstance(value, bool): + value = str(value).lower() + if value is None: + value = "" + message += str(value) + + return cls._calculate_hmac(message=message) + + def _generate_transaction_processed_hmac(self) -> str: + """Creates HMAC from sent transaction callback self.callback_obj_dict + + Returns: + str: Generated HMAC + """ + if not isinstance(self.callback_obj_dict, dict): + return "" + + hmac_dict = { + "amount_cents": self.callback_obj_dict.get("amount_cents"), + "created_at": self.callback_obj_dict.get("created_at"), + "currency": self.callback_obj_dict.get("currency"), + "error_occured": self.callback_obj_dict.get("error_occured"), + "has_parent_transaction": self.callback_obj_dict.get("has_parent_transaction"), + "id": self.callback_obj_dict.get("id"), + "integration_id": self.callback_obj_dict.get("integration_id"), + "is_3d_secure": self.callback_obj_dict.get("is_3d_secure"), + "is_auth": self.callback_obj_dict.get("is_auth"), + "is_capture": self.callback_obj_dict.get("is_capture"), + "is_refunded": self.callback_obj_dict.get("is_refunded"), + "is_standalone_payment": self.callback_obj_dict.get("is_standalone_payment"), + "is_voided": self.callback_obj_dict.get("is_voided"), + "order.id": self.callback_obj_dict.get("order", {}).get("id"), + "owner": self.callback_obj_dict.get("owner"), + "pending": self.callback_obj_dict.get("pending"), + "source_data.pan": self.callback_obj_dict.get("source_data", {}).get("pan"), + "source_data.sub_type": self.callback_obj_dict.get("source_data", {}).get("sub_type"), + "source_data.type": self.callback_obj_dict.get("source_data", {}).get("type"), + "success": self.callback_obj_dict.get("success"), + } + + return self._generate_processed_hmac(hmac_dict=hmac_dict) + + def _generate_card_token_processed_hmac(self) -> str: + """Creates HMAC from sent card token callback body_dic + + Returns: + str: Generated HMAC + """ + if not isinstance(self.callback_obj_dict, dict): + return "" + + hmac_dict = { + "card_subtype": self.callback_obj_dict.get("card_subtype"), + "created_at": self.callback_obj_dict.get("created_at"), + "email": self.callback_obj_dict.get("email"), + "id": self.callback_obj_dict.get("id"), + "masked_pan": self.callback_obj_dict.get("masked_pan"), + "merchant_id": self.callback_obj_dict.get("merchant_id"), + "order_id": self.callback_obj_dict.get("order_id"), + "token": self.callback_obj_dict.get("token"), + } + + return self._generate_processed_hmac(hmac_dict=hmac_dict) + + def _generate_delivery_status_processed_hmac(self) -> str: + """Creates HMAC from sent Delivery Status callback body_dic + + Returns: + str: Generated HMAC + """ + if not isinstance(self.callback_obj_dict, dict): + return "" + + hmac_dict = { + "order_id": self.callback_obj_dict.get("order_id"), + "order_delivery_status": self.callback_obj_dict.get("order_delivery_status"), + "merchant_id": self.callback_obj_dict.get("merchant_id"), + "merchant_name": self.callback_obj_dict.get("merchant_name"), + "updated_at": self.callback_obj_dict.get("updated_at"), + } + + return self._generate_processed_hmac(hmac_dict=hmac_dict) + + # Public Method that can be used Directly to Validate HMAC + @property + def is_valid(self) -> bool: + """Validates HMAC for processed callback + + Returns: + bool: True if HMAC is Valid, False otherwise + """ + if not isinstance(self.callback_dict, dict): + return False + + callback_type = self.callback_dict.get("type") + if callback_type == AcceptCallbackTypes.TRANSACTION: + calculated_hmac = self._generate_transaction_processed_hmac() + elif callback_type == AcceptCallbackTypes.CARD_TOKEN: + calculated_hmac = self._generate_card_token_processed_hmac() + elif callback_type == AcceptCallbackTypes.DELIVERY_STATUS: + calculated_hmac = self._generate_delivery_status_processed_hmac() + else: + return False + + if calculated_hmac != self.incoming_hmac: + return False + + return True diff --git a/press/api/local_payments/paymob/paymob_urls.py b/press/api/local_payments/paymob/paymob_urls.py new file mode 100644 index 0000000000..dfcb62a733 --- /dev/null +++ b/press/api/local_payments/paymob/paymob_urls.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +import frappe + +@dataclass +class PaymobUrls: + base_url: str = "https://accept.paymob.com/" + + # Auth + auth: str = "api/auth/tokens" + + # Ecommerce + order: str = "api/ecommerce/orders" + inquire_transaction: str = "api/ecommerce/orders/transaction_inquiry" + tracking: str = "api/ecommerce/orders/{order_id}/delivery_status?token={token}" + preparing_package: str = "api/ecommerce/orders/{order_id}/airway_bill?token={token}" + + # Acceptance + payment_key: str = "api/acceptance/payment_keys" + payment: str = "api/acceptance/payments/pay" + capture: str = "api/acceptance/capture" + refund: str = "api/acceptance/void_refund/refund" + void: str = "api/acceptance/void_refund/void?token={token}" + retrieve_transaction: str = "api/acceptance/transactions/{id}" + retrieve_transactions: str = "api/acceptance/portal-transactions?page={from_page}&page_size={page_size}&token={token}" + loyalty_checkout: str = "api/acceptance/loyalty_checkout" + iframe: str = "api/acceptance/iframes/{iframe_id}?payment_token={payment_token}" + intention: str = "v1/intention/" + + def get_url(self, endpoint, **kwargs): + # based on available attributes and passed keyword arguments + return f"{self.base_url}{getattr(self, endpoint)}".format(**kwargs) + + +# Example usage +# paymob_urls = PaymobUrls() +# order_registration_url = paymob_urls.get_url("order") +# void_transaction_url = paymob_urls.get_url("void", token="your_token") +# tracking_url = paymob_urls.get_url("tracking", order_id="123", token="your_token") diff --git a/press/api/local_payments/paymob/response_codes.py b/press/api/local_payments/paymob/response_codes.py new file mode 100644 index 0000000000..43f90e084c --- /dev/null +++ b/press/api/local_payments/paymob/response_codes.py @@ -0,0 +1,16 @@ +# Response Codes +SUCCESS = 10 + +# Request Related Error Codes +JSON_DECODE_EXCEPTION = 20 +REQUEST_EXCEPTION = 21 +HTTP_EXCEPTION = 22 +UNHANDLED_EXCEPTION = 23 + + +# Error Messages Templates +JSON_DECODE_EXCEPTION_MESSAGE = "An Error Occurred While Parsing the Response into JSON" +REQUEST_EXCEPTION_MESSAGE = "An Error Occurred During the Request" +HTTP_EXCEPTION_MESSAGE = "Non 2xx Status Code Returned." +UNHANDLED_EXCEPTION_MESSAGE = "Unhandled Exception" +SUCCESS_MESSAGE = "API Successfully Called." diff --git a/press/api/local_payments/paymob/utils.py b/press/api/local_payments/paymob/utils.py new file mode 100644 index 0000000000..d8a602c024 --- /dev/null +++ b/press/api/local_payments/paymob/utils.py @@ -0,0 +1,12 @@ +import frappe + +@frappe.whitelist(allow_guest=True) +def get_exchange_rate(from_currency, to_currency): + """Get the latest exchange rate for the given currencies.""" + exchange_rate = frappe.db.get_value( + "Currency Exchange", + {"from_currency": from_currency, "to_currency": to_currency}, + "exchange_rate", + order_by="date DESC" + ) + return exchange_rate or 0.0 \ No newline at end of file diff --git a/press/auth.py b/press/auth.py index eb602f97b9..f5c64a922e 100644 --- a/press/auth.py +++ b/press/auth.py @@ -48,6 +48,8 @@ "/api/method/frappe.core.doctype.user.user.test_password_strength", "/api/method/frappe.core.doctype.user.user.update_password", "/api/method/get_central_migration_data", + "/api/method/press.api.local_payments.paymob.callbacks.paymob_callback_handler", + "/api/method/press.api.local_payments.paymob.callbacks.paymob_response_callback", ] ALLOWED_WILDCARD_PATHS = [ diff --git a/press/hooks.py b/press/hooks.py index 11fab4eb9e..1781201466 100644 --- a/press/hooks.py +++ b/press/hooks.py @@ -168,6 +168,9 @@ "Marketplace App Subscription": { "on_update": "press.press.doctype.storage_integration_subscription.storage_integration_subscription.create_after_insert", }, +# "Mpesa Payment Record": { +# "before_submit":"press.api.local_payments.mpesa.overrides.mpesa_record.after_save_mpesa_payment_record" +# }, } # Scheduled Tasks diff --git a/press/press/doctype/currency_exchange/__init__.py b/press/press/doctype/currency_exchange/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/press/press/doctype/currency_exchange/currency_exchange.js b/press/press/doctype/currency_exchange/currency_exchange.js new file mode 100644 index 0000000000..1e749e9a6c --- /dev/null +++ b/press/press/doctype/currency_exchange/currency_exchange.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Currency Exchange", { +// refresh(frm) { + +// }, +// }); diff --git a/press/press/doctype/currency_exchange/currency_exchange.json b/press/press/doctype/currency_exchange/currency_exchange.json new file mode 100644 index 0000000000..778c339480 --- /dev/null +++ b/press/press/doctype/currency_exchange/currency_exchange.json @@ -0,0 +1,76 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:{date}-{from_currency}-{to_currency}", + "creation": "2024-09-17 15:41:44.281942", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "date", + "exchange_rate", + "column_break_iqor", + "from_currency", + "to_currency" + ], + "fields": [ + { + "default": "Today", + "fieldname": "date", + "fieldtype": "Date", + "label": "Date", + "reqd": 1 + }, + { + "fieldname": "exchange_rate", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Exchange Rate", + "reqd": 1 + }, + { + "fieldname": "column_break_iqor", + "fieldtype": "Column Break" + }, + { + "fieldname": "from_currency", + "fieldtype": "Link", + "in_list_view": 1, + "label": "From Currency", + "options": "Currency", + "reqd": 1 + }, + { + "fieldname": "to_currency", + "fieldtype": "Link", + "in_list_view": 1, + "label": "To Currency", + "options": "Currency", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-09-24 16:06:10.415345", + "modified_by": "Administrator", + "module": "Press", + "name": "Currency Exchange", + "naming_rule": "Expression", + "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": [] +} \ No newline at end of file diff --git a/press/press/doctype/currency_exchange/currency_exchange.py b/press/press/doctype/currency_exchange/currency_exchange.py new file mode 100644 index 0000000000..557f58e846 --- /dev/null +++ b/press/press/doctype/currency_exchange/currency_exchange.py @@ -0,0 +1,22 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CurrencyExchange(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + date: DF.Date + exchange_rate: DF.Float + from_currency: DF.Link + to_currency: DF.Link + # end: auto-generated types + pass diff --git a/press/press/doctype/currency_exchange/test_currency_exchange.py b/press/press/doctype/currency_exchange/test_currency_exchange.py new file mode 100644 index 0000000000..461286a897 --- /dev/null +++ b/press/press/doctype/currency_exchange/test_currency_exchange.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestCurrencyExchange(FrappeTestCase): + pass diff --git a/press/press/doctype/invoice/invoice.py b/press/press/doctype/invoice/invoice.py index 36046e5a2b..341ff30310 100644 --- a/press/press/doctype/invoice/invoice.py +++ b/press/press/doctype/invoice/invoice.py @@ -12,7 +12,7 @@ from press.api.client import dashboard_whitelist from press.overrides import get_permission_query_conditions_for_doctype from press.utils import log_error -from press.utils.billing import convert_stripe_money, get_frappe_io_connection +from press.utils.billing import convert_stripe_money, get_frappe_io_connection, get_partner_external_connection class Invoice(Document): @@ -1052,3 +1052,79 @@ def finalize_draft_invoice(invoice): def calculate_gst(amount): return amount * 0.18 + +#M-pesa external site for webhook +def create_sales_invoice_on_external_site(transaction_response): + client = get_partner_external_connection() + try: + # Define the necessary data for the Sales Invoice creation + data = { + "customer": transaction_response.get("team"), + "posting_date": frappe.utils.nowdate(), + "due_date": frappe.utils.add_days(frappe.utils.nowdate(), 30), + "items": [ + { + "item_code": "Frappe Cloud Payment", + "qty": 1, + "rate": transaction_response.get("Amount"), + "description": "Payment for Mpesa transaction", + } + ], + "paid_amount": transaction_response.get("Amount"), + "status": "Paid", + } + + # Post to the external site's sales invoice creation API + response = client.session.post( + f"{client.url}/api/method/frappe.client.insert", + headers=client.headers, + json={"doc": data}, + ) + + if response.ok: + res = response.json() + sales_invoice = res.get("message") + if sales_invoice: + frappe.msgprint(_("Sales Invoice created successfully on external site.")) + return sales_invoice + else: + frappe.throw(_("Failed to create Sales Invoice on external site.")) + except Exception as e: + frappe.log_error(str(e), "Error creating Sales Invoice on external site") + + +def fetch_sales_invoice_pdf_on_external_site(sales_invoice_name): + client = get_partner_external_connection() + try: + print_format="Default" + from urllib.parse import urlencode + params= urlencode( + { + "doctype": "Sales Invoice", + "name": sales_invoice_name, + "format": print_format, + "no_letterhead": 0, + } + ) + url=f"{client.url}/api/method/frappe.utils.print_format.download_pdf?{params}" + + with client.session.get(url, headers=client.headers, stream=True) as r: + r.raise_for_status() + file_doc=frappe.get_doc( + { + "doctype": "File", + "attached_to_doctype": "Sales Invoice", + "attached_to_name": sales_invoice_name, + "attached_to_field": "invoice_pdf", + "folder": "Home/Attachments", + "file_name": sales_invoice_name + ".pdf", + "is_private": 1, + "content": r.content, + } + ) + file_doc.save(ignore_permissions=True) + return file_doc.file_url + + except Exception as e: + frappe.log_error(str(e), "Error fetching Sales Invoice PDF on external site") + \ No newline at end of file diff --git a/press/press/doctype/mpesa_payment_record/__init__.py b/press/press/doctype/mpesa_payment_record/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/press/press/doctype/mpesa_payment_record/mpesa_payment_record.js b/press/press/doctype/mpesa_payment_record/mpesa_payment_record.js new file mode 100644 index 0000000000..d556a34a3f --- /dev/null +++ b/press/press/doctype/mpesa_payment_record/mpesa_payment_record.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Mpesa Payment Record", { +// refresh(frm) { + +// }, +// }); diff --git a/press/press/doctype/mpesa_payment_record/mpesa_payment_record.json b/press/press/doctype/mpesa_payment_record/mpesa_payment_record.json new file mode 100644 index 0000000000..b4a78dc247 --- /dev/null +++ b/press/press/doctype/mpesa_payment_record/mpesa_payment_record.json @@ -0,0 +1,242 @@ +{ + "actions": [], + "allow_copy": 1, + "allow_import": 1, + "autoname": "MPC2B.-.YY.-.MM.-.######", + "creation": "2024-04-11 13:39:25.887687", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "team", + "merchant_request_id", + "trans_id", + "transaction_type", + "trans_time", + "grand_total", + "trans_amount", + "amount_usd", + "bill_ref_number", + "exchange_rate", + "column_break_14", + "msisdn", + "payment_partner", + "invoice_number", + "posting_date", + "posting_time", + "default_currency", + "amended_from", + "balance_transaction", + "local_invoice" + ], + "fields": [ + { + "fieldname": "msisdn", + "fieldtype": "Data", + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, + "label": "MSISDN", + "no_copy": 1, + "options": "Phone", + "read_only": 1 + }, + { + "fieldname": "column_break_14", + "fieldtype": "Column Break" + }, + { + "default": "Today", + "fieldname": "posting_date", + "fieldtype": "Date", + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, + "label": "Posting Date", + "no_copy": 1, + "read_only": 1 + }, + { + "default": "Now", + "fieldname": "posting_time", + "fieldtype": "Time", + "label": "Posting Time", + "no_copy": 1, + "read_only": 1 + }, + { + "default": "KES", + "fetch_from": "company.default_currency", + "fieldname": "default_currency", + "fieldtype": "Data", + "label": "Default Currency", + "read_only": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Mpesa Payment Record", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "trans_id", + "fieldtype": "Data", + "label": "Trans ID", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "trans_time", + "fieldtype": "Data", + "label": "Trans Time", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "trans_amount", + "fieldtype": "Float", + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, + "label": "Trans Amount(Ksh)", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "bill_ref_number", + "fieldtype": "Data", + "label": "Bill Ref Number", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "invoice_number", + "fieldtype": "Data", + "label": "Invoice Number", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "transaction_type", + "fieldtype": "Select", + "label": "Transaction Type", + "options": "\nMpesa Express\nMpesa C2B" + }, + { + "fieldname": "merchant_request_id", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Request ID", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "exchange_rate", + "fieldtype": "Float", + "label": "Exchange Rate", + "precision": "9" + }, + { + "fieldname": "balance_transaction", + "fieldtype": "Link", + "label": "Balance Transaction", + "options": "Balance Transaction" + }, + { + "fieldname": "amount_usd", + "fieldtype": "Float", + "label": "Amount(USD)" + }, + { + "fieldname": "team", + "fieldtype": "Link", + "label": "Team", + "options": "Team" + }, + { + "fieldname": "payment_partner", + "fieldtype": "Link", + "label": "Payment Partner", + "options": "Team" + }, + { + "fieldname": "local_invoice", + "fieldtype": "Small Text", + "label": "Local Invoice" + }, + { + "fieldname": "grand_total", + "fieldtype": "Currency", + "label": "Grand Total(Ksh)" + } + ], + "in_create": 1, + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2024-11-22 11:39:42.044987", + "modified_by": "Administrator", + "module": "Press", + "name": "Mpesa Payment Record", + "naming_rule": "Expression (old style)", + "owner": "Administrator", + "permissions": [ + { + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "amend": 1, + "cancel": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "show_preview_popup": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "trans_id", + "track_changes": 1 +} \ No newline at end of file diff --git a/press/press/doctype/mpesa_payment_record/mpesa_payment_record.py b/press/press/doctype/mpesa_payment_record/mpesa_payment_record.py new file mode 100644 index 0000000000..da700638f2 --- /dev/null +++ b/press/press/doctype/mpesa_payment_record/mpesa_payment_record.py @@ -0,0 +1,37 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class MpesaPaymentRecord(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + amended_from: DF.Link | None + amount_usd: DF.Float + balance_transaction: DF.Link | None + bill_ref_number: DF.Data | None + default_currency: DF.Data | None + exchange_rate: DF.Float + grand_total: DF.Currency + invoice_number: DF.Data | None + local_invoice: DF.SmallText | None + merchant_request_id: DF.Data | None + msisdn: DF.Data | None + payment_partner: DF.Link | None + posting_date: DF.Date | None + posting_time: DF.Time | None + team: DF.Link | None + trans_amount: DF.Float + trans_id: DF.Data | None + trans_time: DF.Data | None + transaction_type: DF.Literal["", "Mpesa Express", "Mpesa C2B"] + # end: auto-generated types + pass diff --git a/press/press/doctype/mpesa_payment_record/test_mpesa_payment_record.py b/press/press/doctype/mpesa_payment_record/test_mpesa_payment_record.py new file mode 100644 index 0000000000..3b81a74b30 --- /dev/null +++ b/press/press/doctype/mpesa_payment_record/test_mpesa_payment_record.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestMpesaPaymentRecord(FrappeTestCase): + pass diff --git a/press/press/doctype/mpesa_request_log/__init__.py b/press/press/doctype/mpesa_request_log/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/press/press/doctype/mpesa_request_log/mpesa_request_log.js b/press/press/doctype/mpesa_request_log/mpesa_request_log.js new file mode 100644 index 0000000000..fd0f93b0bc --- /dev/null +++ b/press/press/doctype/mpesa_request_log/mpesa_request_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Mpesa Request Log", { +// refresh(frm) { + +// }, +// }); diff --git a/press/press/doctype/mpesa_request_log/mpesa_request_log.json b/press/press/doctype/mpesa_request_log/mpesa_request_log.json new file mode 100644 index 0000000000..b39c923c9b --- /dev/null +++ b/press/press/doctype/mpesa_request_log/mpesa_request_log.json @@ -0,0 +1,154 @@ +{ + "actions": [], + "creation": "2024-10-24 17:13:46.920886", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "request_id", + "integration_request_service", + "is_remote_request", + "column_break_5", + "request_description", + "status", + "section_break_8", + "url", + "request_headers", + "data", + "response_section", + "output", + "error", + "reference_section", + "reference_doctype", + "column_break_16", + "reference_docname" + ], + "fields": [ + { + "fieldname": "request_id", + "fieldtype": "Data", + "label": "Request ID", + "read_only": 1 + }, + { + "fieldname": "integration_request_service", + "fieldtype": "Data", + "label": "Service", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_remote_request", + "fieldtype": "Check", + "label": "Is Remote Request?", + "read_only": 1 + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "fieldname": "request_description", + "fieldtype": "Data", + "label": "Request Description", + "read_only": 1 + }, + { + "default": "Queued", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "\nQueued\nAuthorized\nCompleted\nCancelled\nFailed", + "read_only": 1 + }, + { + "fieldname": "section_break_8", + "fieldtype": "Section Break" + }, + { + "fieldname": "url", + "fieldtype": "Small Text", + "label": "URL", + "read_only": 1 + }, + { + "fieldname": "request_headers", + "fieldtype": "Code", + "label": "Request Headers", + "read_only": 1 + }, + { + "fieldname": "data", + "fieldtype": "Code", + "label": "Request Data", + "read_only": 1 + }, + { + "fieldname": "response_section", + "fieldtype": "Section Break", + "label": "Response" + }, + { + "fieldname": "output", + "fieldtype": "Code", + "label": "Output", + "read_only": 1 + }, + { + "fieldname": "error", + "fieldtype": "Code", + "label": "Error", + "read_only": 1 + }, + { + "depends_on": "eval:doc.reference_doctype", + "fieldname": "reference_section", + "fieldtype": "Section Break", + "label": "Reference" + }, + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "label": "Reference Document Type", + "options": "DocType", + "read_only": 1 + }, + { + "fieldname": "column_break_16", + "fieldtype": "Column Break" + }, + { + "fieldname": "reference_docname", + "fieldtype": "Dynamic Link", + "label": "Reference Document Name", + "options": "reference_doctype", + "read_only": 1 + } + ], + "in_create": 1, + "links": [], + "modified": "2024-10-24 17:13:46.920886", + "modified_by": "Administrator", + "module": "Press", + "name": "Mpesa Request Log", + "owner": "Administrator", + "permissions": [ + { + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "integration_request_service", + "track_changes": 1 +} \ No newline at end of file diff --git a/press/press/doctype/mpesa_request_log/mpesa_request_log.py b/press/press/doctype/mpesa_request_log/mpesa_request_log.py new file mode 100644 index 0000000000..8611f1e0d8 --- /dev/null +++ b/press/press/doctype/mpesa_request_log/mpesa_request_log.py @@ -0,0 +1,68 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +import json +import frappe +from frappe.integrations.utils import json_handler + +class MpesaRequestLog(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + data: DF.Code | None + error: DF.Code | None + integration_request_service: DF.Data | None + is_remote_request: DF.Check + output: DF.Code | None + reference_docname: DF.DynamicLink | None + reference_doctype: DF.Link | None + request_description: DF.Data | None + request_headers: DF.Code | None + request_id: DF.Data | None + status: DF.Literal["", "Queued", "Authorized", "Completed", "Cancelled", "Failed"] + url: DF.SmallText | None + # end: auto-generated types + + def autoname(self): + if self.flags._name: + self.name = self.flags._name + + def clear_old_logs(days=30): + from frappe.query_builder import Interval + from frappe.query_builder.functions import Now + + table = frappe.qb.DocType("Mpesa Request Log") + frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days)))) + + def update_status(self, params, status): + data = json.loads(self.data) + data.update(params) + + self.data = json.dumps(data) + self.status = status + self.save(ignore_permissions=True) + frappe.db.commit() + + def handle_success(self, response): + """update the output field with the response along with the relevant status""" + if isinstance(response, str): + response = json.loads(response) + self.db_set("status", "Completed") + self.db_set("output", json.dumps(response, default=json_handler)) + + + def handle_failure(self, response): + """update the error field with the response along with the relevant status""" + if isinstance(response, str): + response = json.loads(response) + self.db_set("status", "Failed") + self.db_set("error", json.dumps(response, default=json_handler)) + diff --git a/press/press/doctype/mpesa_request_log/test_mpesa_request_log.py b/press/press/doctype/mpesa_request_log/test_mpesa_request_log.py new file mode 100644 index 0000000000..4c74fe558b --- /dev/null +++ b/press/press/doctype/mpesa_request_log/test_mpesa_request_log.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestMpesaRequestLog(FrappeTestCase): + pass diff --git a/press/press/doctype/mpesa_settings/__init__.py b/press/press/doctype/mpesa_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/press/press/doctype/mpesa_settings/account_balance.html b/press/press/doctype/mpesa_settings/account_balance.html new file mode 100644 index 0000000000..6614cab89a --- /dev/null +++ b/press/press/doctype/mpesa_settings/account_balance.html @@ -0,0 +1,27 @@ +{% if not jQuery.isEmptyObject(data) %} +
{{ __("Balance Details") }}
+ + + + + + + + + + + + {% for(const [key, value] of Object.entries(data)) { %} + + + + + + + + {% } %} + +
{{ __("Account Type") }}{{ __("Current Balance") }}{{ __("Available Balance") }}{{ __("Reserved Balance") }}{{ __("Uncleared Balance") }}
{%= key %} {%= value["current_balance"] %} {%= value["available_balance"] %} {%= value["reserved_balance"] %} {%= value["uncleared_balance"] %}
+{% else %} +

Account Balance Information Not Available.

+{% endif %} diff --git a/press/press/doctype/mpesa_settings/mpesa_connector.py b/press/press/doctype/mpesa_settings/mpesa_connector.py new file mode 100644 index 0000000000..7eb8b9c0d8 --- /dev/null +++ b/press/press/doctype/mpesa_settings/mpesa_connector.py @@ -0,0 +1,149 @@ +import base64 +import datetime + +import requests +from requests.auth import HTTPBasicAuth + + +class MpesaConnector: + def __init__( + self, + env="sandbox", + app_key=None, + app_secret=None, + sandbox_url="https://sandbox.safaricom.co.ke", + live_url="https://api.safaricom.co.ke", + ): + """Setup configuration for Mpesa connector and generate new access token.""" + self.env = env + self.app_key = app_key + self.app_secret = app_secret + if env == "sandbox": + self.base_url = sandbox_url + else: + self.base_url = live_url + self.authenticate() + + def authenticate(self): + """ + This method is used to fetch the access token required by Mpesa. + + Returns: + access_token (str): This token is to be used with the Bearer header for further API calls to Mpesa. + """ + authenticate_uri = "/oauth/v1/generate?grant_type=client_credentials" + authenticate_url = f"{self.base_url}{authenticate_uri}" + r = requests.get(authenticate_url, auth=HTTPBasicAuth(self.app_key, self.app_secret)) + self.authentication_token = r.json()["access_token"] + return r.json()["access_token"] + + def get_balance( + self, + initiator=None, + security_credential=None, + party_a=None, + identifier_type=None, + remarks=None, + queue_timeout_url=None, + result_url=None, + ): + """ + This method uses Mpesa's Account Balance API to to enquire the balance on a M-Pesa BuyGoods (Till Number). + + Args: + initiator (str): Username used to authenticate the transaction. + security_credential (str): Generate from developer portal. + command_id (str): AccountBalance. + party_a (int): Till number being queried. + identifier_type (int): Type of organization receiving the transaction. (MSISDN/Till Number/Organization short code) + remarks (str): Comments that are sent along with the transaction(maximum 100 characters). + queue_timeout_url (str): The url that handles information of timed out transactions. + result_url (str): The url that receives results from M-Pesa api call. + + Returns: + OriginatorConverstionID (str): The unique request ID for tracking a transaction. + ConversationID (str): The unique request ID returned by mpesa for each request made + ResponseDescription (str): Response Description message + """ + + payload = { + "Initiator": initiator, + "SecurityCredential": security_credential, + "CommandID": "AccountBalance", + "PartyA": party_a, + "IdentifierType": identifier_type, + "Remarks": remarks, + "QueueTimeOutURL": queue_timeout_url, + "ResultURL": result_url, + } + headers = { + "Authorization": f"Bearer {self.authentication_token}", + "Content-Type": "application/json", + } + saf_url = "{}{}".format(self.base_url, "/mpesa/accountbalance/v1/query") + r = requests.post(saf_url, headers=headers, json=payload) + return r.json() + + def stk_push( + self, + business_shortcode=None, + passcode=None, + amount=None, + callback_url=None, + reference_code=None, + phone_number=None, + description=None, + ): + """ + This method uses Mpesa's Express API to initiate online payment on behalf of a customer. + + Args: + business_shortcode (int): The short code of the organization. + passcode (str): Get from developer portal + amount (int): The amount being transacted + callback_url (str): A CallBack URL is a valid secure URL that is used to receive notifications from M-Pesa API. + reference_code(str): Account Reference: This is an Alpha-Numeric parameter that is defined by your system as an Identifier of the transaction for CustomerPayBillOnline transaction type. + phone_number(int): The Mobile Number to receive the STK Pin Prompt. + description(str): This is any additional information/comment that can be sent along with the request from your system. MAX 13 characters + + Success Response: + CustomerMessage(str): Messages that customers can understand. + CheckoutRequestID(str): This is a global unique identifier of the processed checkout transaction request. + ResponseDescription(str): Describes Success or failure + MerchantRequestID(str): This is a global unique Identifier for any submitted payment request. + ResponseCode(int): 0 means success all others are error codes. e.g.404.001.03 + + Error Reponse: + requestId(str): This is a unique requestID for the payment request + errorCode(str): This is a predefined code that indicates the reason for request failure. + errorMessage(str): This is a predefined code that indicates the reason for request failure. + """ + + time = ( + str(datetime.datetime.now()).split(".")[0].replace("-", "").replace(" ", "").replace(":", "") + ) + password = f"{str(business_shortcode)}{str(passcode)}{time}" + encoded = base64.b64encode(bytes(password, encoding="utf8")) + payload = { + "BusinessShortCode": business_shortcode, + "Password": encoded.decode("utf-8"), + "Timestamp": time, + "Amount": amount, + "PartyA": int(phone_number), + "PartyB": reference_code, + "PhoneNumber": int(phone_number), + "CallBackURL": callback_url, + "AccountReference": reference_code, + "TransactionDesc": description, + "TransactionType": "CustomerPayBillOnline" + if self.env == "sandbox" + else "CustomerBuyGoodsOnline", + } + headers = { + "Authorization": f"Bearer {self.authentication_token}", + "Content-Type": "application/json", + } + + saf_url = "{}{}".format(self.base_url, "/mpesa/stkpush/v1/processrequest") + r = requests.post(saf_url, headers=headers, json=payload) + return r.json() diff --git a/press/press/doctype/mpesa_settings/mpesa_settings.js b/press/press/doctype/mpesa_settings/mpesa_settings.js new file mode 100644 index 0000000000..9d625736b1 --- /dev/null +++ b/press/press/doctype/mpesa_settings/mpesa_settings.js @@ -0,0 +1,36 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Mpesa Settings', { + onload_post_render: function(frm) { + frm.events.setup_account_balance_html(frm); + }, + + refresh: function(frm) { + frappe.realtime.on("refresh_mpesa_dashboard", function(){ + frm.reload_doc(); + frm.events.setup_account_balance_html(frm); + }); + }, + + get_account_balance: function(frm) { + if (!frm.doc.initiator_name && !frm.doc.security_credential) { + frappe.throw(__("Please set the initiator name and the security credential")); + } + frappe.call({ + method: "get_account_balance_info", + doc: frm.doc + }); + }, + + setup_account_balance_html: function(frm) { + if (!frm.doc.account_balance) return; + $("div").remove(".form-dashboard-section.custom"); + frm.dashboard.add_section( + frappe.render_template('account_balance', { + data: JSON.parse(frm.doc.account_balance) + }) + ); + frm.dashboard.show(); + } +}); diff --git a/press/press/doctype/mpesa_settings/mpesa_settings.json b/press/press/doctype/mpesa_settings/mpesa_settings.json new file mode 100644 index 0000000000..251e4029c3 --- /dev/null +++ b/press/press/doctype/mpesa_settings/mpesa_settings.json @@ -0,0 +1,156 @@ +{ + "actions": [], + "autoname": "format:{payment_gateway_name}-{api_type}", + "creation": "2024-07-04 09:03:02.080734", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "payment_gateway_name", + "team", + "api_type", + "consumer_key", + "consumer_secret", + "initiator_name", + "till_number", + "sandbox", + "column_break_4", + "business_shortcode", + "online_passkey", + "transaction_limit", + "security_credential" + ], + "fields": [ + { + "fieldname": "payment_gateway_name", + "fieldtype": "Data", + "label": "Payment Gateway Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "consumer_key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Consumer Key", + "reqd": 1 + }, + { + "fieldname": "consumer_secret", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Consumer Secret", + "reqd": 1 + }, + { + "fieldname": "initiator_name", + "fieldtype": "Data", + "label": "Initiator Name" + }, + { + "fieldname": "till_number", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Till Number", + "reqd": 1 + }, + { + "default": "150000", + "fieldname": "transaction_limit", + "fieldtype": "Float", + "label": "Transaction Limit", + "non_negative": 1 + }, + { + "default": "0", + "fieldname": "sandbox", + "fieldtype": "Check", + "label": "Sandbox" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:(doc.sandbox==0)", + "fieldname": "business_shortcode", + "fieldtype": "Data", + "label": "Business Shortcode", + "mandatory_depends_on": "eval:(doc.sandbox==0)" + }, + { + "fieldname": "online_passkey", + "fieldtype": "Password", + "label": " Online PassKey", + "reqd": 1 + }, + { + "fieldname": "security_credential", + "fieldtype": "Small Text", + "label": "Security Credential" + }, + { + "fieldname": "team", + "fieldtype": "Link", + "label": "Team", + "options": "Team", + "reqd": 1 + }, + { + "fieldname": "api_type", + "fieldtype": "Select", + "label": "API Type", + "options": "\nMpesa Express\nMpesa C2B", + "reqd": 1 + } + ], + "links": [], + "modified": "2024-11-08 17:28:31.432927", + "modified_by": "Administrator", + "module": "Press", + "name": "Mpesa Settings", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/press/press/doctype/mpesa_settings/mpesa_settings.py b/press/press/doctype/mpesa_settings/mpesa_settings.py new file mode 100644 index 0000000000..88703f3095 --- /dev/null +++ b/press/press/doctype/mpesa_settings/mpesa_settings.py @@ -0,0 +1,35 @@ +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + + +from json import dumps, loads + +import frappe +from frappe import _ +from frappe.model.document import Document + +class MpesaSettings(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + api_type: DF.Literal["", "Mpesa Express", "Mpesa C2B"] + business_shortcode: DF.Data | None + consumer_key: DF.Data + consumer_secret: DF.Password + initiator_name: DF.Data | None + online_passkey: DF.Password + payment_gateway_name: DF.Data + sandbox: DF.Check + security_credential: DF.SmallText | None + team: DF.Link + till_number: DF.Data + transaction_limit: DF.Float + # end: auto-generated types + supported_currencies = ["KES"] + + \ No newline at end of file diff --git a/press/press/doctype/mpesa_settings/test_mpesa_settings.py b/press/press/doctype/mpesa_settings/test_mpesa_settings.py new file mode 100644 index 0000000000..51f072e658 --- /dev/null +++ b/press/press/doctype/mpesa_settings/test_mpesa_settings.py @@ -0,0 +1,190 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import unittest +from json import dumps + +import frappe + +from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_customer +from erpnext.stock.doctype.item.test_item import make_item + +from press.press.doctype.mpesa_settings.mpesa_settings import ( + process_balance_info, + verify_transaction, +) + + +class TestMpesaSettings(unittest.TestCase): + def setUp(self): + # create payment gateway in setup + create_mpesa_settings(payment_gateway_name="_Test") + create_mpesa_settings(payment_gateway_name="_Account Balance") + create_mpesa_settings(payment_gateway_name="Payment") + + self.customer = create_customer("_Test Customer", "USD") + self.item = make_item(properties={"is_stock_item": 1}).name + + + def tearDown(self): + frappe.db.sql("delete from `tabMpesa Settings`") + frappe.db.sql("delete from `tabIntegration Request` where integration_request_service = 'Mpesa'") + + def test_processing_of_account_balance(self): + mpesa_doc = create_mpesa_settings(payment_gateway_name="_Account Balance") + mpesa_doc.get_account_balance_info() + + callback_response = get_account_balance_callback_payload() + process_balance_info(**callback_response) + integration_request = frappe.get_doc("Integration Request", "AG_20200927_00007cdb1f9fb6494315") + + # test integration request creation and successful update of the status on receiving callback response + self.assertTrue(integration_request) + self.assertEqual(integration_request.status, "Completed") + + # test formatting of account balance received as string to json with appropriate currency symbol + mpesa_doc.reload() + self.assertEqual( + mpesa_doc.account_balance, + dumps( + { + "Working Account": { + "current_balance": "Sh 481,000.00", + "available_balance": "Sh 481,000.00", + "reserved_balance": "Sh 0.00", + "uncleared_balance": "Sh 0.00", + } + } + ), + ) + + integration_request.delete() + + def test_processing_of_callback_payload(self): + mpesa_account = frappe.db.get_value( + "Payment Gateway Account", {"payment_gateway": "Mpesa-Payment"}, "payment_account" + ) + frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "KES") + + +def create_mpesa_settings(payment_gateway_name="Express"): + if frappe.db.exists("Mpesa Settings", payment_gateway_name): + return frappe.get_doc("Mpesa Settings", payment_gateway_name) + + doc = frappe.get_doc( + dict( # nosec + doctype="Mpesa Settings", + sandbox=1, + payment_gateway_name=payment_gateway_name, + consumer_key="5sMu9LVI1oS3oBGPJfh3JyvLHwZOdTKn", + consumer_secret="VI1oS3oBGPJfh3JyvLHw", + online_passkey="LVI1oS3oBGPJfh3JyvLHwZOd", + till_number="174379", + ) + ) + + doc.insert(ignore_permissions=True) + return doc + + +def get_test_account_balance_response(): + """Response received after calling the account balance API.""" + return { + "ResultType": 0, + "ResultCode": 0, + "ResultDesc": "The service request has been accepted successfully.", + "OriginatorConversationID": "10816-694520-2", + "ConversationID": "AG_20200927_00007cdb1f9fb6494315", + "TransactionID": "LGR0000000", + "ResultParameters": { + "ResultParameter": [ + {"Key": "ReceiptNo", "Value": "LGR919G2AV"}, + {"Key": "Conversation ID", "Value": "AG_20170727_00004492b1b6d0078fbe"}, + {"Key": "FinalisedTime", "Value": 20170727101415}, + {"Key": "Amount", "Value": 10}, + {"Key": "TransactionStatus", "Value": "Completed"}, + {"Key": "ReasonType", "Value": "Salary Payment via API"}, + {"Key": "TransactionReason"}, + {"Key": "DebitPartyCharges", "Value": "Fee For B2C Payment|KES|33.00"}, + {"Key": "DebitAccountType", "Value": "Utility Account"}, + {"Key": "InitiatedTime", "Value": 20170727101415}, + {"Key": "Originator Conversation ID", "Value": "19455-773836-1"}, + {"Key": "CreditPartyName", "Value": "254708374149 - John Doe"}, + {"Key": "DebitPartyName", "Value": "600134 - Safaricom157"}, + ] + }, + "ReferenceData": {"ReferenceItem": {"Key": "Occasion", "Value": "aaaa"}}, + } + + +def get_payment_request_response_payload(Amount=500): + """Response received after successfully calling the stk push process request API.""" + + CheckoutRequestID = frappe.utils.random_string(10) + + return { + "MerchantRequestID": "8071-27184008-1", + "CheckoutRequestID": CheckoutRequestID, + "ResultCode": 0, + "ResultDesc": "The service request is processed successfully.", + "CallbackMetadata": { + "Item": [ + {"Name": "Amount", "Value": Amount}, + {"Name": "MpesaReceiptNumber", "Value": "LGR7OWQX0R"}, + {"Name": "TransactionDate", "Value": 20201006113336}, + {"Name": "PhoneNumber", "Value": 254723575670}, + ] + }, + } + + +def get_payment_callback_payload( + Amount=500, CheckoutRequestID="ws_CO_061020201133231972", MpesaReceiptNumber="LGR7OWQX0R" +): + """Response received from the server as callback after calling the stkpush process request API.""" + return { + "Body": { + "stkCallback": { + "MerchantRequestID": "19465-780693-1", + "CheckoutRequestID": CheckoutRequestID, + "ResultCode": 0, + "ResultDesc": "The service request is processed successfully.", + "CallbackMetadata": { + "Item": [ + {"Name": "Amount", "Value": Amount}, + {"Name": "MpesaReceiptNumber", "Value": MpesaReceiptNumber}, + {"Name": "Balance"}, + {"Name": "TransactionDate", "Value": 20170727154800}, + {"Name": "PhoneNumber", "Value": 254721566839}, + ] + }, + } + } + } + + +def get_account_balance_callback_payload(): + """Response received from the server as callback after calling the account balance API.""" + return { + "Result": { + "ResultType": 0, + "ResultCode": 0, + "ResultDesc": "The service request is processed successfully.", + "OriginatorConversationID": "16470-170099139-1", + "ConversationID": "AG_20200927_00007cdb1f9fb6494315", + "TransactionID": "OIR0000000", + "ResultParameters": { + "ResultParameter": [ + {"Key": "AccountBalance", "Value": "Working Account|KES|481000.00|481000.00|0.00|0.00"}, + {"Key": "BOCompletedTime", "Value": 20200927234123}, + ] + }, + "ReferenceData": { + "ReferenceItem": { + "Key": "QueueTimeoutURL", + "Value": "https://internalsandbox.safaricom.co.ke/mpesa/abresults/v1/submit", + } + }, + } + } diff --git a/press/press/doctype/partner_payment_payout/__init__.py b/press/press/doctype/partner_payment_payout/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/press/press/doctype/partner_payment_payout/partner_payment_payout.js b/press/press/doctype/partner_payment_payout/partner_payment_payout.js new file mode 100644 index 0000000000..86b5a2867f --- /dev/null +++ b/press/press/doctype/partner_payment_payout/partner_payment_payout.js @@ -0,0 +1,38 @@ +// Copyright (c) 2024, Frappe and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Partner Payment Payout", { + refresh(frm) { + if(frm.doc.docstatus == 0) { + frm.add_custom_button("Fetch Payments", () => { + frappe.call({ + method: "press.api.local_payments.mpesa.utils.fetch_payments", + args: { + // transaction_doctype: frm.doc.transaction_doctype, + from_date: frm.doc.from_date, + to_date: frm.doc.to_date, + partner: frm.doc.partner, + payment_gateway: frm.doc.payment_gateway, + }, + callback: function(response) { + if (response.message) { + // Clear existing entries in transfer_items + frm.clear_table("transfer_items"); + + response.message.forEach(payment => { + let row = frm.add_child("transfer_items"); + row.transaction_id = payment.name; + row.posting_date=payment.posting_date + row.amount = payment.amount; + + }); + + frm.refresh_field("transfer_items"); + // frappe.msgprint("Payments fetched and added to the transfer items table."); + } + } + }); + }); + }}, +}); + diff --git a/press/press/doctype/partner_payment_payout/partner_payment_payout.json b/press/press/doctype/partner_payment_payout/partner_payment_payout.json new file mode 100644 index 0000000000..3fdb489322 --- /dev/null +++ b/press/press/doctype/partner_payment_payout/partner_payment_payout.json @@ -0,0 +1,148 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:PPT-{MM}-{#####}", + "creation": "2024-11-08 13:07:10.321274", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "section_break_l0lu", + "amended_from", + "from_date", + "partner", + "payment_gateway", + "column_break_plbi", + "to_date", + "partner_commission", + "section_break_tvae", + "transfer_items", + "section_break_qxag", + "total_amount", + "column_break_lgdh", + "commission", + "column_break_jfqp", + "net_amount" + ], + "fields": [ + { + "fieldname": "section_break_l0lu", + "fieldtype": "Section Break", + "label": "Filters" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Partner Payment Payout", + "print_hide": 1, + "read_only": 1, + "search_index": 1 + }, + { + "default": "Today", + "fieldname": "from_date", + "fieldtype": "Date", + "label": "From Date" + }, + { + "fieldname": "column_break_plbi", + "fieldtype": "Column Break" + }, + { + "default": "Today", + "fieldname": "to_date", + "fieldtype": "Date", + "label": "To Date" + }, + { + "fieldname": "section_break_tvae", + "fieldtype": "Section Break" + }, + { + "fieldname": "section_break_qxag", + "fieldtype": "Section Break" + }, + { + "fieldname": "total_amount", + "fieldtype": "Currency", + "label": "Total Amount", + "non_negative": 1 + }, + { + "fieldname": "transfer_items", + "fieldtype": "Table", + "label": "Partner Payment Transfer Item", + "options": "Partner Payment Payout Item", + "reqd": 1 + }, + { + "fieldname": "column_break_lgdh", + "fieldtype": "Column Break" + }, + { + "fieldname": "commission", + "fieldtype": "Currency", + "label": "Commission" + }, + { + "fieldname": "column_break_jfqp", + "fieldtype": "Column Break" + }, + { + "fieldname": "net_amount", + "fieldtype": "Currency", + "label": "Net Amount" + }, + { + "fieldname": "partner", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Partner", + "options": "Team", + "reqd": 1 + }, + { + "fetch_from": "partner.partner_commission", + "fieldname": "partner_commission", + "fieldtype": "Percent", + "label": "Partner Commission", + "read_only": 1 + }, + { + "fieldname": "payment_gateway", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Payment Gateway", + "options": "Payment Gateway", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2024-11-22 11:22:22.557825", + "modified_by": "Administrator", + "module": "Press", + "name": "Partner Payment Payout", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/press/press/doctype/partner_payment_payout/partner_payment_payout.py b/press/press/doctype/partner_payment_payout/partner_payment_payout.py new file mode 100644 index 0000000000..0543a2621c --- /dev/null +++ b/press/press/doctype/partner_payment_payout/partner_payment_payout.py @@ -0,0 +1,66 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + + +class PartnerPaymentPayout(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + from press.press.doctype.partner_payment_payout_item.partner_payment_payout_item import PartnerPaymentPayoutItem + + amended_from: DF.Link | None + commission: DF.Currency + from_date: DF.Date | None + net_amount: DF.Currency + partner: DF.Link + partner_commission: DF.Percent + payment_gateway: DF.Link + to_date: DF.Date | None + total_amount: DF.Currency + transfer_items: DF.Table[PartnerPaymentPayoutItem] + # end: auto-generated types + pass + + def before_save(self): + self.total_amount = sum([item.amount for item in self.transfer_items]) + self.commission = self.total_amount * (self.partner_commission / 100) + self.net_amount = self.total_amount - self.commission + for item in self.transfer_items: + item.commission_amount = item.amount * (self.partner_commission / 100) + item.net_amount = item.amount - item.commission_amount + + def on_submit(self): + transaction_names = [item.transaction_id for item in self.transfer_items] + + if transaction_names: + frappe.db.set_value( + "Payment Partner Transaction", + {"name": ["in", transaction_names], "submitted_to_frappe": 0}, + "submitted_to_frappe", + 1 + ) + frappe.db.commit() + + def on_cancel(self): + transaction_names = [item.transaction_id for item in self.transfer_items] + + # Update Payment Partner Records + if transaction_names: + frappe.db.set_value( + "Payment Partner Transaction", + {"name": ["in", transaction_names], "submitted_to_frappe": 1}, + "submitted_to_frappe", + 0 + ) + frappe.db.commit() + + + + diff --git a/press/press/doctype/partner_payment_payout/test_partner_payment_payout.py b/press/press/doctype/partner_payment_payout/test_partner_payment_payout.py new file mode 100644 index 0000000000..2cf5103421 --- /dev/null +++ b/press/press/doctype/partner_payment_payout/test_partner_payment_payout.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestPartnerPaymentPayout(FrappeTestCase): + pass diff --git a/press/press/doctype/partner_payment_payout_item/__init__.py b/press/press/doctype/partner_payment_payout_item/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/press/press/doctype/partner_payment_payout_item/partner_payment_payout_item.json b/press/press/doctype/partner_payment_payout_item/partner_payment_payout_item.json new file mode 100644 index 0000000000..f1fe4eb53e --- /dev/null +++ b/press/press/doctype/partner_payment_payout_item/partner_payment_payout_item.json @@ -0,0 +1,81 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-11-22 11:16:54.513751", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "transaction_id", + "posting_date", + "amount", + "column_break_ayfd", + "commission_amount", + "amount_in_local_currency", + "net_amount" + ], + "fields": [ + { + "fieldname": "transaction_id", + "fieldtype": "Link", + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, + "label": "Transaction Id", + "options": "Payment Partner Transaction", + "reqd": 1 + }, + { + "fieldname": "posting_date", + "fieldtype": "Date", + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, + "label": "Posting Date" + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, + "label": "Amount(USD)" + }, + { + "fieldname": "column_break_ayfd", + "fieldtype": "Column Break" + }, + { + "fieldname": "commission_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Commission Amount" + }, + { + "fieldname": "amount_in_local_currency", + "fieldtype": "Currency", + "label": "Amount(LC)" + }, + { + "fieldname": "net_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Net Amount" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2024-11-22 11:17:55.345884", + "modified_by": "Administrator", + "module": "Press", + "name": "Partner Payment Payout Item", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/press/press/doctype/partner_payment_payout_item/partner_payment_payout_item.py b/press/press/doctype/partner_payment_payout_item/partner_payment_payout_item.py new file mode 100644 index 0000000000..02eb0766af --- /dev/null +++ b/press/press/doctype/partner_payment_payout_item/partner_payment_payout_item.py @@ -0,0 +1,27 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class PartnerPaymentPayoutItem(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + amount: DF.Currency + amount_in_local_currency: DF.Currency + commission_amount: DF.Currency + net_amount: DF.Currency + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + posting_date: DF.Date | None + transaction_id: DF.Link + # end: auto-generated types + pass diff --git a/press/press/doctype/payment_gateway/__init__.py b/press/press/doctype/payment_gateway/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/press/press/doctype/payment_gateway/payment_gateway.js b/press/press/doctype/payment_gateway/payment_gateway.js new file mode 100644 index 0000000000..a8edaa0051 --- /dev/null +++ b/press/press/doctype/payment_gateway/payment_gateway.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Payment Gateway", { +// refresh(frm) { + +// }, +// }); diff --git a/press/press/doctype/payment_gateway/payment_gateway.json b/press/press/doctype/payment_gateway/payment_gateway.json new file mode 100644 index 0000000000..f86d67adef --- /dev/null +++ b/press/press/doctype/payment_gateway/payment_gateway.json @@ -0,0 +1,159 @@ +{ + "actions": [], + "autoname": "field:gateway", + "creation": "2024-09-13 13:25:09.836216", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "team", + "column_break_kvnc", + "team_name", + "column_break_pwgv", + "currency", + "gateway", + "ui_configuration_section", + "integration_logo", + "column_break_jcag", + "gateway_settings", + "column_break_noki", + "gateway_controller", + "partner_integration_section", + "url", + "column_break_oefu", + "api_key", + "column_break_slwm", + "api_secret", + "taxes_section", + "taxes_and_charges" + ], + "fields": [ + { + "fieldname": "gateway", + "fieldtype": "Data", + "label": "Gateway", + "unique": 1 + }, + { + "fieldname": "column_break_kvnc", + "fieldtype": "Column Break" + }, + { + "fieldname": "team", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Team", + "options": "Team" + }, + { + "fieldname": "column_break_pwgv", + "fieldtype": "Column Break" + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "taxes_section", + "fieldtype": "Section Break", + "label": "Taxes" + }, + { + "fieldname": "taxes_and_charges", + "fieldtype": "Percent", + "label": "Taxes and Charges" + }, + { + "fieldname": "partner_integration_section", + "fieldtype": "Section Break", + "label": "Partner Integration" + }, + { + "fieldname": "url", + "fieldtype": "Data", + "label": "URL" + }, + { + "fieldname": "api_key", + "fieldtype": "Data", + "label": "API Key" + }, + { + "fieldname": "api_secret", + "fieldtype": "Password", + "label": "API Secret" + }, + { + "fieldname": "column_break_oefu", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_slwm", + "fieldtype": "Column Break" + }, + { + "fieldname": "ui_configuration_section", + "fieldtype": "Section Break", + "label": "UI Configuration" + }, + { + "fieldname": "integration_logo", + "fieldtype": "Attach Image", + "label": "Integration Logo" + }, + { + "fieldname": "column_break_jcag", + "fieldtype": "Column Break" + }, + { + "fieldname": "gateway_settings", + "fieldtype": "Link", + "label": "Gateway Settings", + "options": "DocType" + }, + { + "fieldname": "column_break_noki", + "fieldtype": "Column Break" + }, + { + "fieldname": "gateway_controller", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Gateway Controller", + "options": "gateway_settings" + }, + { + "fetch_from": "team.team_title", + "fieldname": "team_name", + "fieldtype": "Data", + "label": "Team Name", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-10-13 12:57:10.317435", + "modified_by": "Administrator", + "module": "Press", + "name": "Payment Gateway", + "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": [] +} \ No newline at end of file diff --git a/press/press/doctype/payment_gateway/payment_gateway.py b/press/press/doctype/payment_gateway/payment_gateway.py new file mode 100644 index 0000000000..5310211f88 --- /dev/null +++ b/press/press/doctype/payment_gateway/payment_gateway.py @@ -0,0 +1,29 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class PaymentGateway(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + api_key: DF.Data | None + api_secret: DF.Password | None + currency: DF.Link | None + gateway: DF.Data | None + gateway_controller: DF.DynamicLink | None + gateway_settings: DF.Link | None + integration_logo: DF.AttachImage | None + taxes_and_charges: DF.Percent + team: DF.Link | None + team_name: DF.Data | None + url: DF.Data | None + # end: auto-generated types + pass diff --git a/press/press/doctype/payment_gateway/test_payment_gateway.py b/press/press/doctype/payment_gateway/test_payment_gateway.py new file mode 100644 index 0000000000..9e3f993f27 --- /dev/null +++ b/press/press/doctype/payment_gateway/test_payment_gateway.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestPaymentGateway(FrappeTestCase): + pass diff --git a/press/press/doctype/payment_partner_transaction/__init__.py b/press/press/doctype/payment_partner_transaction/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/press/press/doctype/payment_partner_transaction/payment_partner_transaction.js b/press/press/doctype/payment_partner_transaction/payment_partner_transaction.js new file mode 100644 index 0000000000..a7642633a6 --- /dev/null +++ b/press/press/doctype/payment_partner_transaction/payment_partner_transaction.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Payment Partner Transaction", { +// refresh(frm) { + +// }, +// }); diff --git a/press/press/doctype/payment_partner_transaction/payment_partner_transaction.json b/press/press/doctype/payment_partner_transaction/payment_partner_transaction.json new file mode 100644 index 0000000000..14878a635a --- /dev/null +++ b/press/press/doctype/payment_partner_transaction/payment_partner_transaction.json @@ -0,0 +1,176 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:PPT-{MM}-{#####}", + "creation": "2024-09-13 13:15:49.154039", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "partner_details_section", + "payment_partner", + "posting_date", + "column_break_manc", + "payment_gateway", + "column_break_ejza", + "team", + "transaction_details_section", + "amount", + "actual_amount", + "column_break_xqsh", + "currency", + "actual_currency", + "column_break_jyxx", + "exchange_rate", + "submitted_to_frappe", + "section_break_yhqq", + "payment_transaction_details", + "section_break_7oh3", + "amended_from" + ], + "fields": [ + { + "fieldname": "section_break_7oh3", + "fieldtype": "Section Break" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Payment Partner Transaction", + "print_hide": 1, + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "transaction_details_section", + "fieldtype": "Section Break", + "label": "Transaction Details" + }, + { + "default": "USD", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "label": "Amount", + "options": "currency" + }, + { + "fieldname": "column_break_xqsh", + "fieldtype": "Column Break" + }, + { + "fieldname": "partner_details_section", + "fieldtype": "Section Break", + "label": "Team Details" + }, + { + "fieldname": "payment_partner", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Payment Partner", + "options": "Team" + }, + { + "fieldname": "column_break_ejza", + "fieldtype": "Column Break" + }, + { + "fieldname": "team", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Team", + "options": "Team" + }, + { + "fieldname": "column_break_jyxx", + "fieldtype": "Column Break" + }, + { + "fieldname": "exchange_rate", + "fieldtype": "Float", + "label": "Exchange Rate" + }, + { + "fieldname": "column_break_manc", + "fieldtype": "Column Break" + }, + { + "fieldname": "payment_gateway", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Payment Gateway", + "options": "Payment Gateway" + }, + { + "fieldname": "section_break_yhqq", + "fieldtype": "Section Break" + }, + { + "fieldname": "payment_transaction_details", + "fieldtype": "Code", + "label": "Payment Transaction Details", + "options": "JSON", + "read_only": 1 + }, + { + "fieldname": "actual_amount", + "fieldtype": "Currency", + "label": "Actual Amount", + "options": "actual_currency" + }, + { + "fetch_from": "payment_gateway.currency", + "fieldname": "actual_currency", + "fieldtype": "Link", + "label": "Actual Currency", + "options": "Currency" + }, + { + "default": "0", + "fieldname": "submitted_to_frappe", + "fieldtype": "Check", + "label": "Submitted To Frappe" + }, + { + "default": "Today", + "fieldname": "posting_date", + "fieldtype": "Date", + "label": "Posting Date" + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2024-11-22 09:19:11.492603", + "modified_by": "Administrator", + "module": "Press", + "name": "Payment Partner Transaction", + "naming_rule": "Expression", + "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": [] +} \ No newline at end of file diff --git a/press/press/doctype/payment_partner_transaction/payment_partner_transaction.py b/press/press/doctype/payment_partner_transaction/payment_partner_transaction.py new file mode 100644 index 0000000000..cfa98b3a98 --- /dev/null +++ b/press/press/doctype/payment_partner_transaction/payment_partner_transaction.py @@ -0,0 +1,34 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + + +class PaymentPartnerTransaction(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + actual_amount: DF.Currency + actual_currency: DF.Link | None + amended_from: DF.Link | None + amount: DF.Currency + currency: DF.Link | None + exchange_rate: DF.Float + payment_gateway: DF.Link | None + payment_partner: DF.Link | None + payment_transaction_details: DF.Code | None + posting_date: DF.Date | None + submitted_to_frappe: DF.Check + team: DF.Link | None + # end: auto-generated types + def on_submit(self): + team = frappe.get_doc("Team", self.team) + payment_partner_user = frappe.db.get_value("Team", self.payment_partner, "user") + remark = f"Credit allocated via Payment Partner: {payment_partner_user} using {self.payment_gateway} gateway ({self.doctype} ID: {self.name})" + team.allocate_credit_amount(self.amount, "Prepaid Credits", remark=remark) diff --git a/press/press/doctype/payment_partner_transaction/test_payment_partner_transaction.py b/press/press/doctype/payment_partner_transaction/test_payment_partner_transaction.py new file mode 100644 index 0000000000..605ab6e73b --- /dev/null +++ b/press/press/doctype/payment_partner_transaction/test_payment_partner_transaction.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestPaymentPartnerTransaction(FrappeTestCase): + pass diff --git a/press/press/doctype/paymob_callback_log/__init__.py b/press/press/doctype/paymob_callback_log/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/press/press/doctype/paymob_callback_log/paymob_callback_log.js b/press/press/doctype/paymob_callback_log/paymob_callback_log.js new file mode 100644 index 0000000000..db4a5ac447 --- /dev/null +++ b/press/press/doctype/paymob_callback_log/paymob_callback_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Paymob Callback Log", { +// refresh(frm) { + +// }, +// }); diff --git a/press/press/doctype/paymob_callback_log/paymob_callback_log.json b/press/press/doctype/paymob_callback_log/paymob_callback_log.json new file mode 100644 index 0000000000..0b9e1bc9cc --- /dev/null +++ b/press/press/doctype/paymob_callback_log/paymob_callback_log.json @@ -0,0 +1,104 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-10-16 20:35:29.472893", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "event_type", + "team", + "payment_partner", + "column_break_lbwj", + "special_reference", + "order_id", + "transaction_id", + "success", + "section_break_yzyu", + "payload" + ], + "fields": [ + { + "fieldname": "team", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Team", + "options": "Team", + "read_only": 1 + }, + { + "fieldname": "special_reference", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Special Reference" + }, + { + "fieldname": "column_break_lbwj", + "fieldtype": "Column Break" + }, + { + "fieldname": "payment_partner", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Payment Partner", + "options": "Team" + }, + { + "fieldname": "section_break_yzyu", + "fieldtype": "Section Break" + }, + { + "fieldname": "payload", + "fieldtype": "Code", + "label": "payload", + "options": "JSON", + "read_only": 1 + }, + { + "fieldname": "transaction_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Transaction ID" + }, + { + "fieldname": "order_id", + "fieldtype": "Data", + "label": "Order ID" + }, + { + "fieldname": "event_type", + "fieldtype": "Data", + "label": "Event Type" + }, + { + "default": "0", + "fieldname": "success", + "fieldtype": "Check", + "label": "Success", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-11-27 14:55:09.850919", + "modified_by": "Administrator", + "module": "Press", + "name": "Paymob Callback Log", + "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": [] +} \ No newline at end of file diff --git a/press/press/doctype/paymob_callback_log/paymob_callback_log.py b/press/press/doctype/paymob_callback_log/paymob_callback_log.py new file mode 100644 index 0000000000..f4c8f2ba5f --- /dev/null +++ b/press/press/doctype/paymob_callback_log/paymob_callback_log.py @@ -0,0 +1,99 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document +from frappe.utils import parse_json +from frappe import as_json + +class PaymobCallbackLog(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + event_type: DF.Data | None + order_id: DF.Data | None + payload: DF.Code | None + payment_partner: DF.Link | None + special_reference: DF.Data | None + success: DF.Check + team: DF.Link | None + transaction_id: DF.Data | None + # end: auto-generated types + def validate(self): + self.set_missing_data() + + def set_missing_data(self): + + if not self.team or not self.payment_partner: + paymob_log_data = self._get_paymob_log_data() + if paymob_log_data: + self.team, self.payment_partner = paymob_log_data + + def after_insert(self): + if self._is_payment_successful(): + self._create_payment_partner_transaction() + + def _get_paymob_log_data(self): + return frappe.db.get_value("Paymob Log", + filters={"special_reference": self.special_reference}, + fieldname=["team", "payment_partner"] + ) + + def _is_payment_successful(self) -> bool: + + if not self.payload: + return False + + try: + payload = parse_json(self.payload) + obj = payload.get("obj", {}) + data = obj.get("data", {}) + + success = obj.get("success", False) + is_live = obj.get("is_live", False) + + txn_response_code = data.get("txn_response_code", "") + + return success and is_live and txn_response_code == "APPROVED" + except ValueError: + frappe.log_error("PaymobCallbackLog Payload Error", "Invalid JSON format in payload",) + return False + + def _create_payment_partner_transaction(self): + try: + paymob_log_data = frappe.db.get_value( + "Paymob Log", + filters={"special_reference": self.special_reference}, + fieldname=["exchange_rate", "amount", "actual_amount"] + ) + if not paymob_log_data: + frappe.log_error(f"Paymob Log not found for reference {self.special_reference}", "PaymobCallbackLog Error") + return + + exchange_rate, amount, paid_amount = paymob_log_data + payload=as_json(parse_json(self.payload)) + create_payment_partner_transaction(self.team, self.payment_partner, exchange_rate, amount, paid_amount, "Paymob", payload) + + + except Exception as e: + frappe.log_error("Error creating Payment Partner Transaction", f"PaymobCallbackLog Error :\n{str(e)}") + +def create_payment_partner_transaction(team, payment_partner, exchange_rate, amount, paid_amount,payment_gateway, payload=None): + """Create a Payment Partner Transaction record.""" + transaction_doc = frappe.get_doc({ + "doctype": "Payment Partner Transaction", + "team": team, + "payment_partner": payment_partner, + "exchange_rate": exchange_rate, + "payment_gateway": payment_gateway, + "amount": amount, + "actual_amount": paid_amount, + "payment_transaction_details": payload + }) + transaction_doc.insert() + transaction_doc.submit() diff --git a/press/press/doctype/paymob_callback_log/test_paymob_callback_log.py b/press/press/doctype/paymob_callback_log/test_paymob_callback_log.py new file mode 100644 index 0000000000..d2067c2128 --- /dev/null +++ b/press/press/doctype/paymob_callback_log/test_paymob_callback_log.py @@ -0,0 +1,137 @@ +# Copyright (c) 2024, Frappe and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase + +class TestPaymobCallbackLog(FrappeTestCase): + def setUp(self): + """Set up a mock PaymobCallbackLog document for testing.""" + self.special_reference = "12345" + + # create team user + self.team_user = frappe.get_doc({ + "doctype": "User", + "email": "team_user@frappecloud.com", + "first_name": "Test Team" + }).insert(ignore_permissions=True, ignore_mandatory=True) + + self.team = frappe.get_doc({ + "doctype": "Team", + "team_title": "Test Team", + "user": self.team_user.name + }).insert(ignore_permissions=True, ignore_mandatory=True) + + # Payment Partner User + self.payment_partner_user = frappe.get_doc({ + "doctype": "User", + "email": "payment_partner@frappecloud.com", + "first_name": "Payment Partner Team" + }).insert(ignore_permissions=True, ignore_mandatory=True) + + self.payment_partner = frappe.get_doc({ + "doctype": "Team", + "team_title": "Test Payment Partner", + "user": self.payment_partner_user.name + }).insert(ignore_permissions=True, ignore_mandatory=True) + + # data needed to create Paymob Log + tax_percentage = (14 / 100) + exchange_rate = 48 + amount = 10 + amount_coverted_egp = (amount * exchange_rate) + actual_amount = (amount_coverted_egp) + (amount_coverted_egp * tax_percentage) + + self.paymob_log = frappe.get_doc({ + "doctype": "Paymob Log", + "special_reference": self.special_reference, + "team": self.team.name, + "payment_partner": self.payment_partner.name, + "exchange_rate": exchange_rate, + "amount": amount, + "actual_amount": actual_amount + }).insert(ignore_permissions=True) + + self.doc = frappe.get_doc({ + "doctype": "Paymob Callback Log", + "event_type": "Transaction", + "special_reference": self.special_reference, + "payload": '{"obj": {"success": true, "is_live": true, "data": {"txn_response_code": "APPROVED"}}}', + "team": None, + "payment_partner": None, + "submit_to_payment_partner": False + }).insert(ignore_permissions=True) + + def tearDown(self): + """Clean up test records.""" + frappe.delete_doc("Team", self.team.name, ignore_permissions=True, force=True) + frappe.delete_doc("Team", self.payment_partner.name, ignore_permissions=True, force=True) + frappe.delete_doc("Paymob Log", self.paymob_log.name, ignore_permissions=True, force=True) + frappe.delete_doc("Paymob Callback Log", self.doc.name, ignore_permissions=True, force=True) + frappe.delete_doc("User", self.team_user.name, ignore_permissions=True, force=True) + frappe.delete_doc("User", self.payment_partner_user.name, ignore_permissions=True, force=True) + + def test_set_missing_data(self): + """Test set_missing_data fetches and sets team and payment partner.""" + self.doc.set_missing_data() + self.assertEqual(self.doc.team, self.team.name) + self.assertEqual(self.doc.payment_partner, self.payment_partner.name) + self.assertEqual(self.doc.special_reference, self.special_reference) + + + def test_is_payment_successful(self): + """Test _is_payment_successful correctly parses payload and checks conditions.""" + # Valid success payload + self.assertTrue(self.doc._is_payment_successful()) + + # Payload with success = False + self.doc.payload = '{"obj": {"success": false}}' + self.assertFalse(self.doc._is_payment_successful()) + + # Invalid payload format + self.doc.payload = "invalid_json" + self.assertFalse(self.doc._is_payment_successful()) + + # Missing payload + self.doc.payload = None + self.assertFalse(self.doc._is_payment_successful()) + + def test_create_payment_partner_transaction(self): + """Test _create_payment_partner_transaction creates transactions successfully.""" + self.doc.set_missing_data() + self.doc._create_payment_partner_transaction() + + # Verify the transaction + transaction = frappe.get_all("Payment Partner Transaction", filters={ + "team": self.doc.team, + "payment_partner": self.doc.payment_partner, + "amount": 10 + }) + + self.assertTrue(len(transaction) > 0) + transaction_doc = frappe.get_doc("Payment Partner Transaction", transaction[0].name) + + self.assertEqual(transaction_doc.amount, 10) + self.assertEqual(transaction_doc.exchange_rate, 48) + expected_actual_amount = 547.2 + self.assertEqual(transaction_doc.actual_amount, expected_actual_amount) + self.assertEqual(transaction_doc.payment_partner, self.doc.payment_partner) + + def test_validate(self): + """Test validate triggers set_missing_data.""" + self.doc.team = None # Reset team to trigger `set_missing_data` + self.doc.validate() + self.assertIsNotNone(self.doc.team) + + def test_after_insert(self): + """Test after_insert calls appropriate methods based on success.""" + # self.doc.team = None # Reset team to trigger methods + self.doc.after_insert() + + # Verify the transaction + transaction = frappe.get_all("Payment Partner Transaction", filters={ + "team": self.doc.team, + "payment_partner": self.doc.payment_partner + }) + + self.assertTrue(len(transaction) > 0) \ No newline at end of file diff --git a/press/press/doctype/paymob_log/__init__.py b/press/press/doctype/paymob_log/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/press/press/doctype/paymob_log/paymob_log.js b/press/press/doctype/paymob_log/paymob_log.js new file mode 100644 index 0000000000..634b9dfe32 --- /dev/null +++ b/press/press/doctype/paymob_log/paymob_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Paymob Log", { +// refresh(frm) { + +// }, +// }); diff --git a/press/press/doctype/paymob_log/paymob_log.json b/press/press/doctype/paymob_log/paymob_log.json new file mode 100644 index 0000000000..dba66acf89 --- /dev/null +++ b/press/press/doctype/paymob_log/paymob_log.json @@ -0,0 +1,129 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "hash", + "creation": "2024-10-10 13:18:23.905341", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "event_type", + "payment_partner", + "amount", + "exchange_rate", + "amount_currency", + "column_break_qvrr", + "team", + "special_reference", + "actual_amount", + "actual_currency", + "section_break_zuri", + "payload" + ], + "fields": [ + { + "fieldname": "event_type", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Event Type" + }, + { + "fieldname": "payment_partner", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Payment Partner", + "options": "Team", + "read_only": 1 + }, + { + "fieldname": "column_break_qvrr", + "fieldtype": "Column Break" + }, + { + "fieldname": "team", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Team", + "options": "Team", + "read_only": 1 + }, + { + "fieldname": "section_break_zuri", + "fieldtype": "Section Break" + }, + { + "fieldname": "payload", + "fieldtype": "Code", + "label": "Payload", + "options": "JSON", + "read_only": 1 + }, + { + "fieldname": "special_reference", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Special Reference", + "read_only": 1 + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "label": "Amount", + "options": "amount_currency", + "read_only": 1 + }, + { + "fieldname": "exchange_rate", + "fieldtype": "Float", + "label": "Exchange Rate", + "read_only": 1 + }, + { + "fieldname": "actual_amount", + "fieldtype": "Currency", + "label": "Actual Amount", + "options": "actual_currency", + "read_only": 1 + }, + { + "default": "USD", + "fieldname": "amount_currency", + "fieldtype": "Link", + "hidden": 1, + "label": "Amount Currency", + "options": "Currency" + }, + { + "default": "EGP", + "fieldname": "actual_currency", + "fieldtype": "Link", + "hidden": 1, + "label": "Actual Currency", + "options": "Currency" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-10-17 18:17:45.767607", + "modified_by": "Administrator", + "module": "Press", + "name": "Paymob Log", + "naming_rule": "Random", + "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": [] +} \ No newline at end of file diff --git a/press/press/doctype/paymob_log/paymob_log.py b/press/press/doctype/paymob_log/paymob_log.py new file mode 100644 index 0000000000..53b39d4603 --- /dev/null +++ b/press/press/doctype/paymob_log/paymob_log.py @@ -0,0 +1,28 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class PaymobLog(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + actual_amount: DF.Currency + actual_currency: DF.Link | None + amount: DF.Currency + amount_currency: DF.Link | None + event_type: DF.Data | None + exchange_rate: DF.Float + payload: DF.Code | None + payment_partner: DF.Link | None + special_reference: DF.Data | None + team: DF.Link | None + # end: auto-generated types + pass diff --git a/press/press/doctype/paymob_log/test_paymob_log.py b/press/press/doctype/paymob_log/test_paymob_log.py new file mode 100644 index 0000000000..e571c13acd --- /dev/null +++ b/press/press/doctype/paymob_log/test_paymob_log.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestPaymobLog(FrappeTestCase): + pass diff --git a/press/press/doctype/paymob_settings/__init__.py b/press/press/doctype/paymob_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/press/press/doctype/paymob_settings/paymob_settings.js b/press/press/doctype/paymob_settings/paymob_settings.js new file mode 100644 index 0000000000..66dcc2eb1f --- /dev/null +++ b/press/press/doctype/paymob_settings/paymob_settings.js @@ -0,0 +1,27 @@ +// Copyright (c) 2024, Frappe and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Paymob Settings", { + refresh(frm) { + frm.add_custom_button(__("Get Access Token"), () => { + frm.trigger("get_access_token") + }).addClass("btn btn-primary") + }, + get_access_token: function (frm) { + try { + frm.call({ + method: "get_access_token", + doc: frm.doc, + freeze: true, + freeze_message: __("Getting Access Token ...") + }).then((r) => { + if (!r.exc && r.message) { + frm.set_value("token", r.message) + frappe.show_alert({ message: __("Access Token Updated"), indicator: "green"}); + } + }); + } catch(e) { + console.log(e); + } + } +}); diff --git a/press/press/doctype/paymob_settings/paymob_settings.json b/press/press/doctype/paymob_settings/paymob_settings.json new file mode 100644 index 0000000000..5aec3ac5b6 --- /dev/null +++ b/press/press/doctype/paymob_settings/paymob_settings.json @@ -0,0 +1,128 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-10-10 11:16:57.274423", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "credentials_section", + "api_key", + "public_key", + "hmac", + "column_break_abbe", + "token", + "secret_key", + "section_break_ddej", + "iframe", + "column_break_euul", + "payment_integration" + ], + "fields": [ + { + "fieldname": "credentials_section", + "fieldtype": "Section Break", + "label": "Credentials" + }, + { + "fieldname": "api_key", + "fieldtype": "Password", + "in_list_view": 1, + "label": "API Key", + "reqd": 1 + }, + { + "fieldname": "public_key", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Public Key", + "reqd": 1 + }, + { + "fieldname": "hmac", + "fieldtype": "Password", + "in_list_view": 1, + "label": "HMAC", + "reqd": 1 + }, + { + "fieldname": "column_break_abbe", + "fieldtype": "Column Break" + }, + { + "fieldname": "token", + "fieldtype": "Password", + "label": "Token" + }, + { + "fieldname": "secret_key", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Secret Key", + "reqd": 1 + }, + { + "fieldname": "section_break_ddej", + "fieldtype": "Section Break", + "label": "Payment Config" + }, + { + "fieldname": "iframe", + "fieldtype": "Data", + "label": "Iframe", + "reqd": 1 + }, + { + "fieldname": "column_break_euul", + "fieldtype": "Column Break" + }, + { + "fieldname": "payment_integration", + "fieldtype": "Int", + "label": "Payment Integration", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2024-10-10 12:48:56.812698", + "modified_by": "Administrator", + "module": "Press", + "name": "Paymob Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Accounts Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/press/press/doctype/paymob_settings/paymob_settings.py b/press/press/doctype/paymob_settings/paymob_settings.py new file mode 100644 index 0000000000..24bca277fd --- /dev/null +++ b/press/press/doctype/paymob_settings/paymob_settings.py @@ -0,0 +1,51 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document +from press.api.local_payments.paymob.accept_api import AcceptAPI + + +class PaymobSettings(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + api_key: DF.Password + hmac: DF.Password + iframe: DF.Data + payment_integration: DF.Int + public_key: DF.Password + secret_key: DF.Password + token: DF.Password | None + # end: auto-generated types + + @frappe.whitelist() + def get_access_token(self): + accept = AcceptAPI() + token = accept.retrieve_auth_token() + return token + +@frappe.whitelist() +def update_paymob_settings(**kwargs): + args = frappe._dict(kwargs) + fields = frappe._dict( + { + "api_key": args.get("api_key"), + "secret_key": args.get("secret_key"), + "public_key": args.get("public_key"), + "hmac": args.get("hmac"), + "iframe": args.get("iframe"), + "payment_integration": args.get("payment_integration"), + } + ) + try: + paymob_settings = frappe.get_doc("Paymob Settings").update(fields) + paymob_settings.save() + return "Paymob Credentials Successfully" + except Exception as e: + return "Failed to Update Paymob Credentials" diff --git a/press/press/doctype/paymob_settings/test_paymob_settings.py b/press/press/doctype/paymob_settings/test_paymob_settings.py new file mode 100644 index 0000000000..854dc83a56 --- /dev/null +++ b/press/press/doctype/paymob_settings/test_paymob_settings.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestPaymobSettings(FrappeTestCase): + pass diff --git a/press/press/doctype/press_settings/press_settings.py b/press/press/doctype/press_settings/press_settings.py index 400b28755a..c498085f09 100644 --- a/press/press/doctype/press_settings/press_settings.py +++ b/press/press/doctype/press_settings/press_settings.py @@ -145,9 +145,7 @@ class PressSettings(Document): use_app_cache: DF.Check use_delta_builds: DF.Check use_staging_ca: DF.Check - verify_cards_with_micro_charge: DF.Literal[ - "No", "Only INR", "Only USD", "Both INR and USD" - ] + verify_cards_with_micro_charge: DF.Literal["No", "Only INR", "Only USD", "Both INR and USD"] webroot_directory: DF.Data | None # end: auto-generated types diff --git a/press/press/doctype/team/team.json b/press/press/doctype/team/team.json index 3ae7e17b26..f51b38d40e 100644 --- a/press/press/doctype/team/team.json +++ b/press/press/doctype/team/team.json @@ -42,6 +42,9 @@ "free_credits_allocated", "column_break_12", "address_html", + "tax_id", + "phone_number", + "partner_commission", "custom_apps_section", "github_access_token", "partner_section", @@ -441,6 +444,21 @@ "fieldname": "enable_inplace_updates", "fieldtype": "Check", "label": "Enable In Place Updates" + }, + { + "fieldname": "tax_id", + "fieldtype": "Data", + "label": "Tax Id" + }, + { + "fieldname": "phone_number", + "fieldtype": "Data", + "label": "Phone Number" + }, + { + "fieldname": "partner_commission", + "fieldtype": "Percent", + "label": "Partner Commission" } ], "links": [ @@ -500,7 +518,7 @@ "link_fieldname": "team" } ], - "modified": "2024-09-13 11:55:07.048543", + "modified": "2024-11-15 11:46:09.082313", "modified_by": "Administrator", "module": "Press", "name": "Team", diff --git a/press/press/doctype/team/team.py b/press/press/doctype/team/team.py index b9a7f453ac..c1142f11ae 100644 --- a/press/press/doctype/team/team.py +++ b/press/press/doctype/team/team.py @@ -34,9 +34,7 @@ class Team(Document): if TYPE_CHECKING: from frappe.types import DF from press.press.doctype.child_team_member.child_team_member import ChildTeamMember - from press.press.doctype.communication_email.communication_email import ( - CommunicationEmail, - ) + from press.press.doctype.communication_email.communication_email import CommunicationEmail from press.press.doctype.invoice_discount.invoice_discount import InvoiceDiscount from press.press.doctype.team_member.team_member import TeamMember @@ -70,10 +68,12 @@ class Team(Document): last_used_team: DF.Link | None notify_email: DF.Data | None parent_team: DF.Link | None + partner_commission: DF.Percent partner_email: DF.Data | None partner_referral_code: DF.Data | None partnership_date: DF.Date | None payment_mode: DF.Literal["", "Card", "Prepaid Credits", "Paid By Partner"] + phone_number: DF.Data | None razorpay_enabled: DF.Check referrer_id: DF.Data | None security_portal_enabled: DF.Check @@ -83,6 +83,7 @@ class Team(Document): skip_backups: DF.Check ssh_access_enabled: DF.Check stripe_customer_id: DF.Data | None + tax_id: DF.Data | None team_members: DF.Table[TeamMember] team_title: DF.Data | None user: DF.Link | None diff --git a/press/press/report/mpesa_team_payment/__init__.py b/press/press/report/mpesa_team_payment/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/press/press/report/mpesa_team_payment/mpesa_team_payment.js b/press/press/report/mpesa_team_payment/mpesa_team_payment.js new file mode 100644 index 0000000000..aa0188f3a6 --- /dev/null +++ b/press/press/report/mpesa_team_payment/mpesa_team_payment.js @@ -0,0 +1,45 @@ +// Copyright (c) 2024, Frappe and contributors +// For license information, please see license.txt + +frappe.query_reports["Mpesa Team Payment"] = { + "filters": [ + { + "fieldname":"from_date", + "label": __("From Date"), + "fieldtype": "Date", + "default": frappe.datetime.add_days(frappe.datetime.get_today(), -30), + } + , + { + "fieldname":"to_date", + "label": __("To Date"), + "fieldtype": "Date", + "default": frappe.datetime.get_today(), + } + , + { + "fieldname":"team", + "label": __("Team"), + "fieldtype": "Link", + "options": "Team", + } + , + + { + "fieldname":"payment_partner", + "label": __("Payment Partner"), + "fieldtype": "Link", + "options": "Team", + }, + { + "fieldname":"transaction_type", + "label": __("Transaction Type"), + "fieldtype": "Select", + "options": "\nMpesa Express\nMpesa C2B", + "default": "Mpesa Express", + }, + + + + ] +}; diff --git a/press/press/report/mpesa_team_payment/mpesa_team_payment.json b/press/press/report/mpesa_team_payment/mpesa_team_payment.json new file mode 100644 index 0000000000..a385ed6472 --- /dev/null +++ b/press/press/report/mpesa_team_payment/mpesa_team_payment.json @@ -0,0 +1,35 @@ +{ + "add_total_row": 1, + "columns": [], + "creation": "2024-11-04 10:40:23.092691", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2024-11-04 10:40:23.092691", + "modified_by": "Administrator", + "module": "Press", + "name": "Mpesa Team Payment", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Mpesa Payment Record", + "report_name": "Mpesa Team Payment", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Accounts User" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Sales User" + } + ] +} \ No newline at end of file diff --git a/press/press/report/mpesa_team_payment/mpesa_team_payment.py b/press/press/report/mpesa_team_payment/mpesa_team_payment.py new file mode 100644 index 0000000000..6fe5fddf79 --- /dev/null +++ b/press/press/report/mpesa_team_payment/mpesa_team_payment.py @@ -0,0 +1,67 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ + +def execute(filters=None): + columns = get_columns() + data = get_data(filters) + return columns, data + +def get_columns(): + return [ + {"label": _("name"), "fieldname": "name", "fieldtype": "Link","options":"Mpesa Payment Record","width": 100}, + {"label": _("Team"), "fieldname": "team", "fieldtype": "Link", "options": "Team", "width": 150}, + {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 120}, + {"label": _("Transaction Type"), "fieldname": "transaction_type", "fieldtype": "Select", "width": 150}, + {"label": _("Grand Total"), "fieldname": "grand_total", "fieldtype": "Currency","options":"default_currency", "width": 120}, + {"label": _("FC Amount"), "fieldname": "trans_amount", "fieldtype": "Currency","options":"default_currency", "width": 120}, + {"label": _("FC Amount"), "fieldname": "amount_usd", "fieldtype": "Currency","options":"currency", "width": 120}, + {"label": _("Exchange Rate"), "fieldname": "exchange_rate", "fieldtype": "Float", "width": 100}, + {"label": _("MSISDN"), "fieldname": "msisdn", "fieldtype": "Data", "width": 120}, + {"label": _("Payment Partner"), "fieldname": "payment_partner", "fieldtype": "Link", "options": "Team", "width": 150}, + {"label": _("Default Currency"), "fieldname": "default_currency", "fieldtype": "Link","options":"Currency", "width": 100, "hidden":1}, + {"label": _("Balance Transaction"), "fieldname": "balance_transaction", "fieldtype": "Link","options":"Balance Transaction", "width": 150}, + {"label": _("Currency"), "fieldname": "currency", "fieldtype": "Link","options":"Currency", "width": 100, "hidden":1}, + ] + + +def get_data(filters): + if filters.from_date > filters.to_date: + frappe.throw(_("From Date cannot be after To Date")) + + mpesa_record=frappe.qb.DocType("Mpesa Payment Record") + + query=frappe.qb.from_(mpesa_record)\ + .select("name","team","transaction_type","merchant_request_id","trans_id","grand_total","exchange_rate","trans_amount","amount_usd","msisdn","payment_partner","invoice_number","posting_date","default_currency","balance_transaction", + ).where(mpesa_record.docstatus == 1) + + query = apply_filters(query, filters, mpesa_record) + data = query.run(as_dict=True) + # Append currency to each record + for record in data: + record["currency"] = "USD" + + return data + + +def apply_filters(query, filters, mpesa_record): + doc_status = {"Draft": 0, "Submitted": 1, "Cancelled": 2} + + for filter_key, filter_value in filters.items(): + if filter_key == "from_date": + query = query.where(mpesa_record.posting_date >= filter_value) + elif filter_key == "to_date": + query = query.where(mpesa_record.posting_date <= filter_value) + elif filter_key == "team": + query = query.where(mpesa_record.team == filter_value) + elif filter_key == "payment_partner": + query = query.where(mpesa_record.payment_partner == filter_value) + elif filter_key == "transaction_type": + query = query.where(mpesa_record.transaction_type == filter_value) + elif filter_key == "docstatus": + query = query.where(mpesa_record.docstatus == doc_status.get(filter_value, 0)) + + return query + diff --git a/press/press/report/payment_partner/__init__.py b/press/press/report/payment_partner/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/press/press/report/payment_partner/payment_partner.js b/press/press/report/payment_partner/payment_partner.js new file mode 100644 index 0000000000..3199f25fdb --- /dev/null +++ b/press/press/report/payment_partner/payment_partner.js @@ -0,0 +1,47 @@ +// Copyright (c) 2024, Frappe and contributors +// For license information, please see license.txt + +frappe.query_reports["Payment Partner"] = { +"filters": [ + { + "fieldname":"from_date", + "label": __("From Date"), + "fieldtype": "Date", + "default": frappe.datetime.add_days(frappe.datetime.get_today(), -30), + } + , + { + "fieldname":"to_date", + "label": __("To Date"), + "fieldtype": "Date", + "default": frappe.datetime.get_today(), + } + , + { + "fieldname":"team", + "label": __("Team"), + "fieldtype": "Link", + "options": "Team", + } + , + + { + "fieldname":"payment_partner", + "label": __("Payment Partner"), + "fieldtype": "Link", + "options": "Team", + }, + { + "fieldname":"payment_gateway", + "label": __("Payment Gateway"), + "fieldtype": "Link", + "options": "Payment Gateway", + }, + { + "fieldname":"submitted_to_frappe", + "label": __("Submitted to Frappe"), + "fieldtype": "Check", + }, + + ] +}; diff --git a/press/press/report/payment_partner/payment_partner.json b/press/press/report/payment_partner/payment_partner.json new file mode 100644 index 0000000000..051dd7f378 --- /dev/null +++ b/press/press/report/payment_partner/payment_partner.json @@ -0,0 +1,29 @@ +{ + "add_total_row": 1, + "columns": [], + "creation": "2024-11-25 15:50:14.609170", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2024-11-25 15:50:14.609170", + "modified_by": "Administrator", + "module": "Press", + "name": "Payment Partner", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Payment Partner Transaction", + "report_name": "Payment Partner", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Guest" + } + ] +} \ No newline at end of file diff --git a/press/press/report/payment_partner/payment_partner.py b/press/press/report/payment_partner/payment_partner.py new file mode 100644 index 0000000000..b5b15af1c7 --- /dev/null +++ b/press/press/report/payment_partner/payment_partner.py @@ -0,0 +1,67 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ + +def execute(filters=None): + columns = get_columns() + data = get_data(filters) + return columns, data + +def get_columns(): + return [ + {"label": _("Transaction ID"), "fieldname": "name", "fieldtype": "Link","options":"Payment Partner Transaction","width": 100}, + {"label": _("Team"), "fieldname": "team", "fieldtype": "Link", "options": "Team", "width": 150}, + {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 120}, + {"label": _("Payment Gateway"), "fieldname": "payment_gateway", "fieldtype": "Link","options":"Payment Gateway","width": 150}, + {"label": _("FC Amount"), "fieldname": "amount", "fieldtype": "Currency","options":"currency", "width": 120}, + {"label": _("Actual Amount"), "fieldname": "actual_amount", "fieldtype": "Currency","options":"actual_currency", "width": 120}, + {"label": _("Exchange Rate"), "fieldname": "exchange_rate", "fieldtype": "Float", "width": 100}, + {"label": _("Payment Partner"), "fieldname": "payment_partner", "fieldtype": "Link", "options": "Team", "width": 150}, + {"label": _("Submitted To Frappe"), "fieldname": "submitted_to_frappe", "fieldtype": "Check", "width": 150}, + {"label": _("Actual Currency"), "fieldname": "actual_currency", "fieldtype": "Link","options":"Currency", "width": 100, "hidden":1}, + {"label": _("Currency"), "fieldname": "currency", "fieldtype": "Link","options":"Currency", "width": 100, "hidden":1}, + ] + + +def get_data(filters): + if filters.from_date > filters.to_date: + frappe.throw(_("From Date cannot be after To Date")) + + payment_record=frappe.qb.DocType("Payment Partner Transaction") + + query=frappe.qb.from_(payment_record)\ + .select("name","team","payment_gateway","payment_partner","amount","actual_amount","submitted_to_frappe","posting_date","actual_currency","exchange_rate", + ).where(payment_record.docstatus == 1) + + query = apply_filters(query, filters, payment_record) + data = query.run(as_dict=True) + # Append currency to each record + for record in data: + record["currency"] = "USD" + + return data + + +def apply_filters(query, filters, payment_record): + doc_status = {"Draft": 0, "Submitted": 1, "Cancelled": 2} + + for filter_key, filter_value in filters.items(): + if filter_key == "from_date": + query = query.where(payment_record.posting_date >= filter_value) + elif filter_key == "to_date": + query = query.where(payment_record.posting_date <= filter_value) + elif filter_key == "team": + query = query.where(payment_record.team == filter_value) + elif filter_key == "payment_partner": + query = query.where(payment_record.payment_partner == filter_value) + elif filter_key == "payment_gateway": + query = query.where(payment_record.payment_gateway == filter_value) + elif filter_key == "submitted_to_frappe": + query = query.where(payment_record.submitted_to_frappe == filter_value) + elif filter_key == "docstatus": + query = query.where(payment_record.docstatus == doc_status.get(filter_value, 0)) + + return query + diff --git a/press/public/images/mpesa.svg b/press/public/images/mpesa.svg new file mode 100644 index 0000000000..d24056df00 --- /dev/null +++ b/press/public/images/mpesa.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/press/public/images/paymobLogo.png b/press/public/images/paymobLogo.png new file mode 100644 index 0000000000..36c1d5ce90 Binary files /dev/null and b/press/public/images/paymobLogo.png differ diff --git a/press/public/marketplace/js/call.js b/press/public/marketplace/js/call.js index 82e906b43b..41385f18c4 100644 --- a/press/public/marketplace/js/call.js +++ b/press/public/marketplace/js/call.js @@ -70,3 +70,4 @@ export default async function call(method, args) { throw e; } } + diff --git a/press/utils/billing.py b/press/utils/billing.py index e0d451dfc8..8c3c941493 100644 --- a/press/utils/billing.py +++ b/press/utils/billing.py @@ -8,6 +8,7 @@ from press.exceptions import CentralServerNotSet, FrappeioServerNotSet from press.utils import get_current_team, log_error + states_with_tin = { "Andaman and Nicobar Islands": "35", "Andhra Pradesh": "37", @@ -241,3 +242,25 @@ def process_micro_debit_test_charge(stripe_event): ).insert(ignore_permissions=True) except Exception: log_error("Error Processing Stripe Micro Debit Charge", body=stripe_event) + + +#Get partners external connection +def get_partner_external_connection(mpesa_settings): + #check if connection is already established + if hasattr(frappe.local, "_external_conn"): + return frappe.local.press_external_conn + from frappe.frappeclient import FrappeClient + + #Fetch API from gateway + payment_gateway = frappe.get_all("Payment Gateway", filters={"gateway_controller":mpesa_settings, "gatway_setting":"Mpesa Settings"}, fields=["name","url","api_key","api_secret"]) + if not payment_gateway: + frappe.throw("Mpesa Settings not set up in Payment Gateway") + #Fetch API key and secret + api_key = payment_gateway[0].api_key + api_secret = payment_gateway[0].api_secret + url= payment_gateway[0].url + + #Establish connection + frappe.local._external_conn = FrappeClient(url, api_key=api_key, api_secret=api_secret) + return frappe.local._external_conn + diff --git a/press/utils/mpesa_utils.py b/press/utils/mpesa_utils.py new file mode 100644 index 0000000000..06b02146cd --- /dev/null +++ b/press/utils/mpesa_utils.py @@ -0,0 +1,166 @@ +# Copyright (c) 2019, Frappe Technologies and contributors +# License: MIT. See LICENSE + +import datetime +import json +from urllib.parse import parse_qs + +import frappe +from frappe.utils import get_request_session + + +def make_request(method: str, url: str, auth=None, headers=None, data=None, json=None, params=None): + auth = auth or "" + data = data or {} + headers = headers or {} + + try: + s = get_request_session() + response = frappe.flags.integration_request = s.request( + method, url, data=data, auth=auth, headers=headers, json=json, params=params + ) + response.raise_for_status() + + # Check whether the response has a content-type, before trying to check what it is + if content_type := response.headers.get("content-type"): + if content_type == "text/plain; charset=utf-8": + return parse_qs(response.text) + elif content_type.startswith("application/") and content_type.split(";")[0].endswith("json"): + return response.json() + elif response.text: + return response.text + return + except Exception as exc: + if frappe.flags.integration_request_doc: + frappe.flags.integration_request_doc.log_error() + else: + frappe.log_error() + raise exc + + +def make_get_request(url: str, **kwargs): + """Make a 'GET' HTTP request to the given `url` and return processed response. + + You can optionally pass the below parameters: + + * `headers`: Headers to be set in the request. + * `params`: Query parameters to be passed in the request. + * `auth`: Auth credentials. + """ + return make_request("GET", url, **kwargs) + + +def make_post_request(url: str, **kwargs): + """Make a 'POST' HTTP request to the given `url` and return processed response. + + You can optionally pass the below parameters: + + * `headers`: Headers to be set in the request. + * `data`: Data to be passed in body of the request. + * `json`: JSON to be passed in the request. + * `params`: Query parameters to be passed in the request. + * `auth`: Auth credentials. + """ + return make_request("POST", url, **kwargs) + + +def make_put_request(url: str, **kwargs): + """Make a 'PUT' HTTP request to the given `url` and return processed response. + + You can optionally pass the below parameters: + + * `headers`: Headers to be set in the request. + * `data`: Data to be passed in body of the request. + * `json`: JSON to be passed in the request. + * `params`: Query parameters to be passed in the request. + * `auth`: Auth credentials. + """ + return make_request("PUT", url, **kwargs) + + +def make_patch_request(url: str, **kwargs): + """Make a 'PATCH' HTTP request to the given `url` and return processed response. + + You can optionally pass the below parameters: + + * `headers`: Headers to be set in the request. + * `data`: Data to be passed in body of the request. + * `json`: JSON to be passed in the request. + * `params`: Query parameters to be passed in the request. + * `auth`: Auth credentials. + """ + return make_request("PATCH", url, **kwargs) + + +def make_delete_request(url: str, **kwargs): + """Make a 'DELETE' HTTP request to the given `url` and return processed response. + + You can optionally pass the below parameters: + + * `headers`: Headers to be set in the request. + * `data`: Data to be passed in body of the request. + * `json`: JSON to be passed in the request. + * `params`: Query parameters to be passed in the request. + * `auth`: Auth credentials. + """ + return make_request("DELETE", url, **kwargs) + + +def create_request_log( + data, + integration_type=None, + service_name=None, + name=None, + error=None, + request_headers=None, + output=None, + **kwargs, +): + """ + DEPRECATED: The parameter integration_type will be removed in the next major release. + Use is_remote_request instead. + """ + if integration_type == "Remote": + kwargs["is_remote_request"] = 1 + + elif integration_type == "Subscription Notification": + kwargs["request_description"] = integration_type + + reference_doctype = reference_docname = None + if "reference_doctype" not in kwargs: + if isinstance(data, str): + data = json.loads(data) + + reference_doctype = data.get("reference_doctype") + reference_docname = data.get("reference_docname") + + integration_request = frappe.get_doc( + { + "doctype": "Mpesa Request Log", + "integration_request_service": service_name, + "request_headers": get_json(request_headers), + "data": get_json(data), + "output": get_json(output), + "error": get_json(error), + "reference_doctype": reference_doctype, + "reference_docname": reference_docname, + **kwargs, + } + ) + + if name: + integration_request.flags._name = name + + integration_request.insert(ignore_permissions=True) + frappe.db.commit() + + return integration_request + + +def get_json(obj): + return obj if isinstance(obj, str) else frappe.as_json(obj, indent=1) + + +def json_handler(obj): + if isinstance(obj, datetime.date | datetime.timedelta | datetime.datetime): + return str(obj) \ No newline at end of file