├── .gitattributes ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── pull_request_template.md ├── .gitignore ├── .npmignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── examples ├── collections.js └── disbursements.js ├── package-lock.json ├── package.json ├── release.config.js ├── src ├── auth.ts ├── cli.ts ├── client.ts ├── collections.ts ├── common.ts ├── disbursements.ts ├── errors.ts ├── index.ts ├── users.ts └── validate.ts ├── test ├── auth.test.ts ├── chai.ts ├── client.test.ts ├── collections.test.ts ├── disbursements.test.ts ├── errors.test.ts ├── index.test.ts ├── mocha.opts ├── mock.ts ├── users.test.ts └── validate.test.ts ├── tsconfig.json └── tslint.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.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 | # build output 2 | dist/ 3 | lib 4 | 5 | # dependencies 6 | node_modules/ 7 | yarn-error.log 8 | npm-debug* 9 | 10 | # logs 11 | *.log 12 | .env 13 | # coverage 14 | .nyc_output 15 | coverage 16 | 17 | # test output 18 | test/**/out 19 | .DS_Store 20 | 21 | #editors 22 | .idea 23 | .vscode 24 | 25 | #secrets 26 | *.pem 27 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # source code 2 | src/ 3 | examples/ 4 | 5 | # dependencies 6 | node_modules/ 7 | yarn-error.log 8 | npm-debug* 9 | 10 | # logs 11 | *.log 12 | .env 13 | 14 | # coverage 15 | .nyc_output 16 | coverage 17 | 18 | # test output 19 | test/**/out 20 | .DS_Store 21 | 22 | #editors 23 | .idea 24 | .vscode 25 | 26 | #secrets 27 | *.pem 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 10 4 | 5 | install: 6 | - npm ci 7 | 8 | script: 9 | - npm run lint 10 | - npm run test 11 | - cat ./coverage/lcov.info | npx coveralls 12 | - npm run compile 13 | 14 | deploy: 15 | provider: script 16 | skip_cleanup: true 17 | script: 18 | - npx semantic-release 19 | on: 20 | tags: false 21 | repo: sparkplug/momoapi-node 22 | branch: master 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Contributing Guidelines 2 | 3 | 1. Fork this repo. Please be sure to use the current _master_ branch as your starting point: 4 | 5 | ```bash 6 | 7 | https://github.com/sparkplug/momoapi-node 8 | 9 | ``` 10 | 11 | 2. You'll be redirected to: 12 | 13 | ```bash 14 | 15 | https://github.com/your-username/momoapi-node 16 | 17 | ``` 18 | 19 | 3. Clone the repository: 20 | 21 | ```bash 22 | 23 | git clone https://github.com/your-username/momoapi-node.git 24 | 25 | ``` 26 | 27 | 4. Install the project dependencies: 28 | 29 | ```bash 30 | 31 | npm install 32 | 33 | ``` 34 | 35 | or 36 | 37 | ``` 38 | 39 | yarn 40 | 41 | ``` 42 | 43 | 5. Open in the text editor of your choice 44 | 45 | 6. Create a New Branch from _master_: Please make the branch name descriptive. 46 | 47 | ```bash 48 | 49 | cd momoapi-node 50 | git branch new-branch 51 | git checkout new-branch 52 | 53 | ``` 54 | 55 | 7. Make your edits locally: 56 | 57 | ```bash 58 | 59 | git add -A 60 | 61 | ``` 62 | 63 | 8. Commit the changes: 64 | 65 | ```bash 66 | 67 | git commit -m "Commit Message Here" 68 | 69 | ``` 70 | 71 | 9. Submit a pull request: 72 | 73 | ```bash 74 | 75 | git push --set-upstream origin new-branch-name 76 | 77 | ``` 78 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MTN MoMo API NodeJS Client 2 | 3 | Power your apps with our MTN MoMo API 4 | 5 |
6 | Join our active, engaged community:
7 | Website 8 | | 9 | Spectrum 10 |

11 |
12 | 13 | 14 | [![Build Status](https://travis-ci.com/sparkplug/momoapi-node.svg?branch=master)](https://travis-ci.com/sparkplug/momoapi-node) 15 | [![NPM Version](https://badge.fury.io/js/mtn-momo.svg)](https://badge.fury.io/js/mtn-momo) 16 | ![Installs](https://img.shields.io/npm/dt/mtn-momo.svg) 17 | [![Known Vulnerabilities](https://snyk.io/test/npm/mtn-momo/badge.svg)](https://snyk.io/test/npm/mtn-momo) 18 | [![Coverage Status](https://coveralls.io/repos/github/sparkplug/momoapi-node/badge.svg?branch=master)](https://coveralls.io/github/sparkplug/momoapi-node?branch=master) 19 | [![Join the community on Spectrum](https://withspectrum.github.io/badge/badge.svg)](https://spectrum.chat/momo-api-developers/) 20 | 21 | 22 | ## Usage 23 | 24 | ### Installation 25 | 26 | Add the library to your project 27 | 28 | ```sh 29 | npm install --save mtn-momo 30 | ``` 31 | 32 | ## Sandbox Credentials 33 | 34 | 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`. We run the `momo-sandbox` as below. 35 | 36 | ```sh 37 | ## Within a project 38 | npx momo-sandbox --host example.com --primary-key 23e2r2er2342blahblah 39 | ``` 40 | 41 | If all goes well, it will print the credentials on the terminal; 42 | 43 | ```sh 44 | Momo Sandbox Credentials { 45 | "userSecret": "b2e23bf4e3984a16a55dbfc2d45f66b0", 46 | "userId": "8ecc7cf3-0db8-4013-9c7b-da4894460041" 47 | } 48 | ``` 49 | 50 | 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. 51 | 52 | ## Configuration 53 | 54 | Before we can fully utilize the library, we need to specify global configurations. The global configuration must contain the following: 55 | 56 | - `baseUrl`: An optional base url to the MTN Momo API. By default the staging base url will be used 57 | - `environment`: Optional enviroment, either "sandbox" or "production". Default is 'sandbox' 58 | - `callbackHost`: The domain where you webhooks urls are hosted. This is mandatory. 59 | 60 | As an example, you might configure the library like this: 61 | 62 | ```js 63 | const momo = require("mtn-momo"); 64 | 65 | const { Collections, Disbursements } = momo.create({ 66 | callbackHost: process.env.CALLBACK_HOST 67 | }); 68 | ``` 69 | 70 | ## Collections 71 | 72 | The collections client can be created with the following paramaters. Note that the `userId` and `userSecret` for production are provided on the MTN OVA dashboard; 73 | 74 | - `primaryKey`: Primary Key for the `Collections` product. 75 | - `userId`: For sandbox, use the one generated with the `momo-sandbox` command. 76 | - `userSecret`: For sandbox, use the one generated with the `momo-sandbox` command. 77 | 78 | You can create a collections client with the following 79 | 80 | ```js 81 | const collections = Collections({ 82 | userSecret: process.env.USER_SECRET, 83 | userId: process.env.USER_ID, 84 | primaryKey: process.env.PRIMARY_KEY 85 | }); 86 | ``` 87 | 88 | #### Methods 89 | 90 | 1. `requestToPay(request: PaymentRequest): Promise`: 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 `getTransaction` 91 | 92 | 2. `getTransaction(transactionId: string): Promise`: 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. The error will be a subclass of `MtnMoMoError`. Check [`src/error.ts`](https://github.com/sparkplug/momoapi-node/blob/master/src/errors.ts) for the various errors that can be thrown 93 | 94 | 3. `getBalance(): Promise`: Get the balance of the account. 95 | 96 | 4. `isPayerActive(id: string, type: PartyIdType = "MSISDN"): Promise`: check if an account holder is registered and active in the system. 97 | 98 | #### Sample Code 99 | 100 | ```js 101 | const momo = require("mtn-momo"); 102 | 103 | const { Collections } = momo.create({ 104 | callbackHost: process.env.CALLBACK_HOST 105 | }); 106 | 107 | const collections = Collections({ 108 | userSecret: process.env.COLLECTIONS_USER_SECRET, 109 | userId: process.env.COLLECTIONS_USER_ID, 110 | primaryKey: process.env.COLLECTIONS_PRIMARY_KEY 111 | }); 112 | 113 | // Request to pay 114 | collections 115 | .requestToPay({ 116 | amount: "50", 117 | currency: "EUR", 118 | externalId: "123456", 119 | payer: { 120 | partyIdType: "MSISDN", 121 | partyId: "256774290781" 122 | }, 123 | payerMessage: "testing", 124 | payeeNote: "hello" 125 | }) 126 | .then(transactionId => { 127 | console.log({ transactionId }); 128 | 129 | // Get transaction status 130 | return collections.getTransaction(transactionId); 131 | }) 132 | .then(transaction => { 133 | console.log({ transaction }); 134 | 135 | // Get account balance 136 | return collections.getBalance(); 137 | }) 138 | .then(accountBalance => console.log({ accountBalance })) 139 | .catch(error => { 140 | console.log(error); 141 | }); 142 | ``` 143 | 144 | ## Disbursement 145 | 146 | The disbursements client can be created with the following paramaters. Note that the `userId` and `userSecret` for production are provided on the MTN OVA dashboard; 147 | 148 | - `primaryKey`: Primary Key for the `Disbursements` product. 149 | - `userId`: For sandbox, use the one generated with the `momo-sandbox` command. 150 | - `userSecret`: For sandbox, use the one generated with the `momo-sandbox` command. 151 | 152 | You can create a disbursements client with the following 153 | 154 | ```js 155 | const disbursements = Disbursements({ 156 | userSecret: process.env.DISBURSEMENTS_USER_SECRET, 157 | userId: process.env.DISBURSEMENTS_USER_ID, 158 | primaryKey: process.env.DISBURSEMENTS_PRIMARY_KEY 159 | }); 160 | ``` 161 | 162 | #### Methods 163 | 164 | 1. `transfer(request: TransferRequest): Promise` 165 | 166 | Used to transfer an amount from the owner’s account to a payee account. It returns a transaction id which can use to check the transaction status with the `getTransaction` function 167 | 168 | 2. `getTransaction(transactionId: string): Promise`: Retrieve transaction information using the `transactionId` returned by `transfer`. You can invoke it at intervals until the transaction fails or succeeds. If the transaction has failed, it will throw an appropriate error. The error will be a subclass of `MtnMoMoError`. Check [`src/error.ts`](https://github.com/sparkplug/momoapi-node/blob/master/src/errors.ts) for the various errors that can be thrown 169 | 170 | 3. `getBalance(): Promise`: Get your account balance. 171 | 172 | 4. `isPayerActive(id: string, type: PartyIdType = "MSISDN"): Promise`: This method is used to check if an account holder is registered and active in the system. 173 | 174 | #### Sample Code 175 | 176 | ```js 177 | const momo = require("mtn-momo"); 178 | 179 | // initialise momo library 180 | const { Disbursements } = momo.create({ 181 | callbackHost: process.env.CALLBACK_HOST 182 | }); 183 | 184 | // initialise disbursements 185 | const disbursements = Disbursements({ 186 | userSecret: process.env.DISBURSEMENTS_USER_SECRET, 187 | userId: process.env.DISBURSEMENTS_USER_ID, 188 | primaryKey: process.env.DISBURSEMENTS_PRIMARY_KEY 189 | }); 190 | 191 | // Transfer 192 | disbursements 193 | .transfer({ 194 | amount: "100", 195 | currency: "EUR", 196 | externalId: "947354", 197 | payee: { 198 | partyIdType: "MSISDN", 199 | partyId: "+256776564739" 200 | }, 201 | payerMessage: "testing", 202 | payeeNote: "hello", 203 | callbackUrl: "https://75f59b50.ngrok.io" 204 | }) 205 | .then(transactionId => { 206 | console.log({ transactionId }); 207 | 208 | // Get transaction status 209 | return disbursements.getTransaction(transactionId); 210 | }) 211 | .then(transaction => { 212 | console.log({ transaction }); 213 | 214 | // Get account balance 215 | return disbursements.getBalance(); 216 | }) 217 | .then(accountBalance => console.log({ accountBalance })) 218 | .catch(error => { 219 | console.log(error); 220 | }); 221 | ``` 222 | 223 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@commitlint/config-conventional', 4 | ], 5 | }; -------------------------------------------------------------------------------- /examples/collections.js: -------------------------------------------------------------------------------- 1 | const momo = require("../lib/"); 2 | 3 | const { Collections } = momo.create({ 4 | callbackHost: process.env.CALLBACK_HOST 5 | }); 6 | 7 | // initialise collections 8 | const collections = Collections({ 9 | userSecret: process.env.COLLECTIONS_USER_SECRET, 10 | userId: process.env.COLLECTIONS_USER_ID, 11 | primaryKey: process.env.COLLECTIONS_PRIMARY_KEY 12 | }); 13 | 14 | // Request to pay 15 | collections 16 | .requestToPay({ 17 | amount: "50", 18 | currency: "EUR", 19 | externalId: "123456", 20 | payer: { 21 | partyIdType: "MSISDN", 22 | partyId: "256774290781" 23 | }, 24 | payerMessage: "testing", 25 | payeeNote: "hello" 26 | }) 27 | .then(transactionId => { 28 | console.log({ transactionId }); 29 | 30 | // Get transaction status 31 | return collections.getTransaction(transactionId); 32 | }) 33 | .then(transaction => { 34 | console.log({ transaction }); 35 | 36 | // Get account balance 37 | return collections.getBalance(); 38 | }) 39 | .then(accountBalance => console.log({ accountBalance })) 40 | .catch(error => { 41 | console.log(error); 42 | }); 43 | -------------------------------------------------------------------------------- /examples/disbursements.js: -------------------------------------------------------------------------------- 1 | const momo = require("../lib"); 2 | 3 | const { Disbursements } = momo.create({ callbackHost: process.env.CALLBACK_HOST }); 4 | 5 | // initialise collections 6 | const disbursements = Disbursements({ 7 | userSecret: process.env.DISBURSEMENTS_USER_SECRET, 8 | userId: process.env.DISBURSEMENTS_USER_ID, 9 | primaryKey: process.env.DISBURSEMENTS_PRIMARY_KEY 10 | }); 11 | 12 | const partyId = "256776564739"; 13 | const partyIdType = momo.PayerType.MSISDN; 14 | // Transfer 15 | disbursements 16 | .isPayerActive(partyId, partyIdType) 17 | .then((isActive) => { 18 | console.log("Is Active ? ", isActive); 19 | if (!isActive) { 20 | return Promise.reject("Party not active"); 21 | } 22 | return disbursements.transfer({ 23 | amount: "100", 24 | currency: "EUR", 25 | externalId: "947354", 26 | payee: { 27 | partyIdType, 28 | partyId 29 | }, 30 | payerMessage: "testing", 31 | payeeNote: "hello", 32 | callbackUrl: process.env.CALLBACK_URL ? process.env.CALLBACK_URL : "https://75f59b50.ngrok.io" 33 | }); 34 | }) 35 | 36 | .then(transactionId => { 37 | console.log({ transactionId }); 38 | 39 | // Get transaction status 40 | return disbursements.getTransaction(transactionId); 41 | }) 42 | .then(transaction => { 43 | console.log({ transaction }); 44 | 45 | // Get account balance 46 | return disbursements.getBalance(); 47 | }) 48 | .then(accountBalance => console.log({ accountBalance })) 49 | .catch(error => { 50 | console.log(error); 51 | }); 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mtn-momo", 3 | "version": "0.1.1", 4 | "description": "MTN Mobile Money API Client for NodeJS written in TypeScript", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "scripts": { 8 | "clean": "rm -r lib", 9 | "compile": "tsc -p tsconfig.json", 10 | "lint": "tslint --project tsconfig.json --config tslint.json \"{src,test}/**/*.ts\"", 11 | "test": "NODE_ENV=test nyc mocha", 12 | "commitmsg": "commitlint -e $GIT_PARAMS" 13 | }, 14 | "nyc": { 15 | "extension": [ 16 | ".ts" 17 | ], 18 | "exclude": [ 19 | "**/*.d.ts", 20 | "examples" 21 | ], 22 | "reporter": [ 23 | "lcov" 24 | ], 25 | "all": true 26 | }, 27 | "keywords": [ 28 | "MTN", 29 | "Mobile", 30 | "Money", 31 | "Momo", 32 | "TypeScript", 33 | "NodeJS" 34 | ], 35 | "bin": { 36 | "momo-sandbox": "./lib/cli.js" 37 | }, 38 | "license": "ISC", 39 | "dependencies": { 40 | "axios": "^0.21.1", 41 | "commander": "^2.19.0", 42 | "uuid": "^8.3.2" 43 | }, 44 | "devDependencies": { 45 | "@commitlint/cli": "^8.1.0", 46 | "@commitlint/config-conventional": "^6.1.3", 47 | "@semantic-release/git": "^4.0.2", 48 | "@types/bluebird": "^3.5.24", 49 | "@types/chai": "^4.1.7", 50 | "@types/chai-as-promised": "^7.1.0", 51 | "@types/mocha": "^5.2.5", 52 | "@types/sinon": "^7.0.4", 53 | "@types/uuid": "^8.3.0", 54 | "axios-mock-adapter": "^1.16.0", 55 | "bluebird": "^3.5.3", 56 | "chai": "^4.2.0", 57 | "chai-as-promised": "^7.1.1", 58 | "coveralls": "^3.0.2", 59 | "cz-conventional-changelog": "^2.1.0", 60 | "husky": "^0.14.3", 61 | "mocha": "^5.2.0", 62 | "mocha-lcov-reporter": "^1.3.0", 63 | "nyc": "^14.1.1", 64 | "semantic-release": "^19.0.3", 65 | "sinon": "^7.2.3", 66 | "ts-node": "^7.0.1", 67 | "tslint": "^5.11.0", 68 | "typescript": "^3.1.6" 69 | }, 70 | "directories": { 71 | "lib": "lib" 72 | }, 73 | "repository": { 74 | "type": "git", 75 | "url": "git+https://github.com/sparkplug/momoapi-node.git" 76 | }, 77 | "bugs": { 78 | "url": "https://github.com/sparkplug/momoapi-node/issues" 79 | }, 80 | "homepage": "https://github.com/sparkplug/momoapi-node#readme" 81 | } 82 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "branch": "master", 3 | }; -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from "axios"; 2 | 3 | import { createClient } from "./client"; 4 | 5 | import { AccessToken, Config, UserConfig } from "./common"; 6 | 7 | export type TokenRefresher = () => Promise; 8 | 9 | export type Authorizer = ( 10 | config: Config, 11 | client?: AxiosInstance 12 | ) => Promise; 13 | 14 | interface OAuthCredentials { 15 | accessToken: string; 16 | expires: number; 17 | } 18 | 19 | export function createTokenRefresher( 20 | authorize: Authorizer, 21 | config: Config 22 | ): TokenRefresher { 23 | let credentials: OAuthCredentials; 24 | return () => { 25 | if (isExpired(credentials)) { 26 | return authorize(config) 27 | .then((accessToken: AccessToken) => { 28 | const { access_token, expires_in }: AccessToken = accessToken; 29 | const expires: number = Date.now() + expires_in * 1000 - 60000; 30 | return { 31 | accessToken: access_token, 32 | expires 33 | }; 34 | }) 35 | .then(freshCredentials => { 36 | credentials = freshCredentials; 37 | return credentials.accessToken; 38 | }); 39 | } 40 | 41 | return Promise.resolve(credentials.accessToken); 42 | }; 43 | } 44 | 45 | export const authorizeCollections: Authorizer = function( 46 | config: Config, 47 | client: AxiosInstance = createClient(config) 48 | ): Promise { 49 | const basicAuthToken: string = createBasicAuthToken(config); 50 | return client 51 | .post("/collection/token/", null, { 52 | headers: { 53 | Authorization: `Basic ${basicAuthToken}` 54 | } 55 | }) 56 | .then(response => response.data); 57 | }; 58 | 59 | export const authorizeDisbursements: Authorizer = function( 60 | config: Config, 61 | client: AxiosInstance = createClient(config) 62 | ): Promise { 63 | const basicAuthToken: string = createBasicAuthToken(config); 64 | return client 65 | .post("/disbursement/token/", null, { 66 | headers: { 67 | Authorization: `Basic ${basicAuthToken}` 68 | } 69 | }) 70 | .then(response => response.data); 71 | }; 72 | 73 | export function createBasicAuthToken(config: UserConfig): string { 74 | return Buffer.from(`${config.userId}:${config.userSecret}`).toString( 75 | "base64" 76 | ); 77 | } 78 | 79 | function isExpired(credentials: OAuthCredentials): boolean { 80 | if (!credentials || !credentials.expires) { 81 | return true; 82 | } 83 | 84 | return Date.now() > credentials.expires; 85 | } 86 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import program from "commander"; 4 | 5 | import * as momo from "./"; 6 | import { Credentials } from "./common"; 7 | import { MtnMoMoError } from "./errors"; 8 | 9 | const { version } = require("../package.json"); 10 | 11 | program 12 | .version(version) 13 | .description("Create sandbox credentials") 14 | .option("-x, --host ", "Your webhook host") 15 | .option("-p, --primary-key ", "Primary Key") 16 | .parse(process.argv); 17 | 18 | const stringify = (obj: object | string) => JSON.stringify(obj, null, 2); 19 | 20 | const { Users } = momo.create({ callbackHost: program.host }); 21 | 22 | const users = Users({ primaryKey: program.primaryKey }); 23 | 24 | users 25 | .create(program.host) 26 | .then((userId: string) => { 27 | return users.login(userId).then((credentials: Credentials) => { 28 | console.log( 29 | "Momo Sandbox Credentials", 30 | stringify({ 31 | userSecret: credentials.apiKey, 32 | userId 33 | }) 34 | ); 35 | }); 36 | }) 37 | .catch((error: MtnMoMoError) => { 38 | console.log(error); 39 | }); 40 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance, AxiosRequestConfig } from "axios"; 2 | 3 | import { handleError } from "./errors"; 4 | 5 | import { TokenRefresher } from "./auth"; 6 | import { GlobalConfig, SubscriptionConfig } from "./common"; 7 | 8 | export function createClient( 9 | config: SubscriptionConfig & GlobalConfig, 10 | client: AxiosInstance = axios.create() 11 | ): AxiosInstance { 12 | client.defaults.baseURL = config.baseUrl; 13 | client.defaults.headers = { 14 | "Ocp-Apim-Subscription-Key": config.primaryKey, 15 | "X-Target-Environment": config.environment || "sandbox" 16 | }; 17 | 18 | return withErrorHandling(client); 19 | } 20 | 21 | export function createAuthClient( 22 | refresh: TokenRefresher, 23 | client: AxiosInstance 24 | ): AxiosInstance { 25 | client.interceptors.request.use((request: AxiosRequestConfig) => { 26 | return refresh().then(accessToken => { 27 | return { 28 | ...request, 29 | headers: { 30 | ...request.headers, 31 | Authorization: `Bearer ${accessToken}` 32 | } 33 | }; 34 | }); 35 | }); 36 | 37 | return client; 38 | } 39 | 40 | export function withErrorHandling(client: AxiosInstance): AxiosInstance { 41 | client.interceptors.response.use( 42 | response => response, 43 | error => Promise.reject(handleError(error)) 44 | ); 45 | 46 | return client; 47 | } 48 | -------------------------------------------------------------------------------- /src/collections.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from "axios"; 2 | import { v4 as uuid } from "uuid"; 3 | 4 | import { getTransactionError } from "./errors"; 5 | import { validateRequestToPay } from "./validate"; 6 | 7 | import { 8 | Balance, 9 | FailureReason, 10 | Party, 11 | PartyIdType, 12 | TransactionStatus 13 | } from "./common"; 14 | 15 | export interface PaymentRequest { 16 | /** 17 | * Amount that will be debited from the payer account 18 | */ 19 | amount: string; 20 | 21 | /** 22 | * ISO4217 Currency 23 | */ 24 | currency: string; 25 | 26 | /** 27 | * External id is used as a reference to the transaction. 28 | * External id is used for reconciliation. 29 | * The external id will be included in transaction history report. 30 | * External id is not required to be unique. 31 | */ 32 | externalId?: string; 33 | 34 | /** 35 | * Party identifies a account holder in the wallet platform. 36 | * Party consists of two parameters, type and partyId. 37 | * Each type have its own validation of the partyId 38 | * - MSISDN - Mobile Number validated according to ITU-T E.164. Validated with IsMSISDN 39 | * - EMAIL - Validated to be a valid e-mail format. Validated with IsEmail 40 | * - PARTY_CODE - UUID of the party. Validated with IsUuid 41 | */ 42 | payer: Party; 43 | 44 | /** 45 | * Message that will be written in the payer transaction history message field. 46 | */ 47 | payerMessage?: string; 48 | 49 | /** 50 | * Message that will be written in the payee transaction history note field. 51 | */ 52 | payeeNote?: string; 53 | 54 | /** 55 | * URL to the server where the callback should be sent. 56 | */ 57 | callbackUrl?: string; 58 | } 59 | 60 | export interface Payment { 61 | /** 62 | * Financial transactionIdd from mobile money manager. 63 | * Used to connect to the specific financial transaction made in the account 64 | */ 65 | financialTransactionId: string; 66 | 67 | /** 68 | * External id provided in the creation of the requestToPay transaction 69 | */ 70 | externalId: string; 71 | 72 | /** 73 | * Amount that will be debited from the payer account. 74 | */ 75 | amount: string; 76 | 77 | /** 78 | * ISO4217 Currency 79 | */ 80 | currency: string; 81 | 82 | /** 83 | * Party identifies a account holder in the wallet platform. 84 | * Party consists of two parameters, type and partyId. 85 | * Each type have its own validation of the partyId 86 | * - MSISDN - Mobile Number validated according to ITU-T E.164. Validated with IsMSISDN 87 | * - EMAIL - Validated to be a valid e-mail format. Validated with IsEmail 88 | * - PARTY_CODE - UUID of the party. Validated with IsUuid 89 | */ 90 | payer: Party; 91 | 92 | /** 93 | * Message that will be written in the payer transaction history message field. 94 | */ 95 | payerMessage: string; 96 | 97 | /** 98 | * Message that will be written in the payee transaction history note field. 99 | */ 100 | payeeNote: string; 101 | 102 | reason?: FailureReason; 103 | 104 | status: TransactionStatus; 105 | } 106 | 107 | export default class Collections { 108 | private client: AxiosInstance; 109 | 110 | constructor(client: AxiosInstance) { 111 | this.client = client; 112 | } 113 | 114 | /** 115 | * This method is used to request a payment from a consumer (Payer). 116 | * The payer will be asked to authorize the payment. The transaction will 117 | * be executed once the payer has authorized the payment. 118 | * The requesttopay will be in status PENDING until the transaction 119 | * is authorized or declined by the payer or it is timed out by the system. 120 | * Status of the transaction can be validated by using `getTransation` 121 | * 122 | * @param paymentRequest 123 | */ 124 | public requestToPay({ 125 | callbackUrl, 126 | ...paymentRequest 127 | }: PaymentRequest): Promise { 128 | return validateRequestToPay(paymentRequest).then(() => { 129 | const referenceId: string = uuid(); 130 | return this.client 131 | .post("/collection/v1_0/requesttopay", paymentRequest, { 132 | headers: { 133 | "X-Reference-Id": referenceId, 134 | ...(callbackUrl ? { "X-Callback-Url": callbackUrl } : {}) 135 | } 136 | }) 137 | .then(() => referenceId); 138 | }); 139 | } 140 | 141 | /** 142 | * This method is used to retrieve transaction information. You can invoke it 143 | * at intervals until your transaction fails or succeeds. 144 | * 145 | * If the transaction has failed, it will throw an appropriate error. The error will be a subclass 146 | * of `MtnMoMoError`. Check [`src/error.ts`](https://github.com/sparkplug/momoapi-node/blob/master/src/errors.ts) 147 | * for the various errors that can be thrown 148 | * 149 | * @param referenceId the value returned from `requestToPay` 150 | */ 151 | public getTransaction(referenceId: string): Promise { 152 | return this.client 153 | .get(`/collection/v1_0/requesttopay/${referenceId}`) 154 | .then(response => response.data) 155 | .then(transaction => { 156 | if (transaction.status === TransactionStatus.FAILED) { 157 | return Promise.reject(getTransactionError(transaction)); 158 | } 159 | 160 | return Promise.resolve(transaction); 161 | }); 162 | } 163 | 164 | /** 165 | * Get the balance of the account. 166 | */ 167 | public getBalance(): Promise { 168 | return this.client 169 | .get("/collection/v1_0/account/balance") 170 | .then(response => response.data); 171 | } 172 | 173 | /** 174 | * This method is used to check if an account holder is registered and active in the system. 175 | * 176 | * @param id Specifies the type of the party ID. Allowed values [msisdn, email, party_code]. 177 | * accountHolderId should explicitly be in small letters. 178 | * 179 | * @param type The party number. Validated according to the party ID type (case Sensitive). 180 | * msisdn - Mobile Number validated according to ITU-T E.164. Validated with IsMSISDN 181 | * email - Validated to be a valid e-mail format. Validated with IsEmail 182 | * party_code - UUID of the party. Validated with IsUuid 183 | */ 184 | public isPayerActive( 185 | id: string, 186 | type: PartyIdType = PartyIdType.MSISDN 187 | ): Promise { 188 | return this.client 189 | .get<{result: boolean}>(`/collection/v1_0/accountholder/${type}/${id}/active`) 190 | .then(response => response.data) 191 | .then(data => data.result ? data.result : false); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | export type Config = GlobalConfig & ProductConfig; 2 | 3 | export type ProductConfig = SubscriptionConfig & UserConfig; 4 | 5 | export interface GlobalConfig { 6 | /** 7 | * The provider callback host 8 | */ 9 | callbackHost?: string; 10 | 11 | /** 12 | * The base URL of the EWP system where the transaction shall be processed. 13 | * This parameter is used to route the request to the EWP system that will 14 | * initiate the transaction. 15 | */ 16 | baseUrl?: string; 17 | 18 | /** 19 | * The identifier of the EWP system where the transaction shall be processed. 20 | * This parameter is used to route the request to the EWP system that will 21 | * initiate the transaction. 22 | */ 23 | environment?: Environment; 24 | } 25 | 26 | export interface SubscriptionConfig { 27 | /** 28 | * Subscription key which provides access to this API. Found in your Profile 29 | */ 30 | primaryKey: string; 31 | } 32 | 33 | export interface UserConfig { 34 | /** 35 | * The API user's key 36 | */ 37 | userSecret: string; 38 | 39 | /** 40 | * Recource ID for the API user 41 | */ 42 | userId: string; 43 | } 44 | 45 | export interface Credentials { 46 | apiKey: string; 47 | } 48 | 49 | export interface AccessToken { 50 | /** 51 | * A JWT token which can be used to authrize against the other API end-points. 52 | * The format of the token follows the JWT standard format (see jwt.io for an example). 53 | * This is the token that should be sent in in the Authorization header when calling the other API end-points. 54 | */ 55 | access_token: string; 56 | 57 | /** 58 | * The token type. 59 | * 60 | * TODO: Find list of complete token types 61 | */ 62 | token_type: string; 63 | 64 | /** 65 | * The validity time in seconds of the token 66 | */ 67 | expires_in: number; 68 | } 69 | 70 | /** 71 | * The available balance of the account 72 | */ 73 | export interface Balance { 74 | /** 75 | * The available balance of the account 76 | */ 77 | availableBalance: string; 78 | 79 | /** 80 | * ISO4217 Currency 81 | */ 82 | currency: string; 83 | } 84 | 85 | export interface Party { 86 | partyIdType: PartyIdType; 87 | partyId: string; 88 | } 89 | 90 | export enum PartyIdType { 91 | MSISDN = "MSISDN", 92 | EMAIL = "EMAIL", 93 | PARTY_CODE = "PARTY_CODE" 94 | } 95 | 96 | export enum Environment { 97 | SANDBOX = "sandbox", 98 | PRODUCTION = "production" 99 | } 100 | 101 | export enum TransactionStatus { 102 | SUCCESSFUL = "SUCCESSFUL", 103 | PENDING = "PENDING", 104 | FAILED = "FAILED" 105 | } 106 | 107 | export enum FailureReason { 108 | PAYEE_NOT_FOUND = "PAYEE_NOT_FOUND", 109 | PAYER_NOT_FOUND = "PAYER_NOT_FOUND", 110 | NOT_ALLOWED = "NOT_ALLOWED", 111 | NOT_ALLOWED_TARGET_ENVIRONMENT = "NOT_ALLOWED_TARGET_ENVIRONMENT", 112 | INVALID_CALLBACK_URL_HOST = "INVALID_CALLBACK_URL_HOST", 113 | INVALID_CURRENCY = "INVALID_CURRENCY", 114 | SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE", 115 | INTERNAL_PROCESSING_ERROR = "INTERNAL_PROCESSING_ERROR", 116 | NOT_ENOUGH_FUNDS = "NOT_ENOUGH_FUNDS", 117 | PAYER_LIMIT_REACHED = "PAYER_LIMIT_REACHED", 118 | PAYEE_NOT_ALLOWED_TO_RECEIVE = "PAYEE_NOT_ALLOWED_TO_RECEIVE", 119 | PAYMENT_NOT_APPROVED = "PAYMENT_NOT_APPROVED", 120 | RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND", 121 | APPROVAL_REJECTED = "APPROVAL_REJECTED", 122 | EXPIRED = "EXPIRED", 123 | TRANSACTION_CANCELED = "TRANSACTION_CANCELED", 124 | RESOURCE_ALREADY_EXIST = "RESOURCE_ALREADY_EXIST" 125 | } 126 | -------------------------------------------------------------------------------- /src/disbursements.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from "axios"; 2 | import { v4 as uuid } from "uuid"; 3 | 4 | import { getTransactionError } from "./errors"; 5 | import { validateTransfer } from "./validate"; 6 | 7 | import { 8 | Balance, 9 | FailureReason, 10 | PartyIdType, 11 | TransactionStatus 12 | } from "./common"; 13 | 14 | export interface TransferRequest { 15 | /** 16 | * Unique Transfer Reference (UUID v4), will be automatically generated if not explicitly supplied 17 | */ 18 | referenceId?: string; 19 | /** 20 | * Amount that will be debited from the payer account. 21 | */ 22 | amount: string; 23 | 24 | /** 25 | * ISO4217 Currency 26 | */ 27 | currency: string; 28 | 29 | /** 30 | * External id is used as a reference to the transaction. 31 | * External id is used for reconciliation. 32 | * The external id will be included in transaction history report. 33 | * External id is not required to be unique. 34 | */ 35 | externalId?: string; 36 | 37 | /** 38 | * Party identifies a account holder in the wallet platform. 39 | * Party consists of two parameters, type and partyId. 40 | * Each type have its own validation of the partyId 41 | * MSISDN - Mobile Number validated according to ITU-T E.164. Validated with IsMSISDN 42 | * EMAIL - Validated to be a valid e-mail format. Validated with IsEmail 43 | * PARTY_CODE - UUID of the party. Validated with IsUuid 44 | */ 45 | payee: { 46 | partyIdType: PartyIdType; 47 | partyId: string; 48 | }; 49 | 50 | /** 51 | * Message that will be written in the payer transaction history message field. 52 | */ 53 | payerMessage?: string; 54 | /** 55 | * Message that will be written in the payee transaction history note field. 56 | */ 57 | payeeNote?: string; 58 | /** 59 | * URL to the server where the callback should be sent. 60 | */ 61 | callbackUrl?: string; 62 | } 63 | 64 | export interface Transfer { 65 | /** 66 | * Amount that will be debited from the payer account. 67 | */ 68 | amount: string; 69 | 70 | /** 71 | * ISO4217 Currency 72 | */ 73 | currency: string; 74 | 75 | /** 76 | * Financial transactionIdd from mobile money manager. 77 | * Used to connect to the specific financial transaction made in the account 78 | */ 79 | financialTransactionId: string; 80 | 81 | /** 82 | * External id is used as a reference to the transaction. 83 | * External id is used for reconciliation. 84 | * The external id will be included in transaction history report. 85 | * External id is not required to be unique. 86 | */ 87 | externalId: string; 88 | 89 | /** 90 | * Party identifies a account holder in the wallet platform. 91 | * Party consists of two parameters, type and partyId. 92 | * Each type have its own validation of the partyId 93 | * MSISDN - Mobile Number validated according to ITU-T E.164. Validated with IsMSISDN 94 | * EMAIL - Validated to be a valid e-mail format. Validated with IsEmail 95 | * PARTY_CODE - UUID of the party. Validated with IsUuid 96 | */ 97 | payee: { 98 | partyIdType: "MSISDN"; 99 | partyId: string; 100 | }; 101 | status: TransactionStatus; 102 | reason?: FailureReason; 103 | } 104 | 105 | export default class Disbursements { 106 | private client: AxiosInstance; 107 | 108 | constructor(client: AxiosInstance) { 109 | this.client = client; 110 | } 111 | 112 | /** 113 | * Transfer operation is used to transfer an amount from the owner’s 114 | * account to a payee account. 115 | * Status of the transaction can be validated by using the 116 | * 117 | * @param paymentRequest 118 | */ 119 | public transfer({ 120 | callbackUrl, 121 | referenceId = uuid(), 122 | ...payoutRequest 123 | }: TransferRequest): Promise { 124 | return validateTransfer({ referenceId, ...payoutRequest }).then(() => { 125 | return this.client 126 | .post("/disbursement/v1_0/transfer", payoutRequest, { 127 | headers: { 128 | "X-Reference-Id": referenceId, 129 | ...(callbackUrl ? { "X-Callback-Url": callbackUrl } : {}) 130 | } 131 | }) 132 | .then(() => referenceId); 133 | }); 134 | } 135 | 136 | /** 137 | * This method is used to retrieve the transaction. You can invoke this method 138 | * to at intervals until your transaction fails or succeeds. 139 | * 140 | * If the transaction has failed, it will throw an appropriate error. The error will be a subclass 141 | * of `MtnMoMoError`. Check [`src/error.ts`](https://github.com/sparkplug/momoapi-node/blob/master/src/errors.ts) 142 | * for the various errors that can be thrown 143 | * 144 | * @param referenceId the value returned from `transfer` 145 | */ 146 | public getTransaction(referenceId: string): Promise { 147 | return this.client 148 | .get(`/disbursement/v1_0/transfer/${referenceId}`) 149 | .then(response => response.data) 150 | .then(transaction => { 151 | if (transaction.status === TransactionStatus.FAILED) { 152 | return Promise.reject(getTransactionError(transaction)); 153 | } 154 | 155 | return Promise.resolve(transaction); 156 | }); 157 | } 158 | 159 | /** 160 | * Get the balance of the account. 161 | */ 162 | public getBalance(): Promise { 163 | return this.client 164 | .get("/disbursement/v1_0/account/balance") 165 | .then(response => response.data); 166 | } 167 | 168 | /** 169 | * This method is used to check if an account holder is registered and active in the system. 170 | * 171 | * @param id Specifies the type of the party ID. Allowed values [msisdn, email, party_code]. 172 | * accountHolderId should explicitly be in small letters. 173 | * 174 | * @param type The party number. Validated according to the party ID type (case Sensitive). 175 | * msisdn - Mobile Number validated according to ITU-T E.164. Validated with IsMSISDN 176 | * email - Validated to be a valid e-mail format. Validated with IsEmail 177 | * party_code - UUID of the party. Validated with IsUuid 178 | */ 179 | public isPayerActive( 180 | id: string, 181 | type: PartyIdType = PartyIdType.MSISDN 182 | ): Promise { 183 | return this.client 184 | .get<{ result: boolean }>(`/disbursement/v1_0/accountholder/${String(type).toLowerCase()}/${id}/active`) 185 | .then(response => response.data) 186 | .then(data => data.result ? data.result : false); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from "axios"; 2 | import { Payment } from "./collections"; 3 | import { FailureReason } from "./common"; 4 | import { Transfer } from "./disbursements"; 5 | 6 | interface ErrorBody { 7 | code: FailureReason; 8 | message: string; 9 | } 10 | 11 | export class MtnMoMoError extends Error { 12 | public transaction?: Payment | Transfer; 13 | 14 | constructor(message?: string) { 15 | super(message); 16 | Object.setPrototypeOf(this, new.target.prototype); 17 | } 18 | } 19 | 20 | export class ApprovalRejectedError extends MtnMoMoError { 21 | public name = "ApprovalRejectedError"; 22 | } 23 | 24 | export class ExpiredError extends MtnMoMoError { 25 | public name = "ExpiredError"; 26 | } 27 | 28 | export class InternalProcessingError extends MtnMoMoError { 29 | public name = "InternalProcessingError"; 30 | } 31 | 32 | export class InvalidCallbackUrlHostError extends MtnMoMoError { 33 | public name = "InvalidCallbackUrlHostError"; 34 | } 35 | 36 | export class InvalidCurrencyError extends MtnMoMoError { 37 | public name = "InvalidCurrencyError"; 38 | } 39 | 40 | export class NotAllowedTargetEnvironmentError extends MtnMoMoError { 41 | public name = "NotAllowedTargetEnvironmentError"; 42 | } 43 | 44 | export class NotAllowedError extends MtnMoMoError { 45 | public name = "NotAllowedError"; 46 | } 47 | 48 | export class NotEnoughFundsError extends MtnMoMoError { 49 | public name = "NotEnoughFundsError"; 50 | } 51 | 52 | export class PayeeNotFoundError extends MtnMoMoError { 53 | public name = "PayeeNotFoundError"; 54 | } 55 | 56 | export class PayeeNotAllowedToReceiveError extends MtnMoMoError { 57 | public name = "PayeeNotAllowedToReceiveError"; 58 | } 59 | 60 | export class PayerLimitReachedError extends MtnMoMoError { 61 | public name = "PayerLimitReachedError"; 62 | } 63 | 64 | export class PayerNotFoundError extends MtnMoMoError { 65 | public name = "PayerNotFoundError"; 66 | } 67 | 68 | export class PaymentNotApprovedError extends MtnMoMoError { 69 | public name = "PaymentNotApprovedError"; 70 | } 71 | 72 | export class ResourceAlreadyExistError extends MtnMoMoError { 73 | public name = "ResourceAlreadyExistError"; 74 | } 75 | 76 | export class ResourceNotFoundError extends MtnMoMoError { 77 | public name = "ResourceNotFoundError"; 78 | } 79 | 80 | export class ServiceUnavailableError extends MtnMoMoError { 81 | public name = "ServiceUnavailableError"; 82 | } 83 | 84 | export class TransactionCancelledError extends MtnMoMoError { 85 | public name = "TransactionCancelledError"; 86 | } 87 | 88 | export class UnspecifiedError extends MtnMoMoError { 89 | public name = "UnspecifiedError"; 90 | } 91 | 92 | export function handleError(error: AxiosError): Error { 93 | if (!error.response) { 94 | return error; 95 | } 96 | 97 | const { code, message }: ErrorBody = error.response.data || {}; 98 | 99 | return getError(code, message); 100 | } 101 | 102 | export function getError(code?: FailureReason, message?: string) { 103 | if (code === FailureReason.APPROVAL_REJECTED) { 104 | return new ApprovalRejectedError(message); 105 | } 106 | 107 | if (code === FailureReason.EXPIRED) { 108 | return new ExpiredError(message); 109 | } 110 | 111 | if (code === FailureReason.INTERNAL_PROCESSING_ERROR) { 112 | return new InternalProcessingError(message); 113 | } 114 | 115 | if (code === FailureReason.INVALID_CALLBACK_URL_HOST) { 116 | return new InvalidCallbackUrlHostError(message); 117 | } 118 | 119 | if (code === FailureReason.INVALID_CURRENCY) { 120 | return new InvalidCurrencyError(message); 121 | } 122 | 123 | if (code === FailureReason.NOT_ALLOWED) { 124 | return new NotAllowedError(message); 125 | } 126 | 127 | if (code === FailureReason.NOT_ALLOWED_TARGET_ENVIRONMENT) { 128 | return new NotAllowedTargetEnvironmentError(message); 129 | } 130 | 131 | if (code === FailureReason.NOT_ENOUGH_FUNDS) { 132 | return new NotEnoughFundsError(message); 133 | } 134 | 135 | if (code === FailureReason.PAYEE_NOT_FOUND) { 136 | return new PayeeNotFoundError(message); 137 | } 138 | 139 | if (code === FailureReason.PAYEE_NOT_ALLOWED_TO_RECEIVE) { 140 | return new PayeeNotAllowedToReceiveError(message); 141 | } 142 | 143 | if (code === FailureReason.PAYER_LIMIT_REACHED) { 144 | return new PayerLimitReachedError(message); 145 | } 146 | 147 | if (code === FailureReason.PAYER_NOT_FOUND) { 148 | return new PayerNotFoundError(message); 149 | } 150 | 151 | if (code === FailureReason.PAYMENT_NOT_APPROVED) { 152 | return new PaymentNotApprovedError(message); 153 | } 154 | 155 | if (code === FailureReason.RESOURCE_ALREADY_EXIST) { 156 | return new ResourceAlreadyExistError(message); 157 | } 158 | 159 | if (code === FailureReason.RESOURCE_NOT_FOUND) { 160 | return new ResourceNotFoundError(message); 161 | } 162 | 163 | if (code === FailureReason.SERVICE_UNAVAILABLE) { 164 | return new ServiceUnavailableError(message); 165 | } 166 | 167 | if (code === FailureReason.TRANSACTION_CANCELED) { 168 | return new TransactionCancelledError(message); 169 | } 170 | 171 | return new UnspecifiedError(message); 172 | } 173 | 174 | export function getTransactionError(transaction: Payment | Transfer) { 175 | const error: MtnMoMoError = getError(transaction.reason as FailureReason); 176 | error.transaction = transaction; 177 | 178 | return error; 179 | } 180 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Payment, PaymentRequest } from "./collections"; 2 | export { Transfer, TransferRequest } from "./disbursements"; 3 | export * from "./errors"; 4 | export { 5 | PartyIdType as PayerType, 6 | Party as Payer, 7 | TransactionStatus as Status, 8 | Balance, 9 | Environment, 10 | FailureReason, 11 | GlobalConfig, 12 | ProductConfig 13 | } from "./common"; 14 | 15 | import { AxiosInstance } from "axios"; 16 | 17 | import Collections from "./collections"; 18 | import Disbursements from "./disbursements"; 19 | import Users from "./users"; 20 | 21 | import { 22 | authorizeCollections, 23 | authorizeDisbursements, 24 | createTokenRefresher 25 | } from "./auth"; 26 | import { createAuthClient, createClient } from "./client"; 27 | import { 28 | validateGlobalConfig, 29 | validateProductConfig, 30 | validateSubscriptionConfig 31 | } from "./validate"; 32 | 33 | import { 34 | Config, 35 | Environment, 36 | GlobalConfig, 37 | ProductConfig, 38 | SubscriptionConfig 39 | } from "./common"; 40 | 41 | export interface MomoClient { 42 | Collections(productConfig: ProductConfig): Collections; 43 | Disbursements(productConfig: ProductConfig): Disbursements; 44 | Users(subscription: SubscriptionConfig): Users; 45 | } 46 | 47 | const defaultGlobalConfig: GlobalConfig = { 48 | baseUrl: "https://sandbox.momodeveloper.mtn.com", 49 | environment: Environment.SANDBOX 50 | }; 51 | 52 | /** 53 | * Initialise the library 54 | * 55 | * @param globalConfig Global configuration required to use any product 56 | */ 57 | export function create(globalConfig: GlobalConfig): MomoClient { 58 | validateGlobalConfig(globalConfig); 59 | 60 | return { 61 | Collections(productConfig: ProductConfig): Collections { 62 | validateProductConfig(productConfig); 63 | 64 | const config: Config = { 65 | ...defaultGlobalConfig, 66 | ...globalConfig, 67 | ...productConfig 68 | }; 69 | 70 | const client: AxiosInstance = createAuthClient( 71 | createTokenRefresher(authorizeCollections, config), 72 | createClient(config) 73 | ); 74 | return new Collections(client); 75 | }, 76 | 77 | Disbursements(productConfig: ProductConfig): Disbursements { 78 | const config: Config = { 79 | ...defaultGlobalConfig, 80 | ...globalConfig, 81 | ...productConfig 82 | }; 83 | 84 | const client: AxiosInstance = createAuthClient( 85 | createTokenRefresher(authorizeDisbursements, config), 86 | createClient(config) 87 | ); 88 | 89 | return new Disbursements(client); 90 | }, 91 | 92 | Users(subscriptionConfig: SubscriptionConfig): Users { 93 | validateSubscriptionConfig(subscriptionConfig); 94 | 95 | const config: GlobalConfig & SubscriptionConfig = { 96 | ...defaultGlobalConfig, 97 | ...globalConfig, 98 | ...subscriptionConfig 99 | }; 100 | 101 | const client: AxiosInstance = createClient(config); 102 | 103 | return new Users(client); 104 | } 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /src/users.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from "axios"; 2 | import { v4 as uuid } from "uuid"; 3 | 4 | import { Credentials } from "./common"; 5 | 6 | export default class Users { 7 | private client: AxiosInstance; 8 | 9 | constructor(client: AxiosInstance) { 10 | this.client = client; 11 | } 12 | 13 | /** 14 | * Used to create an API user in the sandbox target environment 15 | * @param host The provider callback host 16 | */ 17 | public create(host: string): Promise { 18 | const userId: string = uuid(); 19 | return this.client 20 | .post( 21 | "/v1_0/apiuser", 22 | { providerCallbackHost: host }, 23 | { 24 | headers: { 25 | "X-Reference-Id": userId 26 | } 27 | } 28 | ) 29 | .then(() => userId); 30 | } 31 | 32 | /** 33 | * Used to create an API key for an API user in the sandbox target environment. 34 | * @param userId 35 | */ 36 | public login(userId: string): Promise { 37 | return this.client 38 | .post(`/v1_0/apiuser/${userId}/apikey`) 39 | .then(response => response.data); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/validate.ts: -------------------------------------------------------------------------------- 1 | import { strictEqual } from "assert"; 2 | 3 | import { PaymentRequest } from "./collections"; 4 | import { 5 | Environment, 6 | GlobalConfig, 7 | ProductConfig, 8 | SubscriptionConfig, 9 | UserConfig 10 | } from "./common"; 11 | import { TransferRequest } from "./disbursements"; 12 | 13 | export function validateRequestToPay( 14 | paymentRequest: PaymentRequest 15 | ): Promise { 16 | const { amount, currency, payer }: PaymentRequest = paymentRequest || {}; 17 | return Promise.resolve().then(() => { 18 | strictEqual(isTruthy(amount), true, "amount is required"); 19 | strictEqual(isNumeric(amount), true, "amount must be a number"); 20 | strictEqual(isTruthy(currency), true, "currency is required"); 21 | strictEqual(isTruthy(payer), true, "payer is required"); 22 | strictEqual(isTruthy(payer.partyId), true, "payer.partyId is required"); 23 | strictEqual( 24 | isTruthy(payer.partyIdType), 25 | true, 26 | "payer.partyIdType is required" 27 | ); 28 | strictEqual(isString(currency), true, "amount must be a string"); 29 | }); 30 | } 31 | 32 | export function validateTransfer( 33 | payoutRequest: TransferRequest 34 | ): Promise { 35 | const { amount, currency, payee, referenceId }: TransferRequest = payoutRequest || {}; 36 | return Promise.resolve().then(() => { 37 | strictEqual(isTruthy(referenceId), true, "referenceId is required"); 38 | strictEqual(isUuid4(referenceId as string), true, "referenceId must be a valid uuid v4"); 39 | strictEqual(isTruthy(amount), true, "amount is required"); 40 | strictEqual(isNumeric(amount), true, "amount must be a number"); 41 | strictEqual(isTruthy(currency), true, "currency is required"); 42 | strictEqual(isTruthy(payee), true, "payee is required"); 43 | strictEqual(isTruthy(payee.partyId), true, "payee.partyId is required"); 44 | strictEqual( 45 | isTruthy(payee.partyIdType), 46 | true, 47 | "payee.partyIdType is required" 48 | ); 49 | strictEqual(isString(currency), true, "amount must be a string"); 50 | }); 51 | } 52 | 53 | export function validateGlobalConfig(config: GlobalConfig): void { 54 | const { callbackHost, baseUrl, environment } = config; 55 | strictEqual(isTruthy(callbackHost), true, "callbackHost is required"); 56 | 57 | if (environment && environment !== Environment.SANDBOX) { 58 | strictEqual( 59 | isTruthy(baseUrl), 60 | true, 61 | "baseUrl is required if environment is not sandbox" 62 | ); 63 | strictEqual(isString(baseUrl), true, "baseUrl must be a string"); 64 | } 65 | } 66 | 67 | export function validateProductConfig(config: ProductConfig): void { 68 | validateSubscriptionConfig(config); 69 | validateUserConfig(config); 70 | } 71 | 72 | export function validateSubscriptionConfig(config: SubscriptionConfig): void { 73 | const { primaryKey } = config; 74 | strictEqual(isTruthy(primaryKey), true, "primaryKey is required"); 75 | strictEqual(isString(primaryKey), true, "primaryKey must be a string"); 76 | } 77 | 78 | export function validateUserConfig({ userId, userSecret }: UserConfig): void { 79 | strictEqual(isTruthy(userId), true, "userId is required"); 80 | strictEqual(isString(userId), true, "userId must be a string"); 81 | 82 | strictEqual(isTruthy(userSecret), true, "userSecret is required"); 83 | strictEqual(isString(userSecret), true, "userSecret must be a string"); 84 | 85 | strictEqual(isUuid4(userId), true, "userId must be a valid uuid v4"); 86 | } 87 | 88 | function isNumeric(value: any): boolean { 89 | return !isNaN(parseInt(value, 10)); 90 | } 91 | 92 | function isTruthy(value: any): boolean { 93 | return !!value; 94 | } 95 | 96 | function isString(value: any): boolean { 97 | return typeof value === "string"; 98 | } 99 | 100 | function isUuid4(value: string): boolean { 101 | return /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test( 102 | value 103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /test/auth.test.ts: -------------------------------------------------------------------------------- 1 | import sinon from "sinon"; 2 | 3 | import { expect } from "./chai"; 4 | 5 | import { 6 | authorizeCollections, 7 | authorizeDisbursements, 8 | createBasicAuthToken, 9 | createTokenRefresher 10 | } from "../src/auth"; 11 | import { createMock } from "./mock"; 12 | 13 | import { AccessToken, Config, Environment } from "../src/common"; 14 | 15 | describe("Auth", function() { 16 | const config: Config = { 17 | environment: Environment.SANDBOX, 18 | baseUrl: "test", 19 | primaryKey: "key", 20 | userId: "id", 21 | userSecret: "secret" 22 | }; 23 | 24 | describe("getTokenRefresher", function() { 25 | context("when the access token is not expired", function() { 26 | it("doesn't call the authorizer for a new access token", function() { 27 | const authorizer = sinon.fake.resolves({ 28 | access_token: "token", 29 | token_type: "string", 30 | expires_in: 3600 31 | } as AccessToken); 32 | const refresh = createTokenRefresher(authorizer, config); 33 | return expect(refresh().then(() => refresh())).to.be.fulfilled.then( 34 | () => { 35 | expect(authorizer.callCount).to.eq(1); 36 | } 37 | ); 38 | }); 39 | }); 40 | 41 | context("when the access token expires", function() { 42 | it("calls the authorizer again for a new access token", function() { 43 | const authorizer = sinon.fake.resolves({ 44 | access_token: "token", 45 | token_type: "string", 46 | expires_in: -3600 47 | } as AccessToken); 48 | const refresh = createTokenRefresher(authorizer, config); 49 | return expect(refresh().then(() => refresh())).to.be.fulfilled.then( 50 | () => { 51 | expect(authorizer.callCount).to.eq(2); 52 | } 53 | ); 54 | }); 55 | }); 56 | }); 57 | 58 | describe("authorizeCollections", function() { 59 | it("makes the correct request", function() { 60 | const [mockClient, mockAdapter] = createMock(); 61 | return expect( 62 | authorizeCollections(config, mockClient) 63 | ).to.be.fulfilled.then(() => { 64 | expect(mockAdapter.history.post).to.have.lengthOf(1); 65 | expect(mockAdapter.history.post[0].url).to.eq("/collection/token/"); 66 | expect(mockAdapter.history.post[0].headers.Authorization).to.eq( 67 | "Basic " + Buffer.from("id:secret").toString("base64") 68 | ); 69 | }); 70 | }); 71 | }); 72 | 73 | describe("authorizeDisbursements", function() { 74 | it("makes the correct request", function() { 75 | const [mockClient, mockAdapter] = createMock(); 76 | return expect( 77 | authorizeDisbursements(config, mockClient) 78 | ).to.be.fulfilled.then(() => { 79 | expect(mockAdapter.history.post).to.have.lengthOf(1); 80 | expect(mockAdapter.history.post[0].url).to.eq("/disbursement/token/"); 81 | expect(mockAdapter.history.post[0].headers.Authorization).to.eq( 82 | "Basic " + Buffer.from("id:secret").toString("base64") 83 | ); 84 | }); 85 | }); 86 | }); 87 | 88 | describe("createBasicAuthToken", function() { 89 | it("encodes id and secret in base64", function() { 90 | expect( 91 | createBasicAuthToken({ 92 | userId: "id", 93 | userSecret: "secret" 94 | }) 95 | ).to.equal(Buffer.from("id:secret").toString("base64")); 96 | }); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /test/chai.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import chaiAsPromised from "chai-as-promised"; 3 | 4 | chai.use(chaiAsPromised); 5 | 6 | export = chai; 7 | -------------------------------------------------------------------------------- /test/client.test.ts: -------------------------------------------------------------------------------- 1 | import sinon from "sinon"; 2 | 3 | import { createAuthClient, createClient } from "../src/client"; 4 | import { expect } from "./chai"; 5 | import { createMock } from "./mock"; 6 | 7 | import { Config, Environment } from "../src/common"; 8 | 9 | describe("Client", function() { 10 | const config: Config = { 11 | environment: Environment.SANDBOX, 12 | baseUrl: "test", 13 | primaryKey: "key", 14 | userId: "id", 15 | userSecret: "secret" 16 | }; 17 | 18 | describe("createClient", function() { 19 | it("creates an axios instance with the right default headers", function() { 20 | const [mockClient] = createMock(); 21 | const client = createClient(config, mockClient); 22 | expect(client.defaults.headers).to.have.deep.property( 23 | "Ocp-Apim-Subscription-Key", 24 | "key" 25 | ); 26 | expect(client.defaults.headers).to.have.deep.property( 27 | "X-Target-Environment", 28 | "sandbox" 29 | ); 30 | }); 31 | 32 | it("makes requests with the right headers", function() { 33 | const [mockClient, mockAdapter] = createMock(); 34 | const client = createClient(config, mockClient); 35 | return expect(client.get("/test")).to.be.fulfilled.then(() => { 36 | expect(mockAdapter.history.get[0].headers).to.have.deep.property( 37 | "Ocp-Apim-Subscription-Key", 38 | "key" 39 | ); 40 | expect(mockAdapter.history.get[0].headers).to.have.deep.property( 41 | "X-Target-Environment", 42 | "sandbox" 43 | ); 44 | }); 45 | }); 46 | }); 47 | 48 | describe("createAuthClient", function() { 49 | it("makes requests with the right headers", function() { 50 | const [mockClient, mockAdapter] = createMock(); 51 | const refresher = sinon.fake.resolves("token"); 52 | const client = createAuthClient(refresher, mockClient); 53 | return expect(client.get("/test")).to.be.fulfilled.then(() => { 54 | expect(mockAdapter.history.get[0].headers).to.have.deep.property( 55 | "Authorization", 56 | "Bearer token" 57 | ); 58 | expect(refresher.callCount).to.eq(1); 59 | }); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/collections.test.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from "axios"; 2 | import MockAdapter from "axios-mock-adapter"; 3 | import { expect } from "chai"; 4 | 5 | import Collections from "../src/collections"; 6 | 7 | import { createMock } from "./mock"; 8 | 9 | import { PaymentRequest } from "../src/collections"; 10 | import { PartyIdType } from "../src/common"; 11 | 12 | describe("Collections", function() { 13 | let collections: Collections; 14 | let mockAdapter: MockAdapter; 15 | let mockClient: AxiosInstance; 16 | 17 | beforeEach(() => { 18 | [mockClient, mockAdapter] = createMock(); 19 | collections = new Collections(mockClient); 20 | }); 21 | 22 | describe("requestToPay", function() { 23 | context("when the amount is missing", function() { 24 | it("throws an error", function() { 25 | const request = {} as PaymentRequest; 26 | return expect(collections.requestToPay(request)).to.be.rejectedWith( 27 | "amount is required" 28 | ); 29 | }); 30 | }); 31 | 32 | context("when the amount is not numeric", function() { 33 | it("throws an error", function() { 34 | const request = { amount: "alphabetic" } as PaymentRequest; 35 | return expect(collections.requestToPay(request)).to.be.rejectedWith( 36 | "amount must be a number" 37 | ); 38 | }); 39 | }); 40 | 41 | context("when the currency is missing", function() { 42 | it("throws an error", function() { 43 | const request = { 44 | amount: "1000" 45 | } as PaymentRequest; 46 | return expect(collections.requestToPay(request)).to.be.rejectedWith( 47 | "currency is required" 48 | ); 49 | }); 50 | }); 51 | 52 | context("when the payer is missing", function() { 53 | it("throws an error", function() { 54 | const request = { 55 | amount: "1000", 56 | currency: "UGX" 57 | } as PaymentRequest; 58 | return expect(collections.requestToPay(request)).to.be.rejectedWith( 59 | "payer is required" 60 | ); 61 | }); 62 | }); 63 | 64 | context("when the party id is missing", function() { 65 | it("throws an error", function() { 66 | const request = { 67 | amount: "1000", 68 | currency: "UGX", 69 | payer: { 70 | } 71 | } as PaymentRequest; 72 | return expect(collections.requestToPay(request)).to.be.rejectedWith( 73 | "payer.partyId is required" 74 | ); 75 | }); 76 | }); 77 | 78 | context("when the party id type is missing", function() { 79 | it("throws an error", function() { 80 | const request = { 81 | amount: "1000", 82 | currency: "UGX", 83 | payer: { 84 | partyId: "xxx", 85 | } 86 | } as PaymentRequest; 87 | return expect(collections.requestToPay(request)).to.be.rejectedWith( 88 | "payer.partyIdType is required" 89 | ); 90 | }); 91 | }); 92 | 93 | it("makes the correct request", function() { 94 | const request: PaymentRequest = { 95 | amount: "50", 96 | currency: "EUR", 97 | externalId: "123456", 98 | payer: { 99 | partyIdType: PartyIdType.MSISDN, 100 | partyId: "256774290781" 101 | }, 102 | payerMessage: "testing", 103 | payeeNote: "hello" 104 | }; 105 | return expect( 106 | collections.requestToPay({ ...request, callbackUrl: "callback url" }) 107 | ).to.be.fulfilled.then(() => { 108 | expect(mockAdapter.history.post).to.have.lengthOf(1); 109 | expect(mockAdapter.history.post[0].url).to.eq( 110 | "/collection/v1_0/requesttopay" 111 | ); 112 | expect(mockAdapter.history.post[0].data).to.eq(JSON.stringify(request)); 113 | expect(mockAdapter.history.post[0].headers["X-Reference-Id"]).to.be.a( 114 | "string" 115 | ); 116 | expect(mockAdapter.history.post[0].headers["X-Callback-Url"]).to.eq( 117 | "callback url" 118 | ); 119 | }); 120 | }); 121 | }); 122 | 123 | describe("getTransaction", function() { 124 | it("makes the correct request", function() { 125 | return expect( 126 | collections.getTransaction("reference") 127 | ).to.be.fulfilled.then(() => { 128 | expect(mockAdapter.history.get).to.have.lengthOf(1); 129 | expect(mockAdapter.history.get[0].url).to.eq( 130 | "/collection/v1_0/requesttopay/reference" 131 | ); 132 | }); 133 | }); 134 | }); 135 | 136 | describe("getBalance", function() { 137 | it("makes the correct request", function() { 138 | return expect(collections.getBalance()).to.be.fulfilled.then(() => { 139 | expect(mockAdapter.history.get).to.have.lengthOf(1); 140 | expect(mockAdapter.history.get[0].url).to.eq( 141 | "/collection/v1_0/account/balance" 142 | ); 143 | }); 144 | }); 145 | }); 146 | 147 | describe("isPayerActive", function() { 148 | it("makes the correct request", function() { 149 | return expect( 150 | collections.isPayerActive("0772000000", PartyIdType.MSISDN) 151 | ).to.be.fulfilled.then(() => { 152 | expect(mockAdapter.history.get).to.have.lengthOf(1); 153 | expect(mockAdapter.history.get[0].url).to.eq( 154 | "/collection/v1_0/accountholder/MSISDN/0772000000/active" 155 | ); 156 | }); 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /test/disbursements.test.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from "axios"; 2 | import MockAdapter from "axios-mock-adapter"; 3 | import { expect } from "chai"; 4 | 5 | import Disbursements from "../src/disbursements"; 6 | 7 | import { createMock } from "./mock"; 8 | 9 | import { PartyIdType } from "../src/common"; 10 | import { TransferRequest } from "../src/disbursements"; 11 | 12 | describe("Disbursements", function() { 13 | let disbursements: Disbursements; 14 | let mockAdapter: MockAdapter; 15 | let mockClient: AxiosInstance; 16 | 17 | beforeEach(() => { 18 | [mockClient, mockAdapter] = createMock(); 19 | disbursements = new Disbursements(mockClient); 20 | }); 21 | 22 | describe("transfer", function() { 23 | context("when the amount is missing", function() { 24 | it("throws an error", function() { 25 | const request = {} as TransferRequest; 26 | return expect(disbursements.transfer(request)).to.be.rejectedWith( 27 | "amount is required" 28 | ); 29 | }); 30 | }); 31 | 32 | context("when the amount is not numeric", function() { 33 | it("throws an error", function() { 34 | const request = { amount: "alphabetic" } as TransferRequest; 35 | return expect(disbursements.transfer(request)).to.be.rejectedWith( 36 | "amount must be a number" 37 | ); 38 | }); 39 | }); 40 | 41 | context("when the currency is missing", function() { 42 | it("throws an error", function() { 43 | const request = { 44 | amount: "1000" 45 | } as TransferRequest; 46 | return expect(disbursements.transfer(request)).to.be.rejectedWith( 47 | "currency is required" 48 | ); 49 | }); 50 | }); 51 | 52 | context("when the payee is missing", function() { 53 | it("throws an error", function() { 54 | const request = { 55 | amount: "1000", 56 | currency: "UGX" 57 | } as TransferRequest; 58 | return expect(disbursements.transfer(request)).to.be.rejectedWith( 59 | "payee is required" 60 | ); 61 | }); 62 | }); 63 | 64 | context("when the party id is missing", function() { 65 | it("throws an error", function() { 66 | const request = { 67 | amount: "1000", 68 | currency: "UGX", 69 | payee: { 70 | } 71 | } as TransferRequest; 72 | return expect(disbursements.transfer(request)).to.be.rejectedWith( 73 | "payee.partyId is required" 74 | ); 75 | }); 76 | }); 77 | 78 | context("when the party id type is missing", function() { 79 | it("throws an error", function() { 80 | const request = { 81 | amount: "1000", 82 | currency: "UGX", 83 | payee: { 84 | partyId: "xxx", 85 | } 86 | } as TransferRequest; 87 | return expect(disbursements.transfer(request)).to.be.rejectedWith( 88 | "payee.partyIdType is required" 89 | ); 90 | }); 91 | }); 92 | 93 | it("makes the correct request", function() { 94 | const request: TransferRequest = { 95 | amount: "50", 96 | currency: "EUR", 97 | externalId: "123456", 98 | payee: { 99 | partyIdType: PartyIdType.MSISDN, 100 | partyId: "256774290781" 101 | }, 102 | payerMessage: "testing", 103 | payeeNote: "hello" 104 | }; 105 | return expect( 106 | disbursements.transfer({ ...request, callbackUrl: "callback url" }) 107 | ).to.be.fulfilled.then(() => { 108 | expect(mockAdapter.history.post).to.have.lengthOf(1); 109 | expect(mockAdapter.history.post[0].url).to.eq( 110 | "/disbursement/v1_0/transfer" 111 | ); 112 | expect(mockAdapter.history.post[0].data).to.eq(JSON.stringify(request)); 113 | expect(mockAdapter.history.post[0].headers["X-Reference-Id"]).to.be.a( 114 | "string" 115 | ); 116 | expect(mockAdapter.history.post[0].headers["X-Callback-Url"]).to.eq( 117 | "callback url" 118 | ); 119 | }); 120 | }); 121 | }); 122 | 123 | describe("getTransaction", function() { 124 | it("makes the correct request", function() { 125 | return expect( 126 | disbursements.getTransaction("reference") 127 | ).to.be.fulfilled.then(() => { 128 | expect(mockAdapter.history.get).to.have.lengthOf(1); 129 | expect(mockAdapter.history.get[0].url).to.eq( 130 | "/disbursement/v1_0/transfer/reference" 131 | ); 132 | }); 133 | }); 134 | }); 135 | 136 | describe("getBalance", function() { 137 | it("makes the correct request", function() { 138 | return expect(disbursements.getBalance()).to.be.fulfilled.then(() => { 139 | expect(mockAdapter.history.get).to.have.lengthOf(1); 140 | expect(mockAdapter.history.get[0].url).to.eq( 141 | "/disbursement/v1_0/account/balance" 142 | ); 143 | }); 144 | }); 145 | }); 146 | 147 | describe("ispayeeActive", function() { 148 | it("makes the correct request", function() { 149 | return expect( 150 | disbursements.isPayerActive("0772000000", PartyIdType.MSISDN) 151 | ).to.be.fulfilled.then(() => { 152 | expect(mockAdapter.history.get).to.have.lengthOf(1); 153 | expect(mockAdapter.history.get[0].url).to.eq( 154 | "/disbursement/v1_0/accountholder/msisdn/0772000000/active" 155 | ); 156 | }); 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /test/errors.test.ts: -------------------------------------------------------------------------------- 1 | import { FailureReason } from "../src/common"; 2 | import { 3 | ApprovalRejectedError, 4 | ExpiredError, 5 | getError, 6 | InternalProcessingError, 7 | InvalidCallbackUrlHostError, 8 | InvalidCurrencyError, 9 | NotAllowedError, 10 | NotAllowedTargetEnvironmentError, 11 | NotEnoughFundsError, 12 | PayeeNotAllowedToReceiveError, 13 | PayeeNotFoundError, 14 | PayerLimitReachedError, 15 | PayerNotFoundError, 16 | PaymentNotApprovedError, 17 | ResourceAlreadyExistError, 18 | ResourceNotFoundError, 19 | ServiceUnavailableError, 20 | TransactionCancelledError, 21 | UnspecifiedError 22 | } from "../src/errors"; 23 | import { expect } from "./chai"; 24 | 25 | describe("Errors", function() { 26 | describe("getError", function() { 27 | context("when there is no error code", function() { 28 | it("returns unspecified error", function() { 29 | expect(getError()).is.instanceOf(UnspecifiedError); 30 | }); 31 | }); 32 | 33 | context("when there is an error code", function() { 34 | it("returns the correct error", function() { 35 | expect(getError(FailureReason.APPROVAL_REJECTED, "test message")) 36 | .is.instanceOf(ApprovalRejectedError) 37 | .and.has.property("message", "test message"); 38 | 39 | expect(getError(FailureReason.EXPIRED, "test message")) 40 | .is.instanceOf(ExpiredError) 41 | .and.has.property("message", "test message"); 42 | 43 | expect( 44 | getError(FailureReason.INTERNAL_PROCESSING_ERROR, "test message") 45 | ) 46 | .is.instanceOf(InternalProcessingError) 47 | .and.has.property("message", "test message"); 48 | 49 | expect( 50 | getError(FailureReason.INVALID_CALLBACK_URL_HOST, "test message") 51 | ) 52 | .is.instanceOf(InvalidCallbackUrlHostError) 53 | .and.has.property("message", "test message"); 54 | 55 | expect(getError(FailureReason.INVALID_CURRENCY, "test message")) 56 | .is.instanceOf(InvalidCurrencyError) 57 | .and.has.property("message", "test message"); 58 | 59 | expect(getError(FailureReason.NOT_ALLOWED, "test message")) 60 | .is.instanceOf(NotAllowedError) 61 | .and.has.property("message", "test message"); 62 | 63 | expect( 64 | getError(FailureReason.NOT_ALLOWED_TARGET_ENVIRONMENT, "test message") 65 | ) 66 | .is.instanceOf(NotAllowedTargetEnvironmentError) 67 | .and.has.property("message", "test message"); 68 | 69 | expect(getError(FailureReason.NOT_ENOUGH_FUNDS, "test message")) 70 | .is.instanceOf(NotEnoughFundsError) 71 | .and.has.property("message", "test message"); 72 | 73 | expect( 74 | getError(FailureReason.PAYEE_NOT_ALLOWED_TO_RECEIVE, "test message") 75 | ) 76 | .is.instanceOf(PayeeNotAllowedToReceiveError) 77 | .and.has.property("message", "test message"); 78 | 79 | expect(getError(FailureReason.PAYEE_NOT_FOUND, "test message")) 80 | .is.instanceOf(PayeeNotFoundError) 81 | .and.has.property("message", "test message"); 82 | 83 | expect(getError(FailureReason.PAYER_LIMIT_REACHED, "test message")) 84 | .is.instanceOf(PayerLimitReachedError) 85 | .and.has.property("message", "test message"); 86 | 87 | expect(getError(FailureReason.PAYER_NOT_FOUND, "test message")) 88 | .is.instanceOf(PayerNotFoundError) 89 | .and.has.property("message", "test message"); 90 | 91 | expect(getError(FailureReason.PAYMENT_NOT_APPROVED, "test message")) 92 | .is.instanceOf(PaymentNotApprovedError) 93 | .and.has.property("message", "test message"); 94 | 95 | expect(getError(FailureReason.RESOURCE_ALREADY_EXIST, "test message")) 96 | .is.instanceOf(ResourceAlreadyExistError) 97 | .and.has.property("message", "test message"); 98 | 99 | expect(getError(FailureReason.RESOURCE_NOT_FOUND, "test message")) 100 | .is.instanceOf(ResourceNotFoundError) 101 | .and.has.property("message", "test message"); 102 | 103 | expect(getError(FailureReason.SERVICE_UNAVAILABLE, "test message")) 104 | .is.instanceOf(ServiceUnavailableError) 105 | .and.has.property("message", "test message"); 106 | 107 | expect(getError(FailureReason.TRANSACTION_CANCELED, "test message")) 108 | .is.instanceOf(TransactionCancelledError) 109 | .and.has.property("message", "test message"); 110 | }); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { AssertionError } from "assert"; 2 | 3 | import * as momo from "../src"; 4 | 5 | import { expect } from "./chai"; 6 | 7 | describe("MomoClient", function() { 8 | describe("#create", function() { 9 | context("when there is no callback host", function() { 10 | it("throws an error", function() { 11 | expect(momo.create.bind(null, {})).to.throw(AssertionError); 12 | }); 13 | }); 14 | 15 | context("when there is a callback host", function() { 16 | it("throws doesn't throw an error", function() { 17 | expect( 18 | momo.create.bind(null, { callbackHost: "example.com" }) 19 | ).to.not.throw(); 20 | }); 21 | 22 | it("returns a creator for Collections client", function() { 23 | expect(momo.create({ callbackHost: "example.com" })) 24 | .to.have.property("Collections") 25 | .that.is.a("function"); 26 | }); 27 | 28 | it("returns a creator for Disbursements client", function() { 29 | expect(momo.create({ callbackHost: "example.com" })) 30 | .to.have.property("Disbursements") 31 | .that.is.a("function"); 32 | }); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ts-node/register 2 | --require source-map-support/register 3 | --recursive 4 | --exit 5 | ./test/**/*.ts 6 | -------------------------------------------------------------------------------- /test/mock.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from "axios"; 2 | import MockAdapter from "axios-mock-adapter"; 3 | 4 | import { Payment } from "../src/collections"; 5 | import { AccessToken, Balance, Credentials } from "../src/common"; 6 | import { Transfer } from "../src/disbursements"; 7 | 8 | export function createMock(): [AxiosInstance, MockAdapter] { 9 | const client = axios.create({ 10 | headers: { 11 | "Content-Type": "application/json" 12 | } 13 | }); 14 | 15 | const mock = new MockAdapter(client); 16 | 17 | mock.onGet("/test").reply(200); 18 | 19 | mock.onPost("/v1_0/apiuser").reply(201); 20 | 21 | mock.onPost(/\/v1_0\/apiuser\/[\w\-]+\/apikey/).reply(200, { 22 | apiKey: "api-key" 23 | } as Credentials); 24 | 25 | mock.onPost("/collection/token/").reply(200, { 26 | access_token: "token", 27 | token_type: "access_token", 28 | expires_in: 3600 29 | } as AccessToken); 30 | 31 | mock 32 | .onGet(/\/collection\/v1_0\/accountholder\/(MSISDN|EMAIL|PARTY_CODE)\/\w+/) 33 | .reply(200, "true"); 34 | 35 | mock.onPost("/collection/v1_0/requesttopay").reply(201); 36 | 37 | mock.onGet(/\/collection\/v1_0\/requesttopay\/[\w\-]+/).reply(200, { 38 | financialTransactionId: "tx id", 39 | externalId: "string", 40 | amount: "2000", 41 | currency: "UGX", 42 | payer: { 43 | partyIdType: "MSISDN", 44 | partyId: "256772000000" 45 | }, 46 | payerMessage: "test", 47 | payeeNote: "test", 48 | status: "SUCCESSFUL" 49 | } as Payment); 50 | 51 | mock.onGet("/collection/v1_0/account/balance").reply(200, { 52 | availableBalance: "2000", 53 | currency: "UGX" 54 | } as Balance); 55 | 56 | mock.onPost("/disbursement/token/").reply(200, { 57 | access_token: "token", 58 | token_type: "access_token", 59 | expires_in: 3600 60 | } as AccessToken); 61 | 62 | mock 63 | .onGet( 64 | /\/disbursement\/v1_0\/accountholder\/(msisdn|email|party_code)\/\w+/ 65 | ) 66 | .reply(200, "true"); 67 | 68 | mock.onPost("/disbursement/v1_0/transfer").reply(201); 69 | 70 | mock.onGet(/\/disbursement\/v1_0\/transfer\/[\w\-]+/).reply(200, { 71 | financialTransactionId: "tx id", 72 | externalId: "string", 73 | amount: "2000", 74 | currency: "UGX", 75 | payee: { 76 | partyIdType: "MSISDN", 77 | partyId: "256772000000" 78 | }, 79 | status: "SUCCESSFUL" 80 | } as Transfer); 81 | 82 | mock.onGet("/disbursement/v1_0/account/balance").reply(200, { 83 | availableBalance: "2000", 84 | currency: "UGX" 85 | } as Balance); 86 | 87 | return [client, mock]; 88 | } 89 | -------------------------------------------------------------------------------- /test/users.test.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from "axios"; 2 | import MockAdapter from "axios-mock-adapter/types"; 3 | import { expect } from "./chai"; 4 | 5 | import Users from "../src/users"; 6 | import { createMock } from "./mock"; 7 | 8 | describe("Users", function() { 9 | let users: Users; 10 | let mockAdapter: MockAdapter; 11 | let mockClient: AxiosInstance; 12 | 13 | beforeEach(() => { 14 | [mockClient, mockAdapter] = createMock(); 15 | users = new Users(mockClient); 16 | }); 17 | 18 | describe("create", function() { 19 | it("makes the correct request", function() { 20 | return expect(users.create("host")).to.be.fulfilled.then(() => { 21 | expect(mockAdapter.history.post).to.have.lengthOf(1); 22 | expect(mockAdapter.history.post[0].url).to.eq("/v1_0/apiuser"); 23 | expect(mockAdapter.history.post[0].data).to.eq( 24 | JSON.stringify({ providerCallbackHost: "host" }) 25 | ); 26 | expect(mockAdapter.history.post[0].headers["X-Reference-Id"]).to.be.a( 27 | "string" 28 | ); 29 | }); 30 | }); 31 | }); 32 | 33 | describe("login", function() { 34 | it("makes the correct request", function() { 35 | return expect(users.login("id")).to.be.fulfilled.then(() => { 36 | expect(mockAdapter.history.post).to.have.lengthOf(1); 37 | expect(mockAdapter.history.post[0].url).to.eq( 38 | "/v1_0/apiuser/id/apikey" 39 | ); 40 | }); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/validate.test.ts: -------------------------------------------------------------------------------- 1 | import { AssertionError } from "assert"; 2 | import { v4 as uuid } from "uuid"; 3 | 4 | import { PaymentRequest } from "../src/collections"; 5 | import { expect } from "./chai"; 6 | 7 | import { Environment, PartyIdType, ProductConfig, SubscriptionConfig, UserConfig } from "../src/common"; 8 | import { TransferRequest } from "../src/disbursements"; 9 | import { 10 | validateGlobalConfig, 11 | validateProductConfig, 12 | validateRequestToPay, 13 | validateSubscriptionConfig, 14 | validateTransfer, 15 | validateUserConfig 16 | } from "../src/validate"; 17 | 18 | describe("Validate", function() { 19 | describe("validateGlobalConfig", function() { 20 | context("when callbackHost is not specified", function() { 21 | it("throws an error", function() { 22 | expect(validateGlobalConfig.bind(null, {})).to.throw( 23 | AssertionError, 24 | "callbackHost is required" 25 | ); 26 | }); 27 | }); 28 | 29 | context("when callbackHost is specified", function() { 30 | it("doesn't throw", function() { 31 | expect( 32 | validateGlobalConfig.bind(null, { callbackHost: "example.com" }) 33 | ).to.not.throw(); 34 | }); 35 | }); 36 | 37 | context("when environment is specified", function() { 38 | context("and is not sandbox", function() { 39 | context("and baseUrl is not specified", function() { 40 | it("throws", function() { 41 | expect( 42 | validateGlobalConfig.bind(null, { 43 | callbackHost: "example.com", 44 | environment: Environment.PRODUCTION 45 | }) 46 | ).to.throw( 47 | AssertionError, 48 | "baseUrl is required if environment is not sandbox" 49 | ); 50 | }); 51 | }); 52 | 53 | context("and baseUrl is specified", function() { 54 | it("doesn't throw", function() { 55 | expect( 56 | validateGlobalConfig.bind(null, { 57 | callbackHost: "example.com", 58 | environment: Environment.PRODUCTION, 59 | baseUrl: "mtn production base url" 60 | }) 61 | ).to.not.throw(); 62 | }); 63 | }); 64 | }); 65 | }); 66 | }); 67 | 68 | describe("validateProductConfig", function() { 69 | context("when primaryKey is not specified", function() { 70 | it("throws an error", function() { 71 | expect(validateProductConfig.bind(null, {} as ProductConfig)).to.throw( 72 | AssertionError, 73 | "primaryKey is required" 74 | ); 75 | }); 76 | }); 77 | 78 | context("when userId is not specified", function() { 79 | it("throws an error", function() { 80 | expect( 81 | validateProductConfig.bind(null, { 82 | primaryKey: "test primary key" 83 | } as ProductConfig) 84 | ).to.throw(AssertionError, "userId is required"); 85 | }); 86 | }); 87 | 88 | context("when userSecret is not specified", function() { 89 | it("throws an error", function() { 90 | expect( 91 | validateProductConfig.bind(null, { 92 | primaryKey: "test primary key", 93 | userId: "test user id" 94 | } as ProductConfig) 95 | ).to.throw(AssertionError, "userSecret is required"); 96 | }); 97 | }); 98 | 99 | context("when userId is not a valid uuid", function() { 100 | it("throws an error", function() { 101 | expect( 102 | validateProductConfig.bind(null, { 103 | primaryKey: "test primary key", 104 | userId: "test user id", 105 | userSecret: "test user secret" 106 | }) 107 | ).to.throw(AssertionError, "userId must be a valid uuid v4"); 108 | }); 109 | }); 110 | 111 | context("when the config is valid", function() { 112 | it("throws an error", function() { 113 | expect( 114 | validateProductConfig.bind(null, { 115 | primaryKey: "test primary key", 116 | userId: uuid(), 117 | userSecret: "test user secret" 118 | }) 119 | ).to.not.throw(); 120 | }); 121 | }); 122 | }); 123 | 124 | describe("validateSubscriptionConfig", function() { 125 | context("when primaryKey is not specified", function() { 126 | it("throws an error", function() { 127 | expect(validateSubscriptionConfig.bind(null, {} as SubscriptionConfig)).to.throw( 128 | AssertionError, 129 | "primaryKey is required" 130 | ); 131 | }); 132 | }); 133 | 134 | context("when primaryKey is specified", function() { 135 | it("throws an error", function() { 136 | expect( 137 | validateSubscriptionConfig.bind(null, { 138 | primaryKey: "test primary key" 139 | }) 140 | ).to.not.throw(); 141 | }); 142 | }); 143 | }); 144 | 145 | describe("validateUserConfig", function() { 146 | context("when userId is not specified", function() { 147 | it("throws an error", function() { 148 | expect(validateUserConfig.bind(null, {} as UserConfig)).to.throw( 149 | AssertionError, 150 | "userId is required" 151 | ); 152 | }); 153 | }); 154 | 155 | context("when userSecret is not specified", function() { 156 | it("throws an error", function() { 157 | expect( 158 | validateUserConfig.bind(null, { 159 | userId: "test user id" 160 | } as UserConfig) 161 | ).to.throw(AssertionError, "userSecret is required"); 162 | }); 163 | }); 164 | 165 | context("when userId is not a valid uuid", function() { 166 | it("throws an error", function() { 167 | expect( 168 | validateUserConfig.bind(null, { 169 | userId: "test user id", 170 | userSecret: "test user secret" 171 | }) 172 | ).to.throw(AssertionError, "userId must be a valid uuid v4"); 173 | }); 174 | }); 175 | 176 | context("when the config is valid", function() { 177 | it("throws an error", function() { 178 | expect( 179 | validateUserConfig.bind(null, { 180 | userId: uuid(), 181 | userSecret: "test user secret" 182 | }) 183 | ).to.not.throw(); 184 | }); 185 | }); 186 | }); 187 | 188 | describe("validateRequestToPay", function() { 189 | context("when the amount is missing", function() { 190 | it("throws an error", function() { 191 | const request = {} as PaymentRequest; 192 | return expect(validateRequestToPay(request)).to.be.rejectedWith( 193 | "amount is required" 194 | ); 195 | }); 196 | }); 197 | 198 | context("when the amount is not numeric", function() { 199 | it("throws an error", function() { 200 | const request = { amount: "alphabetic" } as PaymentRequest; 201 | return expect(validateRequestToPay(request)).to.be.rejectedWith( 202 | "amount must be a number" 203 | ); 204 | }); 205 | }); 206 | 207 | context("when the currency is missing", function() { 208 | it("throws an error", function() { 209 | const request = { 210 | amount: "1000" 211 | } as PaymentRequest; 212 | return expect(validateRequestToPay(request)).to.be.rejectedWith( 213 | "currency is required" 214 | ); 215 | }); 216 | }); 217 | 218 | context("when the payer is missing", function() { 219 | it("throws an error", function() { 220 | const request = { 221 | amount: "1000", 222 | currency: "UGX" 223 | } as PaymentRequest; 224 | return expect(validateRequestToPay(request)).to.be.rejectedWith( 225 | "payer is required" 226 | ); 227 | }); 228 | }); 229 | 230 | context("when the party id is missing", function() { 231 | it("throws an error", function() { 232 | const request = { 233 | amount: "1000", 234 | currency: "UGX", 235 | payer: {} 236 | } as PaymentRequest; 237 | return expect(validateRequestToPay(request)).to.be.rejectedWith( 238 | "payer.partyId is required" 239 | ); 240 | }); 241 | }); 242 | 243 | context("when the party id type is missing", function() { 244 | it("throws an error", function() { 245 | const request = { 246 | amount: "1000", 247 | currency: "UGX", 248 | payer: { 249 | partyId: "xxx" 250 | } 251 | } as PaymentRequest; 252 | return expect(validateRequestToPay(request)).to.be.rejectedWith( 253 | "payer.partyIdType is required" 254 | ); 255 | }); 256 | }); 257 | 258 | context("when the request is valid", function() { 259 | it("fulfills", function() { 260 | const request = { 261 | amount: "1000", 262 | currency: "UGX", 263 | payer: { 264 | partyId: "xxx", 265 | partyIdType: PartyIdType.MSISDN 266 | } 267 | } as PaymentRequest; 268 | return expect(validateRequestToPay(request)).to.be.fulfilled; 269 | }); 270 | }); 271 | }); 272 | 273 | describe("validateTransfer", function() { 274 | context("when the referenceId is missing", function() { 275 | it("throws an error", function() { 276 | const request = {} as TransferRequest; 277 | return expect(validateTransfer(request)).to.be.rejectedWith( 278 | "referenceId is required" 279 | ); 280 | }); 281 | }); 282 | 283 | context("when referenceId is not a valid uuid", function() { 284 | it("throws an error", function () { 285 | const request = { referenceId: "test reference id" } as TransferRequest; 286 | return expect(validateTransfer(request)).to.be.rejectedWith( 287 | "referenceId must be a valid uuid v4" 288 | ); 289 | }); 290 | }); 291 | 292 | context("when the amount is missing", function() { 293 | it("throws an error", function() { 294 | const request = { referenceId: uuid() } as TransferRequest; 295 | return expect(validateTransfer(request)).to.be.rejectedWith( 296 | "amount is required" 297 | ); 298 | }); 299 | }); 300 | 301 | context("when the amount is not numeric", function() { 302 | it("throws an error", function() { 303 | const request = { referenceId: uuid(), amount: "alphabetic" } as TransferRequest; 304 | return expect(validateTransfer(request)).to.be.rejectedWith( 305 | "amount must be a number" 306 | ); 307 | }); 308 | }); 309 | 310 | context("when the currency is missing", function() { 311 | it("throws an error", function() { 312 | const request = { 313 | referenceId: uuid(), 314 | amount: "1000" 315 | } as TransferRequest; 316 | return expect(validateTransfer(request)).to.be.rejectedWith( 317 | "currency is required" 318 | ); 319 | }); 320 | }); 321 | 322 | context("when the payee is missing", function() { 323 | it("throws an error", function() { 324 | const request = { 325 | referenceId: uuid(), 326 | amount: "1000", 327 | currency: "UGX" 328 | } as TransferRequest; 329 | return expect(validateTransfer(request)).to.be.rejectedWith( 330 | "payee is required" 331 | ); 332 | }); 333 | }); 334 | 335 | context("when the party id is missing", function() { 336 | it("throws an error", function() { 337 | const request = { 338 | referenceId: uuid(), 339 | amount: "1000", 340 | currency: "UGX", 341 | payee: {} 342 | } as TransferRequest; 343 | return expect(validateTransfer(request)).to.be.rejectedWith( 344 | "payee.partyId is required" 345 | ); 346 | }); 347 | }); 348 | 349 | context("when the party id type is missing", function() { 350 | it("throws an error", function() { 351 | const request = { 352 | referenceId: uuid(), 353 | amount: "1000", 354 | currency: "UGX", 355 | payee: { 356 | partyId: "xxx" 357 | } 358 | } as TransferRequest; 359 | return expect(validateTransfer(request)).to.be.rejectedWith( 360 | "payee.partyIdType is required" 361 | ); 362 | }); 363 | }); 364 | 365 | context("when the request is valid", function() { 366 | it("fulfills", function() { 367 | const request = { 368 | referenceId: uuid(), 369 | amount: "1000", 370 | currency: "UGX", 371 | payee: { 372 | partyId: "xxx", 373 | partyIdType: PartyIdType.MSISDN 374 | } 375 | } as TransferRequest; 376 | return expect(validateTransfer(request)).to.be.fulfilled; 377 | }); 378 | }); 379 | }); 380 | }); 381 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "lib": ["es2015", "es2016"], 7 | "outDir": "./lib", 8 | "rootDir": "./src", 9 | "strict": true, 10 | "esModuleInterop": true 11 | }, 12 | "exclude": ["lib", "node_modules", "test"] 13 | } 14 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended"], 3 | "rules": { 4 | "object-literal-sort-keys": false, 5 | "interface-name": false, 6 | "trailing-comma": false, 7 | "no-console": false, 8 | "arrow-parens": false, 9 | "only-arrow-functions": false, 10 | "no-var-requires": false, 11 | "ordered-imports": true, 12 | "max-classes-per-file": false 13 | } 14 | } 15 | --------------------------------------------------------------------------------