├── .coveragerc ├── .editorconfig ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── pull_request_template.md ├── .gitignore ├── .travis.yml ├── .travis └── semver.sh ├── AUTHORS.md ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── RESOURCES.md ├── ci └── bootstrap.py ├── setup.cfg ├── setup.py ├── src ├── .gitignore └── mtnmomo │ ├── __init__.py │ ├── __main__.py │ ├── cli.py │ ├── client.py │ ├── collection.py │ ├── config.py │ ├── disbursement.py │ ├── errors.py │ ├── remittance.py │ └── utils.py ├── tests ├── __init__.py ├── integration │ ├── features │ │ ├── collections.feature │ │ ├── disbursements.feature │ │ ├── remittances.feature │ │ └── sandbox.feature │ ├── test_collection.py │ ├── test_disbursement.py │ ├── test_remittance.py │ └── test_sandbox.py └── unitests │ ├── __init__.py │ ├── test_collection.py │ ├── test_disbursement.py │ ├── test_remittance.py │ └── utils.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = src 3 | 4 | [run] 5 | branch = true 6 | source = 7 | src 8 | tests 9 | parallel = true 10 | 11 | [report] 12 | show_missing = true 13 | precision = 2 14 | omit = *migrations* 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | charset = utf-8 11 | 12 | [*.{bat,cmd,ps1}] 13 | end_of_line = crlf 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **IMPORTANT: Please do not create a Pull Request without creating an issue first.** 2 | 3 | *Any change needs to be discussed before proceeding. Failure to do so may result in the rejection of the pull request.* 4 | 5 | Please provide enough information so that others can review your pull request: 6 | 7 | 8 | 9 | Explain the **details** for making this change. What existing problem does the pull request solve? 10 | 11 | 12 | 13 | **Test plan (required)** 14 | 15 | Demonstrate the code is solid. Example: The exact commands you ran and their output, screenshots / videos if the pull request changes UI. 16 | 17 | 18 | 19 | **Code formatting** 20 | 21 | 22 | 23 | **Closing issues** 24 | 25 | Put `closes #XXXX` in your comment to auto-close the issue that your PR fixes (if such). 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | .eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | wheelhouse 18 | develop-eggs 19 | .installed.cfg 20 | lib 21 | lib64 22 | venv*/ 23 | pyvenv*/ 24 | 25 | # Installer logs 26 | pip-log.txt 27 | 28 | # Unit test / coverage reports 29 | .coverage 30 | .tox 31 | .coverage.* 32 | .pytest_cache/ 33 | nosetests.xml 34 | coverage.xml 35 | htmlcov 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | .idea 45 | *.iml 46 | *.komodoproject 47 | 48 | # Complexity 49 | output/*.html 50 | output/*/index.html 51 | 52 | # Sphinx 53 | docs/_build 54 | 55 | .DS_Store 56 | *~ 57 | .*.sw[po] 58 | .build 59 | .ve 60 | .env 61 | .cache 62 | .pytest 63 | .bootstrap 64 | .appveyor.token 65 | *.bak 66 | 67 | # Mypy Cache 68 | .mypy_cache/ 69 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: 3 | directories: 4 | - "$HOME/.cache/pip" 5 | - "$HOME/.pyenv" 6 | matrix: 7 | include: 8 | - os: linux 9 | dist: trusty 10 | sudo: false 11 | env: TOXENV=flake8,coveralls 12 | python: '3.6' 13 | - os: linux 14 | dist: trusty 15 | sudo: false 16 | env: TOXENV=py27-cover 17 | python: '2.7' 18 | - os: linux 19 | dist: trusty 20 | sudo: false 21 | python: '3.5' 22 | env: TOXENV=py35-cover 23 | - os: linux 24 | dist: trusty 25 | sudo: false 26 | python: '3.6' 27 | env: TOXENV=py36-cover 28 | - os: linux 29 | dist: xenial 30 | sudo: required 31 | python: '3.7' 32 | env: TOXENV=py37-cover 33 | script: 34 | - pip install tox 35 | - tox 36 | install: 37 | - python setup.py install 38 | deploy: 39 | provider: pypi 40 | user: sparkplug 41 | on: 42 | tags: true 43 | password: 44 | secure: AABEEwfWWBSb91c20ceFOUnriS9q+f+j/p0gSVsI8MvRES5lPsov9MGaiA6bf9hxLt9xIThpbY4AtwW4Y3U46tosPLW9kyeTgMINRAXmb/uU7h7hH9sTqmsZsSDwzJo/IdkqUszJmTlH/Km/6Ly3EDown/Hdd0FjTj/wJ3Z3PfkIR6GItz/h5QnIw8lx37PJhjE91keQqPxh6dvf8ewNz+f7QjxrFXy+O53pFwF2fpHmGTFa0MPBuRvBhi3fdn8dxKVZ5FI4tj6FBp8fPWoY5vOz6xRSDJ6W75IZPk24r7p4qH8oLRtCOcR+8kntftyrthaUacmFuWJWqlUSCU5p65qgJs4x3M1zzMutfCGPWHLnY/jhqpuAlu2QefBRc0vmVzs+Z/ieZSFHbjVuDrem9S1rt4HgCj7+VozQvLuz1LTWlOa/jh0phCK4+2BOeN20NNegjxNrLruz6kW9kneACvra+ppum0E8BRwknFkTmUPrLtnKd53h+lKZ7My7FRK8mjzXkj6RoOTjPoBZsDbvccXBc7ta8DlB07D9HGmYXwy2jCAAV3xdJy5Dwkhv2B7ck+g2xAHo/FdqmyLkhyh8BIZI6ixFbCGbwqN5sLFQ85+NDnfWA+HTh7UC1N6FNt4KBcnvx8Ho7cE/5piUPvhxw89nAH2beicnDz0ooWqKcn4= 45 | after_success: 46 | - test $TRAVIS_BRANCH = "master" && .travis/semver.sh 47 | env: 48 | global: 49 | secure: BfFLRnrlUwkU7FJui6XIrbN/nmxWkuDaGFrXLcqKSzWQmIwq8T/xIU2yje+wpjUx3mfErWXUgbf8EK/A83OwDgwDvZ4vlBojWA/aTKFUHtW7S7Ix1rTQbuxIzhEpBg2elIQzuKcH9hccGSzpcC6TqeuIWu/Soog7bofO6LT8SXqoRRPZnXgm07pKyJ/8wbUlJ34Ktoje5056zRsmEriFVf4/Qv6YciDzXtPbTRDCy+gXSAOFnr5y7d6Z8SQ3T5eki17X80Yn12nK0fymkTwbBi1F51dHkqSVYB/J6uBI5+K3c80gIw/sqhiHpk2WhfCB6ju/xAlHa9cSPKggJSPeX/v2Ujfk/7wDbH7mlBKsSisWKlQ/8oOd51aiXuHKP4yrj85URhxUNHoehYMg2JyQILTICFcSf4/AlbuEWFYtH34TBS+fF56kMxjHAdDCEX/i3VfAfAER3xsFTCTmQgvyZprPkekQOj5Cy1U+V5oPXRs94h0DqYbsMip1E1cbvevmjCzP3B2UZ/00mlp21a2OtNioxXaptgLsZjNS2kahc1yhDu6L3k2QZwxc58KdgL2vP+t7aQIcGO7gYimmn6GvXiDOjojXAVp0FPIHLnjNVHyqbQa6cma14sANKRZfyfuaGg3z763I7sdc5Su1iufZoD/+QRR4zrtyv3itfEvUBPo= 50 | -------------------------------------------------------------------------------- /.travis/semver.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export SEMVER_LAST_TAG=$(git describe --abbrev=0 --tags 2>/dev/null) 4 | export SEMVER_RELEASE_LEVEL=$(git log --oneline -1 --pretty=%B | cat | tr -d '\n' | cut -d "[" -f2 | cut -d "]" -f1) 5 | 6 | if [ -z $SEMVER_LAST_TAG ]; then 7 | >&2 echo "No tags defined" 8 | SEMVER_LAST_TAG="0.0.1" 9 | fi 10 | 11 | if [ -n $SEMVER_RELEASE_LEVEL ]; then 12 | git clone https://github.com/fsaintjacques/semver-tool /tmp/semver &> /dev/null 13 | SEMVER_NEW_TAG=$(/tmp/semver/src/semver bump $SEMVER_RELEASE_LEVEL $SEMVER_LAST_TAG) 14 | git tag $SEMVER_NEW_TAG &> /dev/null 15 | git push https://${GH_TOKEN}@github.com/sparkplug/momoapi-python.git --tags &> /dev/null 16 | echo $SEMVER_NEW_TAG 17 | else 18 | >&2 echo "No release level defined" 19 | fi 20 | 21 | exit 0 -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | 2 | Authors 3 | ======= 4 | 5 | * Moses Mugisha 6 | * Ray Besiga 7 | 8 | For [Sparkplug](http://sparkpl.ug) -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | Changelog 3 | ========= 4 | 5 | 0.1.0 (2018-10-29) 6 | ------------------ 7 | 8 | * First release on PyPI. 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First off, thank you for considering contributing to this Python MTN MoMo Library. It's people like you that make it such a great tool. Contributions are welcome, and they are greatly appreciated! 4 | 5 | ## Where do I go from here? 6 | 7 | If you've noticed a bug or have a question that doesn't belong on the 8 | [Spectrum](https://spectrum.chat/momo-api-developers/) or [Stack Overflow](https://stackoverflow.com/), [search the issue tracker](https://github.com/sparkplug/momoapi-python/issues) to see if 9 | someone else in the community has already created a ticket. If not, go ahead and 10 | [make one](https://github.com/sparkplug/momoapi-python/issues/new/choose)! 11 | 12 | 13 | 14 | ## Fork & create a branch 15 | 16 | If there is something you think you can fix, then fork the [repo](https://github.com/sparkplug/momoapi-python) and create a branch with a descriptive name. 17 | 18 | A good branch name would be (where issue #32 is the ticket you're working on): 19 | 20 | ```sh 21 | git checkout -b 32-add-swahili-translations 22 | ``` 23 | 24 | ## Get the test suite running 25 | 26 | This library has a comprehensive test suite, which can be run using the `tox` command: 27 | 28 | To view all test environments 29 | 30 | ```sh 31 | $ tox -l 32 | ``` 33 | To run the tests for Python 2.7 34 | 35 | ```sh 36 | $ tox -e py27-cover 37 | ``` 38 | 39 | To run the tests for Python 3.4 40 | 41 | ```sh 42 | $ tox -e py34-cover 43 | ``` 44 | 45 | To run a subset of tests:: 46 | 47 | ```sh 48 | tox -e envname -- pytest -k test_myfeature 49 | ``` 50 | 51 | To run all the test environments in *parallel*, you need to `pip install detox`: 52 | 53 | ```sh 54 | detox 55 | ``` 56 | 57 | ## Bugs and Fixes 58 | 59 | ### Did you find a bug? 60 | 61 | * **Ensure the bug was not already reported** by [searching all issues](https://github.com/sparkplug/momoapi-python/issues). 62 | 63 | * If you're unable to find an open issue addressing the problem, 64 | [open a new one](https://github.com/sparkplug/momoapi-python/issues/new/choose). Be sure to include a **title and clear 65 | description**, as much relevant information as possible, and a **code sample** 66 | or an **executable test case** demonstrating the expected behavior that is not 67 | occurring. 68 | 69 | * If possible, use the relevant bug report templates to create the issue. 70 | Make the necessary changes to demonstrate the issue, and **paste the content into the 71 | issue description** 72 | 73 | ### Implement your fix or feature 74 | 75 | At this point, you're ready to make your changes! Feel free to ask for help; 76 | everyone is a beginner at first :smile_cat: 77 | 78 | If you are proposing a feature: 79 | 80 | * Explain in detail how it would work. 81 | * Keep the scope as narrow as possible, to make it easier to implement. 82 | * Remember that this is a volunteer-driven project, and that code contributions are welcome :) 83 | 84 | If you would like to send us feedback, simply [file an issue](https://github.com/sparkplug/momoapi-python/issues/new/choose). 85 | 86 | ## Local Development 87 | 88 | To set up `python-momoapi` for local development: 89 | 90 | 1. Fork the repo. Look for the "Fork" button in the Github UI. 91 | 2. Clone your fork locally: 92 | 93 | ```sh 94 | git clone https://github.com/your_name_here/momoapi-python.git 95 | ``` 96 | 97 | 3. Create a branch for local development: 98 | ```sh 99 | git checkout -b name-of-your-bugfix-or-feature 100 | ``` 101 | 102 | Now you can make your changes locally. 103 | 104 | 4. When you're done making changes, run all the checks, doc builder and spell checker with `tox`. 105 | ```sh 106 | tox 107 | ``` 108 | Make sure Tox is installed by following the instructions [here](http://tox.readthedocs.io/en/latest/install.html) 109 | 110 | 5. Commit your changes and push your branch to GitHub:: 111 | 112 | ```sh 113 | git add . 114 | git commit -m "Your detailed description of your changes." 115 | git push origin name-of-your-bugfix-or-feature 116 | ``` 117 | 118 | 6. Submit a pull request through the GitHub website. 119 | 120 | ## Pull Request Guidelines 121 | 122 | ### Make a Pull Request 123 | 124 | At this point, you should switch back to your master branch and make sure it's 125 | up to date with `momoapi-python`'s master branch: 126 | 127 | ```sh 128 | git remote add upstream https://github.com/sparkplug/momoapi-python.git 129 | git checkout master 130 | git pull upstream master 131 | ``` 132 | 133 | Then update your feature branch from your local copy of master, and push it! 134 | 135 | ```sh 136 | git checkout 32-add-swahili-translations 137 | git rebase master 138 | git push --set-upstream origin 32-add-swahili-translations 139 | ``` 140 | 141 | Finally, go to GitHub and make a Pull Request :D 142 | 143 | TravisCI will run our test suite against all supported Python versions. We care 144 | about quality, so your PR won't be merged until all tests pass. It's unlikely, 145 | but it's possible that your changes pass tests in one Python version but fail in 146 | another. In that case, you'll have to setup your development environment to use your Python version, and investigate what's going on! 147 | 148 | ### Keeping your Pull Request updated 149 | 150 | If a maintainer asks you to "rebase" your PR, they're saying that a lot of code has changed, and that you need to update your branch so it's easier to merge. 151 | 152 | To learn more about rebasing in Git, there are a lot of [good](https://www.atlassian.com/git/tutorials/rewriting-history/git-rebase) [resources](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) but here's the suggested workflow: 153 | 154 | ```sh 155 | git checkout 32-add-swahili-translations 156 | git pull --rebase upstream master 157 | git push --force-with-lease 32-add-swahili-translations 158 | ``` 159 | 160 | ### Merging a PR (maintainers only) 161 | 162 | A PR can only be merged into master by a maintainer if: 163 | 164 | * It is passing CI. 165 | * It has been approved by at least one maintainers. If it was a maintainer who opened the PR, only one extra approval is needed. 166 | * It has no requested changes. 167 | * It is up to date with current master. 168 | 169 | Any maintainer is allowed to merge a PR if all of these conditions are met. 170 | 171 | ### Shipping a release (maintainers only) 172 | 173 | Maintainers need to do the following to push out a release: 174 | 175 | * Make sure all pull requests are in and that changelog is current 176 | * Update version and changelog with new version number using semver 177 | * If it's not a patch level release, create a stable branch for that release, 178 | otherwise switch to the stable branch corresponding to the patch release you 179 | want to ship: 180 | 181 | ```sh 182 | git checkout master 183 | git fetch momoapi-python 184 | git rebase momoapi-python/master 185 | # If the release is 2.1.x then this should be: 2-1-stable 186 | git checkout -b N-N-stable 187 | git push momoapi-python N-N-stable:N-N-stable 188 | ``` 189 | 190 | Before you make a Pull Request, make sure of the following: 191 | 192 | 1. Make sure your tests pass. Run `tox` beforehand. 193 | 2. Update documentation where necessary. 194 | 3. Note changes to `CHANGELOG.md`. 195 | 4. Add yourself to `AUTHORS.md`. 196 | 197 | ## Improvements 198 | 199 | This library could always use more documentation, whether as part of the official docs, in docstrings, or even in blog posts and articles. We look forward to add them to our RESOURCES file. 200 | 201 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Sparkplug 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | 2 | graft src 3 | graft ci 4 | graft tests 5 | 6 | 7 | include AUTHORS.md 8 | include CHANGELOG.md 9 | include CONTRIBUTING.md 10 | include LICENSE 11 | include README.md 12 | 13 | include tox.ini .travis.yml appveyor.yml 14 | 15 | global-exclude *.py[cod] __pycache__ *.so *.dylib 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MTN MoMo API Python Client 2 | 3 | Power your apps with our MTN MoMo API 4 | 5 |
12 | 13 | 14 | [](https://travis-ci.com/sparkplug/momoapi-node) 15 | [](https://badge.fury.io/js/mtn-momo) 16 | [](https://coveralls.io/github/sparkplug/momoapi-python?branch=master) 17 | [](https://spectrum.chat/momo-api-developers/) 18 | 19 | 20 | # Usage 21 | 22 | ## Installation 23 | 24 | Add the latest version of the library to your project using pip: 25 | 26 | ```bash 27 | $ pip install mtnmomo 28 | ``` 29 | 30 | This library supports Python 2.7+ or Python 3.4+ (PyPy supported) 31 | 32 | # Sandbox Environment 33 | 34 | ## Creating a sandbox environment API user 35 | 36 | Next, we need to get the `User ID` and `User Secret` and to do this we shall need to use the Primary Key for the Product to which we are subscribed, as well as specify a host. The library ships with a commandline application that helps to create sandbox credentials. It assumes you have created an account on `https://momodeveloper.mtn.com` and have your `Ocp-Apim-Subscription-Key`. 37 | 38 | ```bash 39 | ## within the project, on the command line. In this example, our domain is akabbo.ug 40 | $ mtnmomo 41 | $ providerCallBackHost: https://akabbo.ug 42 | $ Ocp-Apim-Subscription-Key: f83xx8d8xx6749f19a26e2265aeadbcdeg 43 | ``` 44 | 45 | The `providerCallBackHost` is your callback host and `Ocp-Apim-Subscription-Key` is your API key for the specific product to which you are subscribed. The `API Key` is unique to the product and you will need an `API Key` for each product you use. You should get a response similar to the following: 46 | 47 | ```bash 48 | Here is your User Id and API secret : {'apiKey': 'b0431db58a9b41faa8f5860230xxxxxx', 'UserId': '053c6dea-dd68-xxxx-xxxx-c830dac9f401'} 49 | ``` 50 | 51 | These are the credentials we shall use for the sandbox environment. In production, these credentials are provided for you on the MTN OVA management dashboard after KYC requirements are met. 52 | 53 | ## Configuration 54 | 55 | Before we can fully utilize the library, we need to specify global configurations. The global configuration must contain the following: 56 | 57 | * `BASE_URL`: An optional base url to the MTN Momo API. By default the staging base url will be used 58 | * `ENVIRONMENT`: Optional environment, either "sandbox" or "production". Default is 'sandbox' 59 | * `CALLBACK_HOST`: The domain where you webhooks urls are hosted. This is mandatory. 60 | 61 | Once you have specified the global variables, you can now provide the product-specific variables. Each MoMo API product requires its own authentication details i.e its own `Subscription Key`, `User ID` and `User Secret`, also sometimes refered to as the `API Secret`. As such, we have to configure subscription keys for each product you will be using. 62 | 63 | The full list of configuration options can be seen in the example below: 64 | 65 | ```python 66 | config = { 67 | "ENVIRONMENT": os.environ.get("ENVIRONMENT"), 68 | "BASE_URL": os.environ.get("BASE_URL"), 69 | "CALLBACK_HOST": os.environ.get("CALLBACK_HOST"), # Mandatory. 70 | "COLLECTION_PRIMARY_KEY": os.environ.get("COLLECTION_PRIMARY_KEY"), 71 | "COLLECTION_USER_ID": os.environ.get("COLLECTION_USER_ID"), 72 | "COLLECTION_API_SECRET": os.environ.get("COLLECTION_API_SECRET"), 73 | "REMITTANCE_USER_ID": os.environ.get("REMITTANCE_USER_ID"), 74 | "REMITTANCE_API_SECRET": os.environ.get("REMITTANCE_API_SECRET"), 75 | "REMITTANCE_PRIMARY_KEY": os.envieon.get("REMITTANCE_PRIMARY_KEY"), 76 | "DISBURSEMENT_USER_ID": os.environ.get("DISBURSEMENT_USER_ID"), 77 | "DISBURSEMENT_API_SECRET": os.environ.get("DISBURSEMENTS_API_SECRET"), 78 | "DISBURSEMENT_PRIMARY_KEY": os.environ.get("DISBURSEMENT_PRIMARY_KEY"), 79 | } 80 | ``` 81 | 82 | You will only need to configure the variables for the product(s) you will be using. 83 | 84 | ## Collections 85 | 86 | The collections client can be created with the following paramaters. Note that the `COLLECTION_USER_ID` and `COLLECTION_API_SECRET` for production are provided on the MTN OVA dashboard; 87 | 88 | * `COLLECTION_PRIMARY_KEY`: Primary Key for the `Collection` product on the developer portal. 89 | * `COLLECTION_USER_ID`: For sandbox, use the one generated with the `mtnmomo` command. 90 | * `COLLECTION_API_SECRET`: For sandbox, use the one generated with the `mtnmomo` command. 91 | 92 | You can create a collection client with the following: 93 | 94 | ```python 95 | import os 96 | from mtnmomo.collection import Collection 97 | 98 | client = Collection({ 99 | "COLLECTION_USER_ID": os.environ.get("COLLECTION_USER_ID"), 100 | "COLLECTION_API_SECRET": os.environ.get("COLLECTION_API_SECRET"), 101 | "COLLECTION_PRIMARY_KEY": os.environ.get("COLLECTION_PRIMARY_KEY"), 102 | }) 103 | ``` 104 | 105 | ### Methods 106 | 107 | 1. `requestToPay`: This operation is used to request a payment from a consumer (Payer). The payer will be asked to authorize the payment. The transaction is executed once the payer has authorized the payment. The transaction will be in status PENDING until it is authorized or declined by the payer or it is timed out by the system. Status of the transaction can be validated by using `getTransactionStatus`. 108 | 109 | 2. `getTransactionStatus`: Retrieve transaction information using the `transactionId` returned by `requestToPay`. You can invoke it at intervals until the transaction fails or succeeds. If the transaction has failed, it will throw an appropriate error. 110 | 111 | 3. `getBalance`: Get the balance of the account. 112 | 113 | 4. `isPayerActive`: check if an account holder is registered and active in the system. 114 | 115 | ### Sample Code 116 | 117 | ```python 118 | import os 119 | from mtnmomo.collection import Collection 120 | 121 | client = Collection({ 122 | "COLLECTION_USER_ID": os.environ.get("COLLECTION_USER_ID"), 123 | "COLLECTION_API_SECRET": os.environ.get("COLLECTION_API_SECRET"), 124 | "COLLECTION_PRIMARY_KEY": os.environ.get("COLLECTION_PRIMARY_KEY"), 125 | }) 126 | 127 | client.requestToPay( 128 | mobile="256772123456", amount="600", external_id="123456789", payee_note="dd", payer_message="dd", currency="EUR") 129 | ``` 130 | 131 | ## Disbursement 132 | 133 | The Disbursements client can be created with the following paramaters. Note that the `DISBURSEMENT_USER_ID` and `DISBURSEMENT_API_SECRET` for production are provided on the MTN OVA dashboard; 134 | 135 | * `DISBURSEMENT_PRIMARY_KEY`: Primary Key for the `Disbursement` product on the developer portal. 136 | * `DISBURSEMENT_USER_ID`: For sandbox, use the one generated with the `mtnmomo` command. 137 | * `DISBURSEMENT_API_SECRET`: For sandbox, use the one generated with the `mtnmomo` command. 138 | 139 | You can create a disbursements client with the following 140 | 141 | ```python 142 | import os 143 | from mtnmomo.disbursement import Disbursement 144 | 145 | client = Disbursement({ 146 | "DISBURSEMENT_USER_ID": os.environ.get("DISBURSEMENT_USER_ID"), 147 | "DISBURSEMENT_API_SECRET": os.environ.get("DISBURSEMENT_API_SECRET"), 148 | "DISBURSEMENT_PRIMARY_KEY": os.environ.get("DISBURSEMENT_PRIMARY_KEY"), 149 | }) 150 | ``` 151 | 152 | ### Methods 153 | 154 | 1. `transfer`: Used to transfer an amount from the owner’s account to a payee account. Status of the transaction can be validated by using the `getTransactionStatus` method. 155 | 156 | 2. `getTransactionStatus`: Retrieve transaction information using the `transactionId` returned by `transfer`. You can invoke it at intervals until the transaction fails or succeeds. 157 | 158 | 2. `getBalance`: Get your account balance. 159 | 160 | 3. `isPayerActive`: This method is used to check if an account holder is registered and active in the system. 161 | 162 | #### Sample Code 163 | 164 | ```python 165 | import os 166 | from mtnmomo.disbursement import Disbursement 167 | 168 | client = Disbursement({ 169 | "DISBURSEMENT_USER_ID": os.environ.get("DISBURSEMENT_USER_ID"), 170 | "DISBURSEMENT_API_SECRET": os.environ.get("DISBURSEMENT_API_SECRET"), 171 | "DISBURSEMENT_PRIMARY_KEY": os.environ.get("DISBURSEMENT_PRIMARY_KEY"), 172 | }) 173 | 174 | client.transfer(amount="600", mobile="256772123456", external_id="123456789", payee_note="dd", payer_message="dd", currency="EUR") 175 | 176 | ``` 177 | 178 | Thank you. 179 | -------------------------------------------------------------------------------- /RESOURCES.md: -------------------------------------------------------------------------------- 1 | # Resources 2 | 3 | Here, we keep a list of resources for use of the library 4 | 5 | ## Official Docs 6 | 7 | * [Developer Portal](https://momodeveloper.mtn.com/api-documentation) 8 | 9 | ## Articles 10 | 11 | 12 | -------------------------------------------------------------------------------- /ci/bootstrap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import absolute_import, print_function, unicode_literals 4 | 5 | import os 6 | import sys 7 | from os.path import abspath 8 | from os.path import dirname 9 | from os.path import exists 10 | from os.path import join 11 | 12 | 13 | if __name__ == "__main__": 14 | base_path = dirname(dirname(abspath(__file__))) 15 | print("Project path: {0}".format(base_path)) 16 | env_path = join(base_path, ".tox", "bootstrap") 17 | if sys.platform == "win32": 18 | bin_path = join(env_path, "Scripts") 19 | else: 20 | bin_path = join(env_path, "bin") 21 | if not exists(env_path): 22 | import subprocess 23 | 24 | print("Making bootstrap env in: {0} ...".format(env_path)) 25 | try: 26 | subprocess.check_call(["virtualenv", env_path]) 27 | except subprocess.CalledProcessError: 28 | subprocess.check_call([sys.executable, "-m", "virtualenv", env_path]) 29 | print("Installing `jinja2` into bootstrap environment...") 30 | subprocess.check_call([join(bin_path, "pip"), "install", "jinja2"]) 31 | python_executable = join(bin_path, "python") 32 | if not os.path.samefile(python_executable, sys.executable): 33 | print("Re-executing with: {0}".format(python_executable)) 34 | os.execv(python_executable, [python_executable, __file__]) 35 | 36 | import jinja2 37 | 38 | import matrix 39 | 40 | jinja = jinja2.Environment( 41 | loader=jinja2.FileSystemLoader(join(base_path, "ci", "templates")), 42 | trim_blocks=True, 43 | lstrip_blocks=True, 44 | keep_trailing_newline=True 45 | ) 46 | 47 | tox_environments = {} 48 | for (alias, conf) in matrix.from_file(join(base_path, "setup.cfg")).items(): 49 | python = conf["python_versions"] 50 | deps = conf["dependencies"] 51 | tox_environments[alias] = { 52 | "deps": deps.split(), 53 | } 54 | if "coverage_flags" in conf: 55 | cover = {"false": False, "true": True}[conf["coverage_flags"].lower()] 56 | tox_environments[alias].update(cover=cover) 57 | if "environment_variables" in conf: 58 | env_vars = conf["environment_variables"] 59 | tox_environments[alias].update(env_vars=env_vars.split()) 60 | 61 | for name in os.listdir(join("ci", "templates")): 62 | with open(join(base_path, name), "w") as fh: 63 | fh.write(jinja.get_template(name).render(tox_environments=tox_environments)) 64 | print("Wrote {}".format(name)) 65 | print("DONE.") 66 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | 5 | [flake8] 6 | max-line-length = 140 7 | exclude = */migrations/* 8 | 9 | [tool:pytest] 10 | pep8maxlinelength = 80 11 | norecursedirs = 12 | .git 13 | .tox 14 | .env 15 | dist 16 | build 17 | migrations 18 | 19 | python_files = 20 | test_*.py 21 | *_test.py 22 | tests.py 23 | addopts = 24 | -ra 25 | --strict 26 | --ignore=setup.py 27 | --ignore=ci 28 | --ignore=.eggs 29 | --doctest-modules 30 | --doctest-glob=\*.rst 31 | --tb=short 32 | 33 | 34 | [isort] 35 | force_single_line = True 36 | line_length = 120 37 | known_first_party = momoapi_python 38 | default_section = THIRDPARTY 39 | forced_separate = test_momoapi_python 40 | not_skip = __init__.py 41 | skip = migrations 42 | 43 | [matrix] 44 | # This is the configuration for the `./bootstrap.py` script. 45 | # It generates `.travis.yml`, `tox.ini` and `appveyor.yml`. 46 | # 47 | # Syntax: [alias:] value [!variable[glob]] [&variable[glob]] 48 | # 49 | # alias: 50 | # - is used to generate the tox environment 51 | # - it's optional 52 | # - if not present the alias will be computed from the `value` 53 | # value: 54 | # - a value of "-" means empty 55 | # !variable[glob]: 56 | # - exclude the combination of the current `value` with 57 | # any value matching the `glob` in `variable` 58 | # - can use as many you want 59 | # &variable[glob]: 60 | # - only include the combination of the current `value` 61 | # when there's a value matching `glob` in `variable` 62 | # - can use as many you want 63 | 64 | python_versions = 65 | py27 66 | py34 67 | py35 68 | py36 69 | py37 70 | pypy 71 | pypy3 72 | 73 | dependencies = 74 | # 1.4: Django==1.4.16 !python_versions[py3*] 75 | # 1.5: Django==1.5.11 76 | # 1.6: Django==1.6.8 77 | # 1.7: Django==1.7.1 !python_versions[py26] 78 | # Deps commented above are provided as examples. That's what you would use in a Django project. 79 | 80 | coverage_flags = 81 | cover: true 82 | nocov: false 83 | 84 | [aliases] 85 | test=pytest 86 | 87 | environment_variables = 88 | - 89 | 90 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | from __future__ import absolute_import 4 | from __future__ import print_function 5 | 6 | import io 7 | import re 8 | from glob import glob 9 | from os.path import basename 10 | from os.path import dirname 11 | from os.path import join 12 | from os.path import splitext 13 | 14 | from setuptools import find_packages 15 | from setuptools import setup 16 | 17 | 18 | def read(*names, **kwargs): 19 | with io.open( 20 | join(dirname(__file__), *names), 21 | encoding=kwargs.get('encoding', 'utf8') 22 | ) as fh: 23 | return fh.read() 24 | 25 | 26 | setup( 27 | name='mtnmomo', 28 | version='3.0.1', 29 | license='MIT license', 30 | description='Python wrapper for the MTN MoMo API.', 31 | long_description='%s\n%s' % ( 32 | re.compile('^.. start-badges.*^.. end-badges', re.M | re.S).sub('', read('README.md')), 33 | re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.md')) 34 | ), 35 | long_description_content_type='text/markdown', 36 | author='Sparkplug', 37 | author_email='mugisha@sparkpl.ug', 38 | url='https://github.com/sparkplug/momoapi-python', 39 | packages=find_packages('src'), 40 | package_dir={'': 'src'}, 41 | py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], 42 | include_package_data=True, 43 | zip_safe=False, 44 | classifiers=[ 45 | # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers 46 | 'Development Status :: 5 - Production/Stable', 47 | 'Intended Audience :: Developers', 48 | 'License :: OSI Approved :: MIT License', 49 | 'Operating System :: Unix', 50 | 'Operating System :: POSIX', 51 | 'Operating System :: Microsoft :: Windows', 52 | 'Programming Language :: Python', 53 | 'Programming Language :: Python :: 2.7', 54 | 'Programming Language :: Python :: 3', 55 | 'Programming Language :: Python :: 3.4', 56 | 'Programming Language :: Python :: 3.5', 57 | 'Programming Language :: Python :: 3.6', 58 | 'Programming Language :: Python :: 3.7', 59 | 'Programming Language :: Python :: Implementation :: CPython', 60 | 'Programming Language :: Python :: Implementation :: PyPy', 61 | # uncomment if you test on these interpreters: 62 | # 'Programming Language :: Python :: Implementation :: IronPython', 63 | # 'Programming Language :: Python :: Implementation :: Jython', 64 | # 'Programming Language :: Python :: Implementation :: Stackless', 65 | 'Topic :: Utilities', 66 | ], 67 | keywords=[ 68 | 'MoMo API', 'MoMo API Python Wrapper', 'MoMo API Python', 69 | 70 | 71 | 72 | ], 73 | install_requires=[ 74 | 'requests == 2.21.0', 75 | 'Click==7.0', 76 | 'phonenumbers' 77 | 78 | # eg: 'aspectlib==1.1.1', 'six>=1.7', 79 | ], 80 | 81 | setup_requires=["pytest-runner", "pytest-cov"], 82 | 83 | 84 | extras_require={'test': ['pytest', 'pytest-watch', 'tox', 85 | 'pytest-cov', 86 | 'pytest-pep8', 87 | 'pytest-cov', 88 | 'pytest-sugar', 89 | 'mock', 90 | 'pytest-instafail', 91 | 'pytest-bdd'], "dev": ["semver"]}, 92 | entry_points={ 93 | 'console_scripts': [ 94 | 'mtnmomo = mtnmomo.cli:main', 95 | ] 96 | }, 97 | ) 98 | -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # IPython 77 | profile_default/ 78 | ipython_config.py 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # Environments 90 | .env 91 | .venv 92 | env/ 93 | venv/ 94 | ENV/ 95 | env.bak/ 96 | venv.bak/ 97 | 98 | # Spyder project settings 99 | .spyderproject 100 | .spyproject 101 | 102 | # Rope project settings 103 | .ropeproject 104 | 105 | # mkdocs documentation 106 | /site 107 | 108 | # mypy 109 | .mypy_cache/ 110 | .dmypy.json 111 | dmypy.json 112 | 113 | # Pyre type checker 114 | .pyre/ 115 | -------------------------------------------------------------------------------- /src/mtnmomo/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.0' 2 | -------------------------------------------------------------------------------- /src/mtnmomo/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Entrypoint module, in case you use `python -mtnmomo_python`. 3 | 4 | 5 | Why does this file exist, and why __main__? For more info, read: 6 | 7 | - https://www.python.org/dev/peps/pep-0338/ 8 | - https://docs.python.org/2/using/cmdline.html#cmdoption-m 9 | - https://docs.python.org/3/using/cmdline.html#cmdoption-m 10 | """ 11 | from mtnmomo.cli import main 12 | 13 | if __name__ == "__main__": 14 | main() 15 | -------------------------------------------------------------------------------- /src/mtnmomo/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module that contains the command line app. 3 | 4 | Why does this file exist, and why not put this in __main__? 5 | 6 | You might be tempted to import things from __main__ later, but that will cause 7 | problems: the code will get executed twice: 8 | 9 | - When you run `python -mmomoapi_python` python will execute 10 | ``__main__.py`` as a script. That means there won't be any 11 | ``momoapi_python.__main__`` in ``sys.modules``. 12 | - When you import __main__ it will get executed again (as a module) because 13 | there's no ``momoapi_python.__main__`` in ``sys.modules``. 14 | 15 | Also see (1) from http://click.pocoo.org/5/setuptools/#setuptools-integration 16 | """ 17 | import json 18 | import time 19 | import uuid 20 | 21 | import click 22 | import requests 23 | 24 | 25 | def generateToken(host, key): 26 | data = {"providerCallbackHost": host} 27 | token = str(uuid.uuid4()) 28 | headers = { 29 | "X-Reference-Id": token, 30 | "Content-Type": "application/json", 31 | "Ocp-Apim-Subscription-Key": key 32 | } 33 | 34 | requests.post( 35 | "https://ericssonbasicapi2.azure-api.net/v1_0/apiuser", 36 | data=json.dumps(data), 37 | headers=headers) 38 | time.sleep(5) 39 | 40 | del headers["X-Reference-Id"] 41 | url = "https://ericssonbasicapi2.azure-api.net/v1_0/apiuser/{0}/apikey".format( 42 | token) 43 | 44 | res = requests.post(url, data=json.dumps({}), headers=headers) 45 | 46 | rr = res.json() 47 | ret = {} 48 | if not (200 <= res.status_code < 300): 49 | return rr 50 | ret["UserId"] = token 51 | ret["APISecret"] = rr["apiKey"] 52 | 53 | return "Here is your User Id and API secret : {0}".format(ret) 54 | 55 | 56 | @click.command() 57 | @click.option( 58 | '--provider', 59 | prompt="providerCallBackHost", 60 | help='providerCallBackHost') 61 | @click.option( 62 | '--key', 63 | prompt="Ocp-Apim-Subscription-Key", 64 | help='Ocp-Apim-Subscription-Key') 65 | def main(provider, key): 66 | click.echo(generateToken(provider, key)) 67 | -------------------------------------------------------------------------------- /src/mtnmomo/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base implementation of the MTN API client 3 | 4 | @author: Moses Mugisha 5 | """ 6 | import json 7 | try: 8 | from json.decoder import JSONDecodeError 9 | except ImportError: 10 | JSONDecodeError = ValueError 11 | 12 | import requests 13 | from requests import Request, Session 14 | from requests._internal_utils import to_native_string 15 | from requests.auth import AuthBase 16 | from requests.auth import HTTPBasicAuth 17 | 18 | 19 | from .config import MomoConfig 20 | from .errors import APIError 21 | from .utils import requests_retry_session 22 | 23 | 24 | class Response: 25 | 26 | def __init__(self, body, code, headers): 27 | self.body = body 28 | self.code = code 29 | self.headers = headers 30 | self.data = body 31 | 32 | 33 | class MoMoAuth(AuthBase): 34 | """Attaches Authentication to the given Request object.""" 35 | 36 | def __init__(self, token): 37 | 38 | self.token = token 39 | 40 | def __call__(self, r): 41 | # modify and return the request 42 | 43 | r.headers['Authorization'] = "Bearer " + to_native_string(self.token) 44 | return r 45 | 46 | 47 | class ClientInterface(): 48 | def getAuthToken(self): 49 | raise NotImplementedError 50 | 51 | def getBalance(self): 52 | raise NotImplementedError 53 | 54 | def getTransactionStatus(self): 55 | raise NotImplementedError 56 | 57 | 58 | class Client(ClientInterface): 59 | def getAuthToken(self): 60 | return super(Client, self).getAuthToken() 61 | 62 | def getBalance(self): 63 | return super(Client, self).getBalance() 64 | 65 | def getTransactionStatus(self): 66 | return super(Client, self).getTransactionStatus() 67 | 68 | 69 | class MomoApi(ClientInterface, object): 70 | 71 | def __init__( 72 | self, 73 | config, 74 | ** kwargs): 75 | super(MomoApi, self).__init__(**kwargs) 76 | self._session = Session() 77 | self._config = MomoConfig(config) 78 | 79 | @property 80 | def config(self): 81 | return self._config 82 | 83 | def request(self, method, url, headers, post_data=None): 84 | self.authToken = self.getAuthToken().json()["access_token"] 85 | request = Request( 86 | method, 87 | url, 88 | data=json.dumps(post_data), 89 | headers=headers, 90 | auth=MoMoAuth(self.authToken)) 91 | 92 | prepped = self._session.prepare_request(request) 93 | 94 | resp = requests_retry_session(sesssion=self._session).send(prepped, 95 | verify=False 96 | ) 97 | return self.interpret_response(resp) 98 | 99 | def interpret_response(self, resp): 100 | rcode = resp.status_code 101 | rheaders = resp.headers 102 | 103 | try: 104 | rbody = resp.json() 105 | except JSONDecodeError: 106 | rbody = resp.text 107 | resp = Response(rbody, rcode, rheaders) 108 | 109 | if not (200 <= rcode < 300): 110 | self.handle_error_response(rbody, rcode, resp.text, rheaders) 111 | 112 | return resp 113 | 114 | def handle_error_response(self, rbody, rcode, resp, rheaders): 115 | 116 | raise APIError( 117 | "Invalid response object from API: {0} (HTTP response code " 118 | "was {1})".format(rbody, rcode), 119 | rbody, rcode, resp) 120 | 121 | def request_headers(self, api_key, method): 122 | headers = {} 123 | 124 | return headers 125 | 126 | def getAuthToken(self, product, url, subscription_key): 127 | data = json.dumps({}) 128 | headers = { 129 | "Content-Type": "application/json", 130 | "Ocp-Apim-Subscription-Key": subscription_key 131 | } 132 | response = requests.post( 133 | 134 | "{0}{1}".format(self.config.baseUrl, url), 135 | auth=HTTPBasicAuth( 136 | self.config.userId(product), 137 | self.config.APISecret(product)), 138 | data=data, 139 | headers=headers) 140 | return response 141 | 142 | def getBalance(self, url, subscription_key): 143 | headers = { 144 | "X-Target-Environment": self.config.environment, 145 | "Content-Type": "application/json", 146 | "Ocp-Apim-Subscription-Key": subscription_key 147 | } 148 | url = "{0}{1}".format(self.config.baseUrl, url) 149 | res = self.request("GET", url, headers) 150 | return res.json() 151 | 152 | def getTransactionStatus( 153 | self, 154 | transaction_id, 155 | url, 156 | subscription_key, 157 | ** kwargs): 158 | 159 | headers = { 160 | "X-Target-Environment": self.config.environment, 161 | "Content-Type": "application/json", 162 | "Ocp-Apim-Subscription-Key": subscription_key 163 | } 164 | _url = self.config.baseUrl + url + transaction_id 165 | res = self.request("GET", _url, headers) 166 | return res.json() 167 | 168 | @classmethod 169 | def generateToken( 170 | cls, 171 | host, 172 | api_user, 173 | api_key, 174 | base_url, 175 | environment="sandbox", 176 | **kwargs): 177 | data = {"providerCallbackHost": host} 178 | 179 | headers = { 180 | "Content-Type": "application/json", 181 | "Ocp-Apim-Subscription-Key": api_key, 182 | "X-Target-Environment": environment, 183 | } 184 | 185 | url = base_url + "/v1_0/apiuser/{0}/apikey".format(api_user) 186 | 187 | res = requests.post(url, data=json.dumps(data), headers=headers) 188 | 189 | return res.json() 190 | 191 | def close(self): 192 | if self._session is not None: 193 | self._session.close() 194 | -------------------------------------------------------------------------------- /src/mtnmomo/collection.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from .client import MomoApi 4 | from .utils import validate_phone_number 5 | 6 | 7 | class Collection(MomoApi, object): 8 | def getAuthToken(self): 9 | """ 10 | Create an access token which can then be used 11 | to authorize and authenticate towards the other end-points of the API 12 | """ 13 | url = "/collection/token/" 14 | response = super(Collection, self).getAuthToken( 15 | "COLLECTION", url, super(Collection, self).config.collectionsKey) 16 | return response 17 | 18 | def getBalance(self): 19 | url = "/collection/v1_0/account/balance" 20 | 21 | return super(Collection, self).getBalance(url, super(Collection, self).config.collectionsKey) 22 | 23 | def getTransactionStatus( 24 | self, 25 | transaction_id, 26 | **kwargs): 27 | url = "/collection/v1_0/requesttopay/" 28 | 29 | return super(Collection, self).getTransactionStatus( 30 | transaction_id, url, super(Collection, self).config.collectionsKey) 31 | 32 | def requestToPay( 33 | self, 34 | mobile, 35 | amount, 36 | external_id, 37 | payee_note="", 38 | payer_message="", 39 | currency="EUR", 40 | **kwargs): 41 | # type: (String,String,String,String,String,String,String) -> json 42 | ref = str(uuid.uuid4()) 43 | data = { 44 | "payer": { 45 | "partyIdType": "MSISDN", 46 | "partyId": validate_phone_number(mobile)}, 47 | "payeeNote": payee_note, 48 | "payerMessage": payer_message, 49 | "externalId": external_id, 50 | "currency": currency, 51 | "amount": str(amount)} 52 | headers = { 53 | "X-Target-Environment": super(Collection, self).config.environment, 54 | "Content-Type": "application/json", 55 | "X-Reference-Id": ref, 56 | "Ocp-Apim-Subscription-Key": super(Collection, self).config.collectionsKey 57 | 58 | 59 | } 60 | if kwargs.get("callback_url"): 61 | headers["X-Callback-Url"] = kwargs.get("callback_url") 62 | url = "{0}/collection/v1_0/requesttopay".format(super(Collection, self).config.baseUrl) 63 | self.request("POST", url, headers, data) 64 | return {"transaction_ref": ref} 65 | -------------------------------------------------------------------------------- /src/mtnmomo/config.py: -------------------------------------------------------------------------------- 1 | from .utils import validate_uuid 2 | from .errors import ConfigurationError 3 | 4 | 5 | class MomoConfig(object): 6 | 7 | def __init__(self, conf): 8 | """ 9 | 10 | config={ 11 | 12 | ENVIRONMENT: os.environ.get("ENVIRONMENT"), 13 | BASE_URL: os.environ.get("BASE_URL"), 14 | CALLBACK_HOST: os.environ.get("CALLBACK_HOST"), 15 | COLLECTION_PRIMARY_KEY: os.environ.get("COLLECTION_PRIMARY_KEY"), 16 | COLLECTION_USER_ID: os.environ.get("COLLECTION_USER_ID"), 17 | COLLECTION_API_SECRET: os.environ.get("COLLECTION_API_SECRET"), 18 | 19 | REMITTANCE_USER_ID: os.environ.get("REMITTANCE_USER_ID"), 20 | REMITTANCE_API_SECRET: os.environ.get("REMITTANCE_API_SECRET"), 21 | REMITTANCE_PRIMARY_KEY: os.environ.get("REMITTANCE_PRIMARY_KEY") 22 | 23 | DISBURSEMENT_USER_ID: os.environ.get("DISBURSEMENT_USER_ID"), 24 | DISBURSEMENT_API_SECRET: os.environ.get("DISBURSEMENTS_API_SECRET"), 25 | DISBURSEMENT_PRIMARY_KEY: os.environ.get("DISBURSEMENT_PRIMARY_KEY"), 26 | 27 | 28 | } 29 | 30 | 31 | """ 32 | self._config = conf 33 | 34 | def get_property(self, property_name): 35 | if property_name not in self._config.keys(): 36 | return None 37 | return self._config[property_name] 38 | 39 | def userId(self, product): 40 | key = self.get_property('{0}_USER_ID'.format(product)) 41 | 42 | if not key: 43 | raise ConfigurationError( 44 | "{0}_USER_ID is missing in the configuration".format(product)) 45 | else: 46 | return validate_uuid(key) 47 | 48 | def APISecret(self, product): 49 | key = self.get_property('{0}_API_SECRET'.format(product)) 50 | 51 | if not key: 52 | raise ConfigurationError( 53 | "{0}_API_SECRET is missing in the configuration".format(product)) 54 | else: 55 | return key 56 | 57 | @property 58 | def environment(self): 59 | return self.get_property('ENVIRONMENT') or "sandbox" 60 | 61 | @property 62 | def baseUrl(self): 63 | return self.get_property( 64 | 'BASE_URL') or "https://ericssonbasicapi2.azure-api.net" 65 | 66 | @property 67 | def callbackHost(self): 68 | key = self.get_property('CALLBACK_HOST') 69 | if not key: 70 | raise ConfigurationError( 71 | "CALLBACK_HOST is missing in the configuration") 72 | else: 73 | return key 74 | 75 | @property 76 | def collectionsKey(self): 77 | key = self.get_property('COLLECTION_PRIMARY_KEY') 78 | if not key: 79 | raise ConfigurationError( 80 | "COLLECTION_PRIMARY_KEY is missing in the configuration") 81 | else: 82 | return validate_uuid(key) 83 | 84 | @property 85 | def disbursementsKey(self): 86 | key = self.get_property('DISBURSEMENT_PRIMARY_KEY') 87 | if not key: 88 | raise ConfigurationError( 89 | "DISBURSEMENT_PRIMARY_KEY is missing in the configuration") 90 | else: 91 | return validate_uuid(key) 92 | 93 | @property 94 | def remittencesKey(self): 95 | key = self.get_property('REMITTANCE_PRIMARY_KEY') 96 | if not key: 97 | raise ConfigurationError( 98 | "REMITTANCE_PRIMARY_KEY is missing in the configuration") 99 | else: 100 | return validate_uuid(key) 101 | -------------------------------------------------------------------------------- /src/mtnmomo/disbursement.py: -------------------------------------------------------------------------------- 1 | from .client import MomoApi 2 | import uuid 3 | from .utils import validate_phone_number 4 | 5 | 6 | class Disbursement(MomoApi, object): 7 | 8 | def getAuthToken(self): 9 | """ 10 | Create an access token which can then be used to authorize and authenticate towards the other end-points of the API. 11 | """ 12 | url = "/disbursement/token/" 13 | response = super(Disbursement, self).getAuthToken( 14 | "DISBURSEMENT", url, super(Disbursement, self).config.disbursementsKey) 15 | return response 16 | 17 | def getBalance(self): 18 | url = "/disbursement/v1_0/account/balance" 19 | 20 | return super(Disbursement, self).getBalance(url, super(Disbursement, self).config.disbursementsKey) 21 | 22 | def getTransactionStatus( 23 | self, 24 | transaction_id, 25 | **kwargs): 26 | url = "/disbursement/v1_0/transfer/" 27 | 28 | return super(Disbursement, self).getTransactionStatus( 29 | transaction_id, url, super(Disbursement, self).config.disbursementsKey) 30 | 31 | def transfer( 32 | self, 33 | amount, 34 | mobile, 35 | external_id, 36 | payee_note="", 37 | payer_message="", 38 | currency="EUR", 39 | **kwargs): 40 | ref = str(uuid.uuid4()) 41 | data = { 42 | "amount": str(amount), 43 | "currency": currency, 44 | "externalId": external_id, 45 | "payee": { 46 | "partyIdType": "MSISDN", 47 | "partyId": validate_phone_number(mobile) 48 | }, 49 | "payerMessage": payer_message, 50 | "payeeNote": payee_note 51 | } 52 | headers = { 53 | "X-Target-Environment": super(Disbursement, self).config.environment, 54 | "Content-Type": "application/json", 55 | "Ocp-Apim-Subscription-Key": super(Disbursement, self).config.disbursementsKey, 56 | "X-Reference-Id": ref, 57 | } 58 | if kwargs.get("callback_url"): 59 | headers["X-Callback-Url"] = kwargs.get("callback_url") 60 | url = super(Disbursement, self).config.baseUrl + "/disbursement/v1_0/transfer" 61 | self.request("POST", url, headers, data) 62 | return {"transaction_ref": ref} 63 | -------------------------------------------------------------------------------- /src/mtnmomo/errors.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class MomoError(Exception): 4 | def __init__(self, message=None, http_body=None, http_status=None, 5 | json_body=None, headers=None, code=None): 6 | super(MomoError, self).__init__(message) 7 | 8 | if http_body and hasattr(http_body, 'decode'): 9 | try: 10 | http_body = http_body.decode('utf-8') 11 | except BaseException: 12 | http_body = ('