diff --git a/.github/workflows/coveralls.yml b/.github/workflows/coveralls.yml new file mode 100644 index 0000000..5849190 --- /dev/null +++ b/.github/workflows/coveralls.yml @@ -0,0 +1,33 @@ +name: Elixir Coveralls + +on: + push: + branches: + - develop + - master + pull_request: + branches: + - master + - develop + +jobs: + coveralls-coverage: + + runs-on: ubuntu-latest + env: + MIX_ENV: test + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - uses: actions/checkout@v2 + - name: Create dev file + run: mv config/dev.sample.exs config/dev.exs + - name: Setup elixir + uses: actions/setup-elixir@v1 + with: + elixir-version: '1.9.4' + otp-version: '22.2' + - name: Install Dependencies + run: mix deps.get + - name: Coverals Coverage + run: mix coveralls.github diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 2fe5e37..cfbc358 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -11,9 +11,13 @@ on: - develop jobs: - check-lint: + check-lint-and-test: runs-on: ubuntu-latest + strategy: + matrix: + otp: [21.3.8.10, 22.2] + elixir: [1.8.2, 1.9.4] steps: - uses: actions/checkout@v2 @@ -22,8 +26,8 @@ jobs: - name: Setup elixir uses: actions/setup-elixir@v1 with: - elixir-version: '1.9.4' - otp-version: '22.2' + otp-version: ${{matrix.otp}} + elixir-version: ${{matrix.elixir}} - name: Install Dependencies run: mix deps.get - name: Run Tests diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml index 74f59f9..c00a391 100644 --- a/.github/workflows/greetings.yml +++ b/.github/workflows/greetings.yml @@ -1,6 +1,6 @@ name: Greetings -on: [pull_request, issues] +on: [pull_request_target, issues] jobs: greeting: diff --git a/.github/workflows/hex-publish.yml b/.github/workflows/hex-publish.yml new file mode 100644 index 0000000..1dcb8be --- /dev/null +++ b/.github/workflows/hex-publish.yml @@ -0,0 +1,16 @@ +on: + push: + tags: + - '*' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v2 + + - name: Publish to Hex.pm + uses: erlangpack/github-action@v1 + env: + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1348ef9..8441fe3 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,6 @@ erl_crash.dump # Ignore package tarball (built via "mix hex.build"). ex_pesa-*.tar -config/dev.exs \ No newline at end of file +config/dev.exs +/.vscode +/.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index e073c1d..9d66488 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -##### [badges][badges] +

-# ExPesa +[![Actions Status](https://github.com/beamkenya/ex_pesa/workflows/Elixir%20CI/badge.svg)](https://github.com/beamkenya/ex_pesa/actions) ![Hex.pm](https://img.shields.io/hexpm/v/ex_pesa) ![Hex.pm](https://img.shields.io/hexpm/dt/ex_pesa) [![Coverage Status](https://coveralls.io/repos/github/beamkenya/ex_pesa/badge.svg?branch=develop)](https://coveralls.io/github/beamkenya/ex_pesa?branch=develop) -> Payment Library +# ExPesa :dollar: :pound: :yen: :euro: + +> Payment Library For Most Public Payment API's in Kenya and hopefully Africa. Let us get this :moneybag: ## Table of contents @@ -11,19 +13,29 @@ - [Configuration](#configuration) - [Documentation](#documentation) - [Contribution](#contribution) +- [Contributors](#contributors) - [Licence](#licence) ## Features -[WIP] - - [x] Mpesa - - [x] STK push - - [ ] B2C - - [ ] B2B - - [ ] C2B -- [ ] Equity + - [x] Mpesa Express (STK) + - [x] STK Transaction Validation + - [x] B2C + - [x] B2B + - [x] C2B + - [x] Reversal + - [x] Transaction Status + - [x] Account Balance +- [ ] JengaWS(Equity) + - [ ] Send Money + - [ ] Receive Payments + - [ ] Buy Goods, Pay Bills, Get Airtime + - [ ] Credit + - [ ] Reg Tech: KYC, AML, & CDD API + - [ ] Account Services - [ ] Paypal +- [ ] Card ## Installation @@ -33,19 +45,24 @@ by adding `ex_pesa` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:ex_pesa, "~> 0.1.0"} + {:ex_pesa, "~> 0.1.1"} ] end ``` ## Configuration -Create a copy of `config/dev.exs` or `config/prod.exs` form `config/dev.sample.exs` +Create a copy of `config/dev.exs` or `config/prod.exs` from `config/dev.sample.exs` +Use the `sandbox` key to `true` when you are using sandbox credentials, chnage to `false` when going to `:prod` ### Mpesa (Daraja) -Add below config to dec.exs / prod.exs files -This asumes you have a clear understanding of how Daraja API works. +Mpesa Daraja API link: https://developer.safaricom.co.ke + +Add below config to dev.exs / prod.exs files +This asumes you have a clear understanding of how [Daraja API works](https://developer.safaricom.co.ke/get-started). + +You can also refer to this Safaricom Daraja API Tutorial: https://peternjeru.co.ke/safdaraja/ui/ by Peter Njeru ```elixir config :ex_pesa, @@ -62,6 +79,22 @@ config :ex_pesa, The docs can be found at [https://hexdocs.pm/ex_pesa](https://hexdocs.pm/ex_pesa). +### Quick Examples + +#### Mpesa Express (STK) + +```elixir + iex> ExPesa.Mpesa.Stk.request(%{amount: 10, phone: "254724540000", reference: "reference", description: "description"}) + {:ok, + %{ + "CheckoutRequestID" => "ws_CO_010320202011179845", + "CustomerMessage" => "Success. Request accepted for processing", + "MerchantRequestID" => "25558-10595705-4", + "ResponseCode" => "0", + "ResponseDescription" => "Success. Request accepted for processing" + }} +``` + ## Contribution If you'd like to contribute, start by searching through the [issues](https://github.com/beamkenya/ex_pesa/issues) and [pull requests](https://github.com/beamkenya/ex_pesa/pulls) to see whether someone else has raised a similar idea or question. @@ -69,6 +102,15 @@ If you don't see your idea listed, [Open an issue](https://github.com/beamkenya/ Check the [Contribution guide](contributing.md) on how to contribute. +## Contributors + +Auto-populated from: +[contributors-img](https://contributors-img.firebaseapp.com/image?repo=beamkenya/ex_pesa) + + + + + ## Licence ExPesa is released under [MIT License](https://github.com/appcues/exsentry/blob/master/LICENSE.txt) diff --git a/assets/logo.jpg b/assets/logo.jpg new file mode 100644 index 0000000..972d28f Binary files /dev/null and b/assets/logo.jpg differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..4803640 Binary files /dev/null and b/assets/logo.png differ diff --git a/config/dev.sample.exs b/config/dev.sample.exs index 31d9a5d..cd613cd 100644 --- a/config/dev.sample.exs +++ b/config/dev.sample.exs @@ -2,11 +2,57 @@ use Mix.Config config :tesla, adapter: Tesla.Adapter.Hackney config :ex_pesa, - force_live_url: "NO", + # When changed to "false" one will use the live endpoint url + sandbox: true, mpesa: [ consumer_key: "72yw1nun6g1QQPPgOsAObCGSfuimGO7b", consumer_secret: "vRzZiD5RllMLIdLD", mpesa_short_code: "174379", + # had to pass this for registerurl to work for C2B + c2b_short_code: "600247", mpesa_passkey: "bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919", - mpesa_callback_url: "http://91eb0af5.ngrok.io/api/payment/callback" + mpesa_callback_url: "http://91eb0af5.ngrok.io/api/payment/callback", + cert: + "-----BEGIN CERTIFICATE-----\nMIIGKzCCBROgAwIBAgIQDL7NH8cxSdUpl0ihH0A1wTANBgkqhkiG9w0BAQsFADBN\nMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E\naWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMTgwODI3MDAwMDAwWhcN\nMTkwNDA0MTIwMDAwWjBuMQswCQYDVQQGEwJLRTEQMA4GA1UEBxMHTmFpcm9iaTEW\nMBQGA1UEChMNU2FmYXJpY29tIFBMQzETMBEGA1UECxMKRGlnaXRhbCBJVDEgMB4G\nA1UEAxMXc2FuZGJveC5zYWZhcmljb20uY28ua2UwggEiMA0GCSqGSIb3DQEBAQUA\nA4IBDwAwggEKAoIBAQC78yeC/wLoZY6TJeqc4g/9eAKIpeCwEsjX09pD8ZxAGXqT\nOi7ssdIGJBPmJZNeEVyf8ocFhisCuLngJ9Z5e/AvH52PhrEFmVu2D03zSf4C+rhZ\nndEKP6G79pUAb/bemOliU9zM8xYYkpCRzPWUzk6zSDarg0ZDLw5FrtZj/VJ9YEDL\nWGgAfwExEgSN3wjyUlJ2UwI3wqQXLka0VNFWoZxUH5j436gbSWRIL6NJUmrq8V8S\naTEPz3eJHj3NOToDu245c7VKdF/KExyZjRjD2p5I+Aip80TXzKlZj6DjMb3DlfXF\nHsnu0+1uJE701mvKX7BiscxKr8tCRphL63as4dqvAgMBAAGjggLkMIIC4DAfBgNV\nHSMEGDAWgBQPgGEcgjFh1S8o541GOLQs4cbZ4jAdBgNVHQ4EFgQUzZmY7ZORLw9w\nqRbAQN5m9lJ28qMwIgYDVR0RBBswGYIXc2FuZGJveC5zYWZhcmljb20uY28ua2Uw\nDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBr\nBgNVHR8EZDBiMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vc3NjYS1z\naGEyLWc2LmNybDAvoC2gK4YpaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NzY2Et\nc2hhMi1nNi5jcmwwTAYDVR0gBEUwQzA3BglghkgBhv1sAQEwKjAoBggrBgEFBQcC\nARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAIBgZngQwBAgIwfAYIKwYB\nBQUHAQEEcDBuMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20w\nRgYIKwYBBQUHMAKGOmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy\ndFNIQTJTZWN1cmVTZXJ2ZXJDQS5jcnQwCQYDVR0TBAIwADCCAQUGCisGAQQB1nkC\nBAIEgfYEgfMA8QB2AKS5CZC0GFgUh7sTosxncAo8NZgE+RvfuON3zQ7IDdwQAAAB\nZXs1FvEAAAQDAEcwRQIgBzVMkm7SNprjJ1GBqiXIc9rNzY+y7gt6s/O02oMkyFoC\nIQDBuThGlpmUKpeZoHhK6HGwB4jDMIecmKaOcMS18R2jxwB3AId1v+dZfPiMQ5lf\nvfNu/1aNR1Y2/0q1YMG06v9eoIMPAAABZXs1F8IAAAQDAEgwRgIhAIRq2XFiC+RS\nuDCYq8ICJg0QafSV+e9BLpJnElEdaSjiAiEAyiiW4vxwv4cWcAXE6FAipctyUBs6\nbE5QyaCnmNpoDiQwDQYJKoZIhvcNAQELBQADggEBAB0YoWve9Sxhb0PBS3Hc46Rf\na7H1jhHuwE+UyscSQsdJdk8uPAgDuKRZMvJPGEaCkNHm36NfcaXXFjPOl7LI1d1a\n9zqSP0xeZBI6cF0x96WuQGrI9/WR2tfxjmaUSp8a/aJ6n+tZA28eJZNPrIaMm+6j\ngh7AkKnqcf+g8F/MvCCVdNAiVMdz6UpCscf6BRPHNZ5ifvChGh7aUKjrVLLuF4Ls\nHE05qm6HNyV5eTa6wvcbc4ewguN1UDZvPWetSyfBk10Wbpor4znQ4TJ3Y9uCvsJH\n41ldblDvZZ2z4kB2UYQ7iBkPlJSxSOaFgW/GGDXq49sz/995xzhVITHxh2SdLkI=\n-----END CERTIFICATE-----\n", + b2b: [ + short_code: "600247", + initiator_name: "John Doe", + password: "Safaricom133", + timeout_url: "https://58cb49b30213.ngrok.io/b2b/timeout_url", + result_url: "https://58cb49b30213.ngrok.io/b2b/result_url", + security_credential: + "kxlZ1Twlfr6xQru0GId03LbegvuTPelnz3qITkvUJxaCTQt1HaD2hN801Pgbi38x6dEq/hsanBBtfj6JbePayipE/srOyMQ61ieiO+5uHb/JX/NLi1Jy6Alvi0hKbCbq9cVwC/bZBhli7AUAtpfKVgIyXq2InNyfzXpzR8FhrbXiaMhTPJ8WleozPm5CnXe2bFlFP7K0yhCRlT+UOPl7xh0LqU23rMTj3TN/ms600c3j/m2FxQZdmY5/rdORrJeTQV1vw6kXr1QrGaSDSdRMUiaGbg5uPL8LSNwC5bn3M92oPY2cWmkyH9rOzbCN+o5+23TvweaKZlrKuv7etKXMFg==" + ], + b2c: [ + short_code: "600247", + initiator_name: "John Doe", + password: "Safaricom133", + timeout_url: "https://58cb49b30213.ngrok.io/b2c/timeout_url", + result_url: "https://58cb49b30213.ngrok.io/b2c/result_url" + ], + balance: [ + short_code: "602843", + initiator_name: "Safaricom1", + password: "Safaricom133", + timeout_url: "https://58cb49b30213.ngrok.io/b2b/timeout_url", + result_url: "https://58cb49b30213.ngrok.io/b2b/result_url", + security_credential: + "kxlZ1Twlfr6xQru0GId03LbegvuTPelnz3qITkvUJxaCTQt1HaD2hN801Pgbi38x6dEq/hsanBBtfj6JbePayipE/srOyMQ61ieiO+5uHb/JX/NLi1Jy6Alvi0hKbCbq9cVwC/bZBhli7AUAtpfKVgIyXq2InNyfzXpzR8FhrbXiaMhTPJ8WleozPm5CnXe2bFlFP7K0yhCRlT+UOPl7xh0LqU23rMTj3TN/ms600c3j/m2FxQZdmY5/rdORrJeTQV1vw6kXr1QrGaSDSdRMUiaGbg5uPL8LSNwC5bn3M92oPY2cWmkyH9rOzbCN+o5+23TvweaKZlrKuv7etKXMFg==" + ], + transaction_status: [ + initiator_name: "John Doe", + password: "Safaricom133", + timeout_url: "https://58cb49b30213.ngrok.io/transaction/timeout_url", + result_url: "https://58cb49b30213.ngrok.io/transaction/result_url", + security_credential: "" + ], + reversal: [ + short_code: "600247", + initiator_name: "Jane Doe", + password: "superStrong@1", + timeout_url: "https://58cb49b30213.ngrok.io/reversal/timeout_url", + result_url: "https://58cb49b30213.ngrok.io/reversal/result_url", + security_credential: + "kxlZ1Twlfr6xQru0GId03LbegvuTPelnz3qITkvUJxaCTQt1HaD2hN801Pgbi38x6dEq/hsanBBtfj6JbePayipE/srOyMQ61ieiO+5uHb/JX/NLi1Jy6Alvi0hKbCbq9cVwC/bZBhli7AUAtpfKVgIyXq2InNyfzXpzR8FhrbXiaMhTPJ8WleozPm5CnXe2bFlFP7K0yhCRlT+UOPl7xh0LqU23rMTj3TN/ms600c3j/m2FxQZdmY5/rdORrJeTQV1vw6kXr1QrGaSDSdRMUiaGbg5uPL8LSNwC5bn3M92oPY2cWmkyH9rOzbCN+o5+23TvweaKZlrKuv7etKXMFg==" + ] ] diff --git a/config/test.exs b/config/test.exs index 699d37b..946ba13 100644 --- a/config/test.exs +++ b/config/test.exs @@ -2,11 +2,55 @@ use Mix.Config config :tesla, adapter: Tesla.Mock config :ex_pesa, - force_live_url: "NO", + sandbox: true, mpesa: [ consumer_key: "72yw1nun6g1QQPPgOsAObCGSfuimGO7b", consumer_secret: "vRzZiD5RllMLIdLD", mpesa_short_code: "174379", mpesa_passkey: "bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919", - mpesa_callback_url: "http://91eb0af5.ngrok.io/api/payment/callback" + mpesa_callback_url: "http://91eb0af5.ngrok.io/api/payment/callback", + cert: + "-----BEGIN CERTIFICATE-----\nMIIGKzCCBROgAwIBAgIQDL7NH8cxSdUpl0ihH0A1wTANBgkqhkiG9w0BAQsFADBN\nMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E\naWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMTgwODI3MDAwMDAwWhcN\nMTkwNDA0MTIwMDAwWjBuMQswCQYDVQQGEwJLRTEQMA4GA1UEBxMHTmFpcm9iaTEW\nMBQGA1UEChMNU2FmYXJpY29tIFBMQzETMBEGA1UECxMKRGlnaXRhbCBJVDEgMB4G\nA1UEAxMXc2FuZGJveC5zYWZhcmljb20uY28ua2UwggEiMA0GCSqGSIb3DQEBAQUA\nA4IBDwAwggEKAoIBAQC78yeC/wLoZY6TJeqc4g/9eAKIpeCwEsjX09pD8ZxAGXqT\nOi7ssdIGJBPmJZNeEVyf8ocFhisCuLngJ9Z5e/AvH52PhrEFmVu2D03zSf4C+rhZ\nndEKP6G79pUAb/bemOliU9zM8xYYkpCRzPWUzk6zSDarg0ZDLw5FrtZj/VJ9YEDL\nWGgAfwExEgSN3wjyUlJ2UwI3wqQXLka0VNFWoZxUH5j436gbSWRIL6NJUmrq8V8S\naTEPz3eJHj3NOToDu245c7VKdF/KExyZjRjD2p5I+Aip80TXzKlZj6DjMb3DlfXF\nHsnu0+1uJE701mvKX7BiscxKr8tCRphL63as4dqvAgMBAAGjggLkMIIC4DAfBgNV\nHSMEGDAWgBQPgGEcgjFh1S8o541GOLQs4cbZ4jAdBgNVHQ4EFgQUzZmY7ZORLw9w\nqRbAQN5m9lJ28qMwIgYDVR0RBBswGYIXc2FuZGJveC5zYWZhcmljb20uY28ua2Uw\nDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBr\nBgNVHR8EZDBiMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vc3NjYS1z\naGEyLWc2LmNybDAvoC2gK4YpaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NzY2Et\nc2hhMi1nNi5jcmwwTAYDVR0gBEUwQzA3BglghkgBhv1sAQEwKjAoBggrBgEFBQcC\nARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAIBgZngQwBAgIwfAYIKwYB\nBQUHAQEEcDBuMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20w\nRgYIKwYBBQUHMAKGOmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy\ndFNIQTJTZWN1cmVTZXJ2ZXJDQS5jcnQwCQYDVR0TBAIwADCCAQUGCisGAQQB1nkC\nBAIEgfYEgfMA8QB2AKS5CZC0GFgUh7sTosxncAo8NZgE+RvfuON3zQ7IDdwQAAAB\nZXs1FvEAAAQDAEcwRQIgBzVMkm7SNprjJ1GBqiXIc9rNzY+y7gt6s/O02oMkyFoC\nIQDBuThGlpmUKpeZoHhK6HGwB4jDMIecmKaOcMS18R2jxwB3AId1v+dZfPiMQ5lf\nvfNu/1aNR1Y2/0q1YMG06v9eoIMPAAABZXs1F8IAAAQDAEgwRgIhAIRq2XFiC+RS\nuDCYq8ICJg0QafSV+e9BLpJnElEdaSjiAiEAyiiW4vxwv4cWcAXE6FAipctyUBs6\nbE5QyaCnmNpoDiQwDQYJKoZIhvcNAQELBQADggEBAB0YoWve9Sxhb0PBS3Hc46Rf\na7H1jhHuwE+UyscSQsdJdk8uPAgDuKRZMvJPGEaCkNHm36NfcaXXFjPOl7LI1d1a\n9zqSP0xeZBI6cF0x96WuQGrI9/WR2tfxjmaUSp8a/aJ6n+tZA28eJZNPrIaMm+6j\ngh7AkKnqcf+g8F/MvCCVdNAiVMdz6UpCscf6BRPHNZ5ifvChGh7aUKjrVLLuF4Ls\nHE05qm6HNyV5eTa6wvcbc4ewguN1UDZvPWetSyfBk10Wbpor4znQ4TJ3Y9uCvsJH\n41ldblDvZZ2z4kB2UYQ7iBkPlJSxSOaFgW/GGDXq49sz/995xzhVITHxh2SdLkI=\n-----END CERTIFICATE-----\n", + b2b: [ + short_code: "600247", + initiator_name: "John Doe", + password: "Safaricom133", + timeout_url: "https://58cb49b30213.ngrok.io/b2b/timeout_url", + result_url: "https://58cb49b30213.ngrok.io/b2b/result_url", + # Leace blank to increase util test coverage + security_credential: "" + ], + b2c: [ + short_code: "600247", + initiator_name: "John Doe", + password: "Safaricom133", + timeout_url: "https://58cb49b30213.ngrok.io/b2c/timeout_url", + result_url: "https://58cb49b30213.ngrok.io/b2c/result_url" + ], + balance: [ + short_code: "600247", + initiator_name: "Safaricom1", + password: "Safaricom133", + timeout_url: "https://58cb49b30213.ngrok.io/b2b/timeout_url", + result_url: "https://58cb49b30213.ngrok.io/b2b/result_url", + security_credential: + "kxlZ1Twlfr6xQru0GId03LbegvuTPelnz3qITkvUJxaCTQt1HaD2hN801Pgbi38x6dEq/hsanBBtfj6JbePayipE/srOyMQ61ieiO+5uHb/JX/NLi1Jy6Alvi0hKbCbq9cVwC/bZBhli7AUAtpfKVgIyXq2InNyfzXpzR8FhrbXiaMhTPJ8WleozPm5CnXe2bFlFP7K0yhCRlT+UOPl7xh0LqU23rMTj3TN/ms600c3j/m2FxQZdmY5/rdORrJeTQV1vw6kXr1QrGaSDSdRMUiaGbg5uPL8LSNwC5bn3M92oPY2cWmkyH9rOzbCN+o5+23TvweaKZlrKuv7etKXMFg==" + ], + transaction_status: [ + initiator_name: "John Doe", + password: "Safaricom133", + timeout_url: "https://58cb49b30213.ngrok.io/transaction/timeout_url", + result_url: "https://58cb49b30213.ngrok.io/transaction/result_url", + security_credential: + "kxlZ1Twlfr6xQru0GId03LbegvuTPelnz3qITkvUJxaCTQt1HaD2hN801Pgbi38x6dEq/hsanBBtfj6JbePayipE/srOyMQ61ieiO+5uHb/JX/NLi1Jy6Alvi0hKbCbq9cVwC/bZBhli7AUAtpfKVgIyXq2InNyfzXpzR8FhrbXiaMhTPJ8WleozPm5CnXe2bFlFP7K0yhCRlT+UOPl7xh0LqU23rMTj3TN/ms600c3j/m2FxQZdmY5/rdORrJeTQV1vw6kXr1QrGaSDSdRMUiaGbg5uPL8LSNwC5bn3M92oPY2cWmkyH9rOzbCN+o5+23TvweaKZlrKuv7etKXMFg==" + ], + reversal: [ + short_code: "600247", + initiator_name: "Jane Doe", + password: "superStrong@1", + timeout_url: "https://58cb49b30213.ngrok.io/reversal/timeout_url", + result_url: "https://58cb49b30213.ngrok.io/reversal/result_url", + security_credential: + "kxlZ1Twlfr6xQru0GId03LbegvuTPelnz3qITkvUJxaCTQt1HaD2hN801Pgbi38x6dEq/hsanBBtfj6JbePayipE/srOyMQ61ieiO+5uHb/JX/NLi1Jy6Alvi0hKbCbq9cVwC/bZBhli7AUAtpfKVgIyXq2InNyfzXpzR8FhrbXiaMhTPJ8WleozPm5CnXe2bFlFP7K0yhCRlT+UOPl7xh0LqU23rMTj3TN/ms600c3j/m2FxQZdmY5/rdORrJeTQV1vw6kXr1QrGaSDSdRMUiaGbg5uPL8LSNwC5bn3M92oPY2cWmkyH9rOzbCN+o5+23TvweaKZlrKuv7etKXMFg==" + ] ] diff --git a/lib/ex_pesa.ex b/lib/ex_pesa.ex index 03df549..f66eb4f 100644 --- a/lib/ex_pesa.ex +++ b/lib/ex_pesa.ex @@ -1,17 +1,6 @@ defmodule ExPesa do - @moduledoc """ - Documentation for `ExPesa`. - """ + @moduledoc false - @doc """ - Hello world. - - ## Examples - - iex> ExPesa.hello() - :world - - """ def hello do :world end diff --git a/lib/ex_pesa/Mpesa/account_balance.ex b/lib/ex_pesa/Mpesa/account_balance.ex new file mode 100644 index 0000000..3fa7fe9 --- /dev/null +++ b/lib/ex_pesa/Mpesa/account_balance.ex @@ -0,0 +1,75 @@ +defmodule ExPesa.Mpesa.AccountBalance do + @moduledoc """ + The Account Balance API requests for the account balance of a shortcode. + """ + import ExPesa.Mpesa.MpesaBase + import ExPesa.Util + + @doc """ + Initiates account balnce request + + ## Configuration + + Add below config to dev.exs / prod.exs files + This asumes you have a clear understanding of how Daraja API works. See docs here https://developer.safaricom.co.ke/docs#account-balance-api + + `config.exs` + ```elixir + config :ex_pesa, + mpesa: [ + cert: "", + balance: [ + short_code: "", + initiator_name: "Safaricom1", + password: "Safaricom133", + timeout_url: "", + result_url: "", + security_credential: "" + ] + ] + ``` + To generate security_credential, head over to https://developer.safaricom.co.ke/test_credentials, then Initiator Security Password for your environment. + Alternatively, generate security credential using certificate + `cert` - This is the M-Pesa public key certificate used to encrypt your plain password. + There are 2 types of certificates. + - sandox - https://developer.safaricom.co.ke/sites/default/files/cert/cert_sandbox/cert.cer . + - production - https://developer.safaricom.co.ke/sites/default/files/cert/cert_prod/cert.cer . + `password` - This is a plain unencrypted password. + Environment + - production - set password from the organization portal. + - sandbox - use your own custom password + + ## Example + + iex> ExPesa.Mpesa.AccountBalance.request() + {:ok, + %{ + "ConversationID" => "AG_20201010_00007d6021022d396df6", + "OriginatorConversationID" => "28290-142922216-2", + "ResponseCode" => "0", + "ResponseDescription" => "Accept the service request successfully." + }} + """ + + def request() do + case get_security_credential_for(:balance) do + nil -> {:error, "cannot generate security_credential due to missing configuration fields"} + security_credential -> account_balance(security_credential) + end + end + + defp account_balance(security_credential) do + payload = %{ + "Initiator" => Application.get_env(:ex_pesa, :mpesa)[:balance][:initiator_name], + "SecurityCredential" => security_credential, + "CommandID" => "AccountBalance", + "PartyA" => Application.get_env(:ex_pesa, :mpesa)[:balance][:short_code], + "IdentifierType" => 4, + "Remarks" => "Checking account balance", + "QueueTimeOutURL" => Application.get_env(:ex_pesa, :mpesa)[:balance][:timeout_url], + "ResultURL" => Application.get_env(:ex_pesa, :mpesa)[:balance][:result_url] + } + + make_request("/mpesa/accountbalance/v1/query", payload) + end +end diff --git a/lib/ex_pesa/Mpesa/b2b.ex b/lib/ex_pesa/Mpesa/b2b.ex new file mode 100644 index 0000000..025b344 --- /dev/null +++ b/lib/ex_pesa/Mpesa/b2b.ex @@ -0,0 +1,128 @@ +defmodule ExPesa.Mpesa.B2B do + @moduledoc """ + This API enables Business to Business (B2B) transactions between a business and another business. + Use of this API requires a valid and verified B2B M-Pesa short code for the business initiating the transaction and the both businesses involved in the transaction. + """ + import ExPesa.Mpesa.MpesaBase + import ExPesa.Util + + @doc """ + Initiates the Mpesa B2B . + + ## Configuration + Add below config to dev.exs / prod.exs files + This asumes you have a clear understanding of how Daraja API works. See docs here https://developer.safaricom.co.ke/docs#b2b-api + + #### B2B - Configuration Parameters + - `initiator` - This is the credential/username used to authenticate the transaction request. + Environment + - production + - create a user with api access method (access channel) + - Enter user name + - assign business manager role and B2B ORG API initiator role. + Use the username from your notifitation channel (SMS) + - sandbox - use your own custom username. + + - `timeout_url` - The path that stores information of time out transactions.it should be properly validated to make sure that it contains the port, URI and domain name or publicly available IP. + - `result_url` - The path that receives results from M-Pesa it should be properly validated to make sure that it contains the port, URI and domain name or publicly available IP. + - `security credential` - To generate security_credential, head over to https://developer.safaricom.co.ke/test_credentials, then Initiator Security Password for your environment. + + `config.exs` + ```elixir + config :ex_pesa, + mpesa: [ + b2b: [ + short_code: "", + initiator_name: "", + timeout_url: "", + result_url: "", + security_credential: "" + ] + ] + ``` + + Alternatively, generate security credential using certificate + `cert` - This is the M-Pesa public key certificate used to encrypt your plain password. + There are 2 types of certificates. + - sandox - https://developer.safaricom.co.ke/sites/default/files/cert/cert_sandbox/cert.cer . + - production - https://developer.safaricom.co.ke/sites/default/files/cert/cert_prod/cert.cer . + `password` - This is a plain unencrypted password. + Environment + - production - set password from the organization portal. + - sandbox - use your own custom password + + `config.exs` + ```elixir + config :ex_pesa, + mpesa: [ + cert: "" + b2b: [ + short_code: "", + initiator_name: "", + password: "", + timeout_url: "", + result_url: "" + ] + ] + ``` + + ## Parameters + + attrs: - a map containing: + - `command_id` - Unique command for each transaction type, possible values are: BusinessPayBill, MerchantToMerchantTransfer, MerchantTransferFromMerchantToWorking, MerchantServicesMMFAccountTransfer, AgencyFloatAdvance. + - `amount` - The amount being transacted. + - `receiver_party` - Organization’s short code receiving the funds being transacted. + - `remarks` - Comments that are sent along with the transaction. + - `account_reference` - Account Reference mandatory for "BusinessPaybill" CommandID. + + ## Example + iex> ExPesa.Mpesa.B2B.request(%{command_id: "BusinessPayBill", amount: 10500, receiver_party: 600000, remarks: "B2B Request", account_reference: "BILL PAYMENT"}) + {:ok, + %{ + "ConversationID" => "AG_20200927_00007d4c98884c889b25", + "OriginatorConversationID" => "27274-37744848-4", + "ResponseCode" => "0", + "ResponseDescription" => "Accept the service request successfully." + }} + """ + + def request(params) do + case get_security_credential_for(:b2b) do + nil -> {:error, "cannot generate security_credential due to missing configuration fields"} + security_credential -> b2b_request(security_credential, params) + end + end + + defp b2b_request( + security_credential, + %{ + command_id: command_id, + amount: amount, + receiver_party: receiver_party, + remarks: remarks + } = params + ) do + account_reference = Map.get(params, :account_reference, nil) + + payload = %{ + "Initiator" => Application.get_env(:ex_pesa, :mpesa)[:b2b][:initiator_name], + "SecurityCredential" => security_credential, + "CommandID" => command_id, + "Amount" => amount, + "PartyA" => Application.get_env(:ex_pesa, :mpesa)[:b2b][:short_code], + "SenderIdentifierType" => 4, + "PartyB" => receiver_party, + "RecieverIdentifierType" => 4, + "Remarks" => remarks, + "AccountReference" => account_reference, + "QueueTimeOutURL" => Application.get_env(:ex_pesa, :mpesa)[:b2b][:timeout_url], + "ResultURL" => Application.get_env(:ex_pesa, :mpesa)[:b2b][:result_url] + } + + make_request("/mpesa/b2b/v1/paymentrequest", payload) + end + + defp b2b_request(_security_credential, _) do + {:error, "Required Parameter missing, 'command_id','amount','receiver_party', 'remarks'"} + end +end diff --git a/lib/ex_pesa/Mpesa/b2c.ex b/lib/ex_pesa/Mpesa/b2c.ex new file mode 100644 index 0000000..2be41bf --- /dev/null +++ b/lib/ex_pesa/Mpesa/b2c.ex @@ -0,0 +1,101 @@ +defmodule ExPesa.Mpesa.B2c do + @moduledoc """ + Business to Customer (B2C) API enables the Business or organization to pay its customers who are the end-users of its products or services. + Currently, the B2C API allows the org to perform around 3 types of transactions: SalaryPayments, BusinessPayments or Promotion payments. + Salary payments are used by organizations paying their employees via M-Pesa, Business Payments are normal business transactions to customers + e.g. bank transfers to mobile, Promotion payments are payments made by organization carrying out promotional services e.g. + betting companies paying out winnings to clients + """ + + import ExPesa.Mpesa.MpesaBase + import ExPesa.Util + + @doc """ + Initiates the Mpesa B2C . + + ## Configuration + Add below config to dev.exs / prod.exs files + + `config.exs` + ```elixir + config :ex_pesa, + mpesa: [ + cert: "", + b2c: [ + initiator_name: "Safaricom1", + password: "Safaricom133", + timeout_url: "", + result_url: "", + security_credential: "" + ] + ] + ``` + To generate security_credential, head over to https://developer.safaricom.co.ke/test_credentials, then Initiator Security Password for your environment. + Alternatively, generate security credential using certificate + `cert` - This is the M-Pesa public key certificate used to encrypt your plain password. + There are 2 types of certificates. + - sandox - https://developer.safaricom.co.ke/sites/default/files/cert/cert_sandbox/cert.cer . + - production - https://developer.safaricom.co.ke/sites/default/files/cert/cert_prod/cert.cer . + `password` - This is a plain unencrypted password. + Environment + - production - set password from the organization portal. + - sandbox - use your own custom password + + ## Parameters + + attrs: - a map containing: + - `command_id` - Unique command for each transaction type, possible values are: BusinessPayBill, MerchantToMerchantTransfer, MerchantTransferFromMerchantToWorking, MerchantServicesMMFAccountTransfer, AgencyFloatAdvance. + - `amount` - The amount being transacted. + - `phone_number` - Phone number receiving the transaction. + - `remarks` - Comments that are sent along with the transaction. + - `occassion` - Optional. + + ## Example + iex> ExPesa.Mpesa.B2c.request(%{command_id: "BusinessPayment", amount: 10500, phone_number: "254722000000", remarks: "B2C Request"}) + {:ok, + %{ + "ConversationID" => "AG_20201010_00006bd489ffcaf79e91", + "OriginatorConversationID" => "27293-71728391-3", + "ResponseCode" => "0", + "ResponseDescription" => "Accept the service request successfully." + }} + """ + + def request(params) do + case get_security_credential_for(:b2c) do + nil -> {:error, "cannot generate security_credential due to missing configuration fields"} + security_credential -> b2c_request(security_credential, params) + end + end + + defp b2c_request( + security_credential, + %{ + command_id: command_id, + amount: amount, + phone_number: phone_number, + remarks: remarks + } = params + ) do + occassion = Map.get(params, :occassion, nil) + + payload = %{ + "InitiatorName" => Application.get_env(:ex_pesa, :mpesa)[:b2c][:initiator_name], + "SecurityCredential" => security_credential, + "CommandID" => command_id, + "Amount" => amount, + "PartyA" => Application.get_env(:ex_pesa, :mpesa)[:b2c][:short_code], + "PartyB" => phone_number, + "Remarks" => remarks, + "QueueTimeOutURL" => Application.get_env(:ex_pesa, :mpesa)[:b2c][:timeout_url], + "ResultURL" => Application.get_env(:ex_pesa, :mpesa)[:b2c][:result_url], + "Occassion" => occassion + } + + make_request("/mpesa/b2c/v1/paymentrequest", payload) + end + + defp b2c_request(_security_credential, _) do + {:error, "Required Parameter missing, 'command_id','amount','phone_number', 'remarks'"} + end +end diff --git a/lib/ex_pesa/Mpesa/c2b.ex b/lib/ex_pesa/Mpesa/c2b.ex new file mode 100644 index 0000000..9ea014d --- /dev/null +++ b/lib/ex_pesa/Mpesa/c2b.ex @@ -0,0 +1,107 @@ +defmodule ExPesa.Mpesa.C2B do + @moduledoc """ + C2B M-Pesa API enables Paybill and Buy Goods merchants to integrate to M-Pesa and receive real time payments notifications. + """ + + import ExPesa.Mpesa.MpesaBase + + @doc """ + There are two URLs required for RegisterURL API: Validation URL and Confirmation URL. + For the two URLs, below are some pointers. This will also apply to the Callback URLs used on other APIs: + - Use publicly available (Internet-accessible) IP addresses or domain names. + - Do not use the words MPesa, M-Pesa, Safaricom or any of their variants in either upper or lower cases in your URLs, the system filters these URLs out and blocks them. Of course any Localhost URL will be refused. + - Do not use public URL testers e.g. mockbin or requestbin especially on production, they are also blocked by the API. + + ## Parameters + + attrs: - a map containing: + - `ShortCode` - This is your paybill number/till number, which you expect to receive payments notifications about. + - `ResponseType` - [Cancelled/Completed] This is the default action value that determines what MPesa will do in the scenario that + your endpoint is unreachable or is unable to respond on time. Only two values are allowed: Completed or Cancelled. + Completed means MPesa will automatically complete your transaction, whereas Cancelled means + MPesa will automatically cancel the transaction, in the event MPesa is unable to reach your Validation URL. + - `ConfirmationURL` - [confirmation URL]. + - `ValidationURL` - [validation URL]. + + ## Example + + iex> ExPesa.Mpesa.C2B.registerurl(%{ConfirmationURL: "https://58cb49b30213.ngrok.io/confirmation", ValidationURL: "https://58cb49b30213.ngrok.io/validation", ResponseType: "Completed"}) + {:ok, + %{ + "ConversationID" => "", + "OriginatorCoversationID" => "", + "ResponseDescription" => "success" + } + } + """ + + def registerurl(%{ + ConfirmationURL: confirmation_url, + ValidationURL: validation_url, + ResponseType: response_type + }) do + paybill = Application.get_env(:ex_pesa, :mpesa)[:c2b_short_code] + + payload = %{ + "ShortCode" => paybill, + "ResponseType" => response_type, + "ConfirmationURL" => confirmation_url, + "ValidationURL" => validation_url + } + + make_request("/mpesa/c2b/v1/registerurl", payload) + end + + def registerurl(%{}) do + {:error, "Required Parameter missing, 'ConfirmationURL', 'ValidationURL','ResponseType'"} + end + + @doc """ + This API is used to make payment requests from Client to Business (C2B). + ## Parameters + + attrs: - a map containing: + - `CommandID` - This is a unique identifier of the transaction type: There are two types of these Identifiers: + CustomerPayBillOnline: This is used for Pay Bills shortcodes. + CustomerBuyGoodsOnline: This is used for Buy Goods shortcodes. + - `Amount` - This is the amount being transacted. The parameter expected is a numeric value. + - `Msisdn` - This is the phone number initiating the C2B transaction. + - `BillRefNumber` - This is used on CustomerPayBillOnline option only. + This is where a customer is expected to enter a unique bill identifier, e.g an Account Number. + - `ShortCode` - This is the Short Code receiving the amount being transacted. + You can use the sandbox provided test credentials down below to simulates a payment made from the client phone's STK/SIM Toolkit menu, and enables you to receive the payment requests in real time. + ## Example + + iex> ExPesa.Mpesa.C2B.simulate(%{command_id: "CustomerPayBillOnline", phone_number: "254728833100", amount: 10, bill_reference: "Some Reference" }) + {:ok, + %{ + "ConversationID" => "AG_20200921_00006e93a78f009f7025", + "OriginatorCoversationID" => "9769-145819182-2", + "ResponseDescription" => "Accept the service request successfully." + } + } + """ + + def simulate(%{ + command_id: command_id, + phone_number: phone_number, + amount: amount, + bill_reference: bill_reference + }) do + paybill = Application.get_env(:ex_pesa, :mpesa)[:c2b_short_code] + + payload = %{ + "ShortCode" => paybill, + "CommandID" => command_id, + "Amount" => amount, + "Msisdn" => phone_number, + "BillRefNumber" => bill_reference + } + + make_request("/mpesa/c2b/v1/simulate", payload) + end + + def simulate(%{}) do + {:error, "Required Parameter missing, 'CommandID','Amount','Msisdn', 'BillRefNumber'"} + end +end diff --git a/lib/ex_pesa/Mpesa/mpesa_base.ex b/lib/ex_pesa/Mpesa/mpesa_base.ex index 81f41a8..3a08891 100644 --- a/lib/ex_pesa/Mpesa/mpesa_base.ex +++ b/lib/ex_pesa/Mpesa/mpesa_base.ex @@ -2,6 +2,7 @@ defmodule ExPesa.Mpesa.MpesaBase do @moduledoc false import ExPesa.Util + alias ExPesa.Mpesa.TokenServer @live "https://api.safaricom.co.ke" @sandbox "https://sandbox.safaricom.co.ke" @@ -24,7 +25,21 @@ defmodule ExPesa.Mpesa.MpesaBase do end def token(client) do - Tesla.get(client, "/oauth/v1/generate?grant_type=client_credentials") |> get_token + {token, datetime} = TokenServer.get() + + if DateTime.compare(datetime, DateTime.utc_now()) !== :gt do + case Tesla.get(client, "/oauth/v1/generate?grant_type=client_credentials") |> get_token do + {:ok, token} -> + # added 3550 secs, 50 less normal 3600 in 1 hr + TokenServer.insert({token, DateTime.add(DateTime.utc_now(), 3550, :second)}) + {:ok, token} + + {:error, message} -> + {:error, message} + end + else + {:ok, token} + end end @doc false diff --git a/lib/ex_pesa/Mpesa/reversal.ex b/lib/ex_pesa/Mpesa/reversal.ex new file mode 100644 index 0000000..8bd7722 --- /dev/null +++ b/lib/ex_pesa/Mpesa/reversal.ex @@ -0,0 +1,109 @@ +defmodule ExPesa.Mpesa.Reversal do + @moduledoc """ + Reversal API enables reversal of transactions done + You will be able to reverse a transaction where you are the credit party. This means it will be done via the Web portal, and may require manual authorization from the Service Provider side. But if you are allowed to reverse a transaction via API, it may also need to be authorized. + + An initiator requires the Org Reversals Initiator role to be able to perform reversals via API + """ + import ExPesa.Mpesa.MpesaBase + alias ExPesa.Util + + @doc """ + Makes a request to the mpesa reversal endpoint with the given params. + + ## Params + The function requires two keys to be present for a successful request, `:transation_id` and `:amount` + The params can be any of the accepted params by the api endpoint with the keys converted to snake case. For example + `QueueTimeOutURL` is expected to be in the format `queue_time_out_url`. + + Additionally, the keys `:security_credential, :initiator, :receiver_party, :result_url, :queue_time_out_url` are + loaded from the respective config and used if they're not provided as part of the params in the arguments. + + ## Options + Because reversal can be done for B2B, B2C or C2B, this function allows for an option to load the configs from. + It defaults to `:reversal` which means it will use config under the `:mpesa` config. + In order to reuse the configs for the other apis, use the parent key under the mpesa config. + For example, in order to use the `b2b` configs, pass in `:b2b` as the option + ## Configuration + Add below config to dev.exs / prod.exs files + + `config.exs` + ```elixir + config :ex_pesa, + mpesa: [ + cert: "", + reversal: [ + short_code: "", + initiator_name: "", + password: "", + timeout_url: "", + result_url: "", + security_credential: "" + ] + ] + ``` + + To generate security_credential, head over to https://developer.safaricom.co.ke/test_credentials, then Initiator Security Password for your environment. + Alternatively, generate security credential using certificate + `cert` - This is the M-Pesa public key certificate used to encrypt your plain password. + There are 2 types of certificates. + - sandox - https://developer.safaricom.co.ke/sites/default/files/cert/cert_sandbox/cert.cer . + - production - https://developer.safaricom.co.ke/sites/default/files/cert/cert_prod/cert.cer . + `password` - This is a plain unencrypted password. + Environment + - production - set password from the organization portal. + - sandbox - use your own custom password + + ## Example + iex> ExPesa.Mpesa.Reversal.reverse(%{amount: 30, transaction_id: "LGR013H3J2"}, :reversal) + {:ok, + %{ + "ConversationID" => "AG_20201011_00006511c0024c170286", + "OriginatorConversationID" => "8094-41340768-1", + "ResponseCode" => "0", + "ResponseDescription" => "Accept the service request successfully." + }} + """ + def reverse(params, option \\ :reversal) + + def reverse(%{transaction_id: _trans_id, amount: _amount} = params, api) do + config = Application.get_env(:ex_pesa, :mpesa)[api] + credential = Util.get_security_credential_for(api) + reversal_config = Application.get_env(:ex_pesa, :mpesa)[:reversal] + + %{ + security_credential: credential, + initiator: config[:initiator_name], + receiver_party: config[:short_code], + result_url: reversal_config[:result_url], + queue_time_out_url: reversal_config[:timeout_url] + } + |> Map.merge(params) + |> reversal_payload() + |> request_reversal() + end + + def reverse(_, _) do + {:error, "either transaction_id or amount is missing from the given params"} + end + + defp reversal_payload(params) do + %{ + "Initiator" => params.initiator, + "SecurityCredential" => params.security_credential, + "CommandID" => "TransactionReversal", + "TransactionID" => params.transaction_id, + "Amount" => params.amount, + "ReceiverParty" => params.receiver_party, + "RecieverIdentifierType" => "11", + "ResultURL" => params.result_url, + "QueueTimeOutURL" => params.queue_time_out_url, + "Remarks" => Map.get(params, :remarks, "Payment Reversal"), + "Occasion" => Map.get(params, :occassion, "Payment Reversal") + } + end + + defp request_reversal(payload) do + make_request("/mpesa/reversal/v1/request", payload) + end +end diff --git a/lib/ex_pesa/Mpesa/stk.ex b/lib/ex_pesa/Mpesa/stk.ex index 919853e..45e3120 100644 --- a/lib/ex_pesa/Mpesa/stk.ex +++ b/lib/ex_pesa/Mpesa/stk.ex @@ -10,7 +10,7 @@ defmodule ExPesa.Mpesa.Stk do ## Configuration - Add below config to dev.exs / prod.exs files + Add below config to dev.exs / prod.exs files This asumes you have a clear understanding of how Daraja API works. See docs here https://developer.safaricom.co.ke/docs#lipa-na-m-pesa-online-payment `config.exs` @@ -77,4 +77,63 @@ defmodule ExPesa.Mpesa.Stk do def request(_) do {:error, "Required Parameters missing, 'phone, 'amount', 'reference', 'description'"} end + + @doc """ + STK PUSH Transaction Validation + + ## Configuration + + Add below config to dev.exs / prod.exs files (at this stage after STK, the config keys should be there) + This asumes you have a clear understanding of how Daraja API works. See docs here https://developer.safaricom.co.ke/docs#lipa-na-m-pesa-online-query-request + + `config.exs` + ```elixir + config :ex_pesa, + mpesa: [ + consumer_key: "", + consumer_secret: "", + mpesa_short_code: "", + mpesa_passkey: "", + ] + ``` + + ## Parameters + + attrs: - a map containing: + - `checkout_request_id` - Checkout RequestID. + + ## Example + + iex> ExPesa.Mpesa.Stk.validate(%{checkout_request_id: "ws_CO_260820202102496165"}) + {:ok, + %{ + "CheckoutRequestID" => "ws_CO_260820202102496165", + "MerchantRequestID" => "11130-78831728-4", + "ResponseCode" => "0", + "ResponseDescription" => "The service request has been accepted successsfully", + "ResultCode" => "1032", + "ResultDesc" => "Request cancelled by user" + } + } + """ + @spec validate(map()) :: {:error, any()} | {:ok, any()} + def validate(%{checkout_request_id: checkout_request_id}) do + paybill = Application.get_env(:ex_pesa, :mpesa)[:mpesa_short_code] + passkey = Application.get_env(:ex_pesa, :mpesa)[:mpesa_passkey] + {:ok, timestamp} = Timex.now() |> Timex.format("%Y%m%d%H%M%S", :strftime) + password = Base.encode64(paybill <> passkey <> timestamp) + + payload = %{ + "BusinessShortCode" => paybill, + "Password" => password, + "Timestamp" => timestamp, + "CheckoutRequestID" => checkout_request_id + } + + make_request("/mpesa/stkpushquery/v1/query", payload) + end + + def validate(_) do + {:error, "Required Parameter missing, 'CheckoutRequestID'"} + end end diff --git a/lib/ex_pesa/Mpesa/token_server.ex b/lib/ex_pesa/Mpesa/token_server.ex new file mode 100644 index 0000000..460f678 --- /dev/null +++ b/lib/ex_pesa/Mpesa/token_server.ex @@ -0,0 +1,30 @@ +defmodule ExPesa.Mpesa.TokenServer do + @moduledoc false + + use GenServer + @name __MODULE__ + + def start_link(opts \\ []) do + opts = Keyword.put_new(opts, :name, @name) + GenServer.start_link(__MODULE__, {'token', DateTime.utc_now()}, opts) + end + + def insert(pid \\ @name, token), do: GenServer.cast(pid, {:insert, token}) + + def get(pid \\ @name), do: GenServer.call(pid, :get) + + @impl true + def init(token_tuple) do + {:ok, token_tuple} + end + + @impl true + def handle_cast({:insert, token_tuple}, _state) do + {:noreply, token_tuple} + end + + @impl true + def handle_call(:get, _from, state) do + {:reply, state, state} + end +end diff --git a/lib/ex_pesa/Mpesa/transaction_status.ex b/lib/ex_pesa/Mpesa/transaction_status.ex new file mode 100644 index 0000000..8c122de --- /dev/null +++ b/lib/ex_pesa/Mpesa/transaction_status.ex @@ -0,0 +1,113 @@ +defmodule ExPesa.Mpesa.TransactionStatus do + @moduledoc """ + Use this api to check the transaction status. + """ + + import ExPesa.Mpesa.MpesaBase + import ExPesa.Util + + @doc """ + Transaction Status Query + ## Requirement Params + - `CommandID`[String] - Takes only 'TransactionStatusQuery' command id + - `timeout_url` [URL] - The path that stores information of time out transaction. Takes the form of + https://ip or domain:port/path + - `result_url`[URL] - The path that stores information of transaction. Example https://ip or domain:port/path + - `Initiator` [Alpha-Numeric] - The name of Initiator to initiating the request. This is the credential/username + used to authenticate the transaction request + + - `security credential` - To generate security_credential, head over to https://developer.safaricom.co.ke/test_credentials, then Initiator Security Password for your environment. + + `config.exs` + ```elixir + config :ex_pesa, + mpesa: [ + cert: "", + transaction_status: [ + initiator_name: "", + password: "", + timeout_url: "", + result_url: "", + security_credential: "" + ] + ] + ``` + + Alternatively, generate security credential using certificate + `cert` - This is the M-Pesa public key certificate used to encrypt your plain password. + There are 2 types of certificates. + - sandox - https://developer.safaricom.co.ke/sites/default/files/cert/cert_sandbox/cert.cer . + - production - https://developer.safaricom.co.ke/sites/default/files/cert/cert_prod/cert.cer . + `password` - This is a plain unencrypted password. + Environment + - production - set password from the organization portal. + - sandbox - use your own custom password + + ## Parameters + The following are the parameters required for this method, the rest are fetched from config + files. + + - `transaction_id` [Alpha-Numeric] - Unique identifier to identify a transaction on M-Pesa Alpha-Numeric LKXXXX1234 + - `receiver_party` [Numeric] - Organization/MSISDN receiving the transaction, can be + -Shortcode (6 digits) + -MSISDN (12 Digits) + - `identifier_type` [Numeric] - Type of organization receiving the transaction can be the folowing: + 1 – MSISDN + 2 – Till Number + 4 – Organization short code + - `remarks`[String] - Comments that are sent along with the transaction, can be a sequence of characters up to 100 + - `occasion` [ String] - Optional Parameter String sequence of characters up to 100 + + ## Example + + iex> ExPesa.Mpesa.TransactionStatus.request(%{transaction_id: "SOME7803", receiver_party: "600247", identifier_type: 4, remarks: "TransactionReversal", occasion: "TransactionReversal"}) + {:ok, + %{ + "ConversationID" => "AG_20201010_000056be35a7b266b43e", + "OriginatorConversationID" => "27288-72545279-2", + "ResponseCode" => "0", + "ResponseDescription" => "Accept the service request successfully." + } + } + + """ + def request(params) do + case get_security_credential_for(:transaction_status) do + nil -> {:error, "cannot generate security_credential due to missing configuration fields"} + security_credential -> query(security_credential, params) + end + end + + defp query( + security_credential, + %{ + transaction_id: transaction_id, + receiver_party: receiver_party, + identifier_type: identifier_type, + remarks: remarks + } = params + ) do + occasion = Map.get(params, :occasion, nil) + + payload = %{ + "CommandID" => "TransactionStatusQuery", + "PartyA" => receiver_party, + "IdentifierType" => identifier_type, + "Remarks" => remarks, + "SecurityCredential" => security_credential, + "Initiator" => Application.get_env(:ex_pesa, :mpesa)[:transaction_status][:initiator_name], + "QueueTimeOutURL" => + Application.get_env(:ex_pesa, :mpesa)[:transaction_status][:timeout_url], + "ResultURL" => Application.get_env(:ex_pesa, :mpesa)[:transaction_status][:result_url], + "TransactionID" => transaction_id, + "Occasion" => occasion + } + + make_request("/mpesa/transactionstatus/v1/query", payload) + end + + defp query(_security_credential, _) do + {:error, + "Some Required Parameter missing, check whether you have 'transaction_id', 'receiver_party', 'identifier_type', 'remarks'"} + end +end diff --git a/lib/ex_pesa/application.ex b/lib/ex_pesa/application.ex new file mode 100644 index 0000000..463aa65 --- /dev/null +++ b/lib/ex_pesa/application.ex @@ -0,0 +1,20 @@ +defmodule ExPesa.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + def start(_type, _args) do + # List all child processes to be supervised + children = [ + # Starts a MpesaServer by calling: ExPesa.Mpesa.TokenServer.start_link(arg) + ExPesa.Mpesa.TokenServer + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: ExPesa.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/lib/ex_pesa/util.ex b/lib/ex_pesa/util.ex index 000cfb5..00ee4f3 100644 --- a/lib/ex_pesa/util.ex +++ b/lib/ex_pesa/util.ex @@ -5,9 +5,65 @@ defmodule ExPesa.Util do @spec get_url(String.t(), String.t()) :: String.t() def get_url(live_url, sandbox_url) do cond do - Mix.env() == :prod -> live_url - Application.get_env(:ex_pesa, :force_live_url) == "YES" -> live_url + Application.get_env(:ex_pesa, :sandbox) === false -> live_url + Application.get_env(:ex_pesa, :sandbox) === true -> sandbox_url true -> sandbox_url end end + + @doc """ + Security Credentials read more https://developer.safaricom.co.ke/docs#security-credentials + M-Pesa Core authenticates a transaction by decrypting the security credentials. Security credentials are generated by encrypting the base64 encoded initiator password with M-Pesa’s public key, a X509 certificate. + + The algorithm for generating security credentials is as follows: + Write the unencrypted password into a byte array. + Encrypt the array with the M-Pesa public key certificate. Use the RSA algorithm, and use PKCS #1.5 padding (not OAEP), and add the result to the encrypted stream. + Convert the resulting encrypted byte array into a string using base64 encoding. The resulting base64 encoded string is the security credential. + + ## Example + iex> certfile = "-----BEGIN CERTIFICATE-----\nMIIGKzCCBROgAwIBAgIQDL7NH8cxSdUpl0ihH0A1wTANBgkqhkiG9w0BAQsFADBN\nMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E\naWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMTgwODI3MDAwMDAwWhcN\nMTkwNDA0MTIwMDAwWjBuMQswCQYDVQQGEwJLRTEQMA4GA1UEBxMHTmFpcm9iaTEW\nMBQGA1UEChMNU2FmYXJpY29tIFBMQzETMBEGA1UECxMKRGlnaXRhbCBJVDEgMB4G\nA1UEAxMXc2FuZGJveC5zYWZhcmljb20uY28ua2UwggEiMA0GCSqGSIb3DQEBAQUA\nA4IBDwAwggEKAoIBAQC78yeC/wLoZY6TJeqc4g/9eAKIpeCwEsjX09pD8ZxAGXqT\nOi7ssdIGJBPmJZNeEVyf8ocFhisCuLngJ9Z5e/AvH52PhrEFmVu2D03zSf4C+rhZ\nndEKP6G79pUAb/bemOliU9zM8xYYkpCRzPWUzk6zSDarg0ZDLw5FrtZj/VJ9YEDL\nWGgAfwExEgSN3wjyUlJ2UwI3wqQXLka0VNFWoZxUH5j436gbSWRIL6NJUmrq8V8S\naTEPz3eJHj3NOToDu245c7VKdF/KExyZjRjD2p5I+Aip80TXzKlZj6DjMb3DlfXF\nHsnu0+1uJE701mvKX7BiscxKr8tCRphL63as4dqvAgMBAAGjggLkMIIC4DAfBgNV\nHSMEGDAWgBQPgGEcgjFh1S8o541GOLQs4cbZ4jAdBgNVHQ4EFgQUzZmY7ZORLw9w\nqRbAQN5m9lJ28qMwIgYDVR0RBBswGYIXc2FuZGJveC5zYWZhcmljb20uY28ua2Uw\nDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBr\nBgNVHR8EZDBiMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vc3NjYS1z\naGEyLWc2LmNybDAvoC2gK4YpaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NzY2Et\nc2hhMi1nNi5jcmwwTAYDVR0gBEUwQzA3BglghkgBhv1sAQEwKjAoBggrBgEFBQcC\nARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAIBgZngQwBAgIwfAYIKwYB\nBQUHAQEEcDBuMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20w\nRgYIKwYBBQUHMAKGOmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy\ndFNIQTJTZWN1cmVTZXJ2ZXJDQS5jcnQwCQYDVR0TBAIwADCCAQUGCisGAQQB1nkC\nBAIEgfYEgfMA8QB2AKS5CZC0GFgUh7sTosxncAo8NZgE+RvfuON3zQ7IDdwQAAAB\nZXs1FvEAAAQDAEcwRQIgBzVMkm7SNprjJ1GBqiXIc9rNzY+y7gt6s/O02oMkyFoC\nIQDBuThGlpmUKpeZoHhK6HGwB4jDMIecmKaOcMS18R2jxwB3AId1v+dZfPiMQ5lf\nvfNu/1aNR1Y2/0q1YMG06v9eoIMPAAABZXs1F8IAAAQDAEgwRgIhAIRq2XFiC+RS\nuDCYq8ICJg0QafSV+e9BLpJnElEdaSjiAiEAyiiW4vxwv4cWcAXE6FAipctyUBs6\nbE5QyaCnmNpoDiQwDQYJKoZIhvcNAQELBQADggEBAB0YoWve9Sxhb0PBS3Hc46Rf\na7H1jhHuwE+UyscSQsdJdk8uPAgDuKRZMvJPGEaCkNHm36NfcaXXFjPOl7LI1d1a\n9zqSP0xeZBI6cF0x96WuQGrI9/WR2tfxjmaUSp8a/aJ6n+tZA28eJZNPrIaMm+6j\ngh7AkKnqcf+g8F/MvCCVdNAiVMdz6UpCscf6BRPHNZ5ifvChGh7aUKjrVLLuF4Ls\nHE05qm6HNyV5eTa6wvcbc4ewguN1UDZvPWetSyfBk10Wbpor4znQ4TJ3Y9uCvsJH\n41ldblDvZZ2z4kB2UYQ7iBkPlJSxSOaFgW/GGDXq49sz/995xzhVITHxh2SdLkI=\n-----END CERTIFICATE-----\n" + iex> password = "Safaricom133" + iex> ExPesa.Util.generate_security_credential(%{CertFile: certfile, Password: password}) + """ + + def generate_security_credential(%{CertFile: certfile, Password: password}) + when certfile != nil and password != nil do + cert_text = + certfile |> String.trim() |> String.split(~r{\n *}, trim: true) |> Enum.join("\n") + + # 1) Decode the certificate. + [pem_entry] = :public_key.pem_decode(cert_text) + cert_decoded = :public_key.pem_entry_decode(pem_entry) + + # 2) Extract public key. + list = Tuple.to_list(elem(cert_decoded, 1)) + plk = List.keyfind(list, :SubjectPublicKeyInfo, 0) |> elem(2) + + public_key = :public_key.der_decode(:RSAPublicKey, plk) + + # 3) Encrypt the plain password text + ciphertext = + :public_key.encrypt_public(password, public_key, [{:rsa_pad, :rsa_pkcs1_padding}]) + + # 4) Base64 encode and return the result + :base64.encode(ciphertext) + end + + def generate_security_credential(%{}) do + nil + end + + def get_security_credential_for(key) do + securityCredential = Application.get_env(:ex_pesa, :mpesa)[key][:security_credential] + + cond do + securityCredential === "" || securityCredential === nil -> + cert = Application.get_env(:ex_pesa, :mpesa)[:cert] + password = Application.get_env(:ex_pesa, :mpesa)[key][:password] + generate_security_credential(%{CertFile: cert, Password: password}) + + securityCredential -> + securityCredential + end + end end diff --git a/mix.exs b/mix.exs index 3f81c86..4ea34b1 100644 --- a/mix.exs +++ b/mix.exs @@ -4,41 +4,53 @@ defmodule ExPesa.MixProject do def project do [ app: :ex_pesa, - version: "0.1.0", - elixir: "~> 1.9", + version: "0.1.1", + elixir: "~> 1.8", start_permanent: Mix.env() == :prod, - description: "This is a Payments Library", + description: "Payment Library For Most Public Payment API's in Kenya and hopefully Africa.", package: package(), deps: deps(), - name: "ex_pesa", + name: "ExPesa", source_url: "https://github.com/beamkenya/ex_pesa.git", - homepage_url: "https://hexdocs.pm/ex_pesa/ExPesa.html", docs: [ # The main page in the docs - main: "ex_pesa", - # logo: - # "https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/M-PESA_LOGO-01.svg/1200px-M-PESA_LOGO-01.svg.png", - extras: ["README.md"] + main: "readme", + canonical: "http://hexdocs.pm/at_ex", + source_url: "https://github.com/beamkenya/ex_pesa.git", + logo: "assets/logo.png", + assets: "assets", + extras: ["README.md", "contributing.md"] + ], + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test ] ] end defp package do [ - maintainers: ["Beam Kenya"], + name: "ex_pesa", + maintainers: [ + "Paul Oguda, Magak Emmanuel, Tracey Onim, Anthony Leiro, Frank Midigo, Evans Okoth " + ], licenses: ["MIT"], links: %{ "GitHub" => "https://github.com/beamkenya/ex_pesa.git", - "Documentation" => "https://hexdocs.pm/ex_pesa/ExPesa.html", "README" => "https://hexdocs.pm/ex_pesa/readme.html" - } + }, + homepage_url: "https://github.com/elixirkenya/africastalking-elixir" ] end # Run "mix help compile.app" to learn about applications. def application do [ - extra_applications: [:logger] + extra_applications: [:logger], + mod: {ExPesa.Application, []} ] end @@ -46,10 +58,11 @@ defmodule ExPesa.MixProject do defp deps do [ {:tesla, "~> 1.3.0"}, - {:hackney, "~> 1.15.2"}, + {:hackney, "~> 1.16.0"}, {:jason, ">= 1.0.0"}, {:timex, "~> 3.6.2"}, - {:ex_doc, "~> 0.21", only: :dev, runtime: false} + {:ex_doc, "~> 0.21", only: :dev, runtime: false}, + {:excoveralls, "~> 0.10", only: :test} ] end end diff --git a/mix.lock b/mix.lock index 6706ef4..42fedec 100644 --- a/mix.lock +++ b/mix.lock @@ -1,14 +1,16 @@ %{ - "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, + "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "cowlib": {:hex, :cowlib, "2.6.0", "8aa629f81a0fc189f261dc98a42243fa842625feea3c7ec56c48f4ccdb55490f", [:rebar3], [], "hexpm", "45a1a08e05e4c66f2af665295955e337d52c2d33b1f1cf24d353cadeddf34992"}, "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, "ex_doc": {:hex, :ex_doc, "0.22.2", "03a2a58bdd2ba0d83d004507c4ee113b9c521956938298eba16e55cc4aba4a6c", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "cf60e1b3e2efe317095b6bb79651f83a2c1b3edcb4d319c421d7fcda8b3aff26"}, + "ex_doc_makeup": {:hex, :ex_doc_makeup, "0.1.2", "88d9a9c4be29a8486c3aa410927706c2da0c4e22fcc135e49dceb15f17510de0", [:mix], [{:ex_doc, ">= 0.18.1", [hex: :ex_doc, repo: "hexpm", optional: false]}, {:makeup_elixir, ">= 0.3.1", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "7cc49a1634112799252ce540e85eb321734d67a75c76db4fd0cba646fef37574"}, + "excoveralls": {:hex, :excoveralls, "0.13.2", "5ca05099750c086f144fcf75842c363fc15d7d9c6faa7ad323d010294ced685e", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1e7ed75c158808a5a8f019d3ad63a5efe482994f2f8336c0a8c77d2f0ab152ce"}, "gettext": {:hex, :gettext, "0.18.1", "89e8499b051c7671fa60782faf24409b5d2306aa71feb43d79648a8bc63d0522", [:mix], [], "hexpm", "e70750c10a5f88cb8dc026fc28fa101529835026dec4a06dba3b614f2a99c7a9"}, "gun": {:hex, :gun, "1.3.2", "542064cbb9f613650b8a8100b3a927505f364fbe198b7a5a112868ff43f3e477", [:rebar3], [{:cowlib, "~> 2.6.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "ba323f0a5fd8abac379a3e1fe6d8ce570c4a12c7fd1c68f4994b53447918e462"}, - "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, + "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, "httpoison": {:hex, :httpoison, "1.7.0", "abba7d086233c2d8574726227b6c2c4f6e53c4deae7fe5f6de531162ce9929a0", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "975cc87c845a103d3d1ea1ccfd68a2700c211a434d8428b10c323dc95dc5b980"}, - "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, + "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, "makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, @@ -18,9 +20,9 @@ "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "tesla": {:hex, :tesla, "1.3.3", "26ae98627af5c406584aa6755ab5fc96315d70d69a24dd7f8369cfcb75094a45", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "2648f1c276102f9250299e0b7b57f3071c67827349d9173f34c281756a1b124c"}, "timex": {:hex, :timex, "3.6.2", "845cdeb6119e2fef10751c0b247b6c59d86d78554c83f78db612e3290f819bc2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "26030b46199d02a590be61c2394b37ea25a3664c02fafbeca0b24c972025d47a"}, "tzdata": {:hex, :tzdata, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a6e1ee7003c4d04ecbd21dd3ec690d4c6662db5d3bbdd7262d53cdf5e7c746c1"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, } diff --git a/test/ex_pesa/Mpesa/account_balance_test.exs b/test/ex_pesa/Mpesa/account_balance_test.exs new file mode 100644 index 0000000..8f2707d --- /dev/null +++ b/test/ex_pesa/Mpesa/account_balance_test.exs @@ -0,0 +1,50 @@ +defmodule ExPesa.Mpesa.AccountBalanceTest do + @moduledoc false + + use ExUnit.Case, async: true + + import Tesla.Mock + doctest ExPesa.Mpesa.AccountBalance + + alias ExPesa.Mpesa.AccountBalance + + setup do + mock(fn + %{ + url: "https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials", + method: :get + } -> + %Tesla.Env{ + status: 200, + body: %{ + "access_token" => "SGWcJPtNtYNPGm6uSYR9yPYrAI3Bm", + "expires_in" => "3599" + } + } + + %{ + url: "https://sandbox.safaricom.co.ke/mpesa/accountbalance/v1/query", + method: :post + } -> + %Tesla.Env{ + status: 200, + body: %{ + "ConversationID" => "AG_20201010_00007d6021022d396df6", + "OriginatorConversationID" => "28290-142922216-2", + "ResponseCode" => "0", + "ResponseDescription" => "Accept the service request successfully." + } + } + end) + + :ok + end + + describe "Mpesa AccountBalance" do + test "request/1 should Initiate a AccountBalance request" do + {:ok, result} = AccountBalance.request() + + assert result["ResponseCode"] == "0" + end + end +end diff --git a/test/ex_pesa/Mpesa/b2b_test.exs b/test/ex_pesa/Mpesa/b2b_test.exs new file mode 100644 index 0000000..039bfdc --- /dev/null +++ b/test/ex_pesa/Mpesa/b2b_test.exs @@ -0,0 +1,64 @@ +defmodule ExPesa.Mpesa.B2BTest do + @moduledoc false + + use ExUnit.Case, async: true + + import Tesla.Mock + doctest ExPesa.Mpesa.B2B + + alias ExPesa.Mpesa.B2B + + setup do + mock(fn + %{ + url: "https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials", + method: :get + } -> + %Tesla.Env{ + status: 200, + body: %{ + "access_token" => "SGWcJPtNtYNPGm6uSYR9yPYrAI3Bm", + "expires_in" => "3599" + } + } + + %{ + url: "https://sandbox.safaricom.co.ke/mpesa/b2b/v1/paymentrequest", + method: :post + } -> + %Tesla.Env{ + status: 200, + body: %{ + "ConversationID" => "AG_20200927_00007d4c98884c889b25", + "OriginatorConversationID" => "27274-37744848-4", + "ResponseCode" => "0", + "ResponseDescription" => "Accept the service request successfully." + } + } + end) + + :ok + end + + describe "Mpesa B2B" do + test "request/1 should Initiate a B2B request" do + payment_details = %{ + command_id: "BusinessPayBill", + amount: 10500, + receiver_party: 600_000, + remarks: "B2B Request", + account_reference: "BILL PAYMENT" + } + + {:ok, result} = B2B.request(payment_details) + + assert result["ResponseCode"] == "0" + end + + test "request/1 should error out without required parameter" do + {:error, result} = B2B.request(%{}) + + "Required Parameter missing, 'command_id','amount','receiver_party', 'remarks'" = result + end + end +end diff --git a/test/ex_pesa/Mpesa/b2c_test.exs b/test/ex_pesa/Mpesa/b2c_test.exs new file mode 100644 index 0000000..f1ef32c --- /dev/null +++ b/test/ex_pesa/Mpesa/b2c_test.exs @@ -0,0 +1,63 @@ +defmodule ExPesa.Mpesa.B2CTest do + @moduledoc false + + use ExUnit.Case, async: true + + import Tesla.Mock + doctest ExPesa.Mpesa.B2c + + alias ExPesa.Mpesa.B2c + + setup do + mock(fn + %{ + url: "https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials", + method: :get + } -> + %Tesla.Env{ + status: 200, + body: %{ + "access_token" => "SGWcJPtNtYNPGm6uSYR9yPYrAI3Bm", + "expires_in" => "3599" + } + } + + %{ + url: "https://sandbox.safaricom.co.ke/mpesa/b2c/v1/paymentrequest", + method: :post + } -> + %Tesla.Env{ + status: 200, + body: %{ + "ConversationID" => "AG_20201010_00006bd489ffcaf79e91", + "OriginatorConversationID" => "27293-71728391-3", + "ResponseCode" => "0", + "ResponseDescription" => "Accept the service request successfully." + } + } + end) + + :ok + end + + describe "Mpesa B2C" do + test "request/1 should Initiate a B2C request" do + payment_details = %{ + command_id: "BusinessPayBill", + amount: 10500, + phone_number: "254722000000", + remarks: "B2C Request" + } + + {:ok, result} = B2c.request(payment_details) + + assert result["ResponseCode"] == "0" + end + + test "request/1 should error out without required parameter" do + {:error, result} = B2c.request(%{}) + + "Required Parameter missing, 'command_id','amount','phone_number', 'remarks'" = result + end + end +end diff --git a/test/ex_pesa/Mpesa/c2b_test.exs b/test/ex_pesa/Mpesa/c2b_test.exs new file mode 100644 index 0000000..518eeb7 --- /dev/null +++ b/test/ex_pesa/Mpesa/c2b_test.exs @@ -0,0 +1,88 @@ +defmodule ExPesa.Mpesa.C2BTest do + @moduledoc false + + use ExUnit.Case, async: true + + import Tesla.Mock + doctest ExPesa.Mpesa.C2B + + alias ExPesa.Mpesa.C2B + + setup do + mock(fn + %{ + url: "https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials", + method: :get + } -> + %Tesla.Env{ + status: 200, + body: %{ + "access_token" => "SGWcJPtNtYNPGm6uSYR9yPYrAI3Bm", + "expires_in" => "3599" + } + } + + %{ + url: "https://sandbox.safaricom.co.ke/mpesa/c2b/v1/registerurl", + method: :post + } -> + %Tesla.Env{ + status: 200, + body: %{ + "ConversationID" => "", + "OriginatorCoversationID" => "", + "ResponseDescription" => "success" + } + } + + %{url: "https://sandbox.safaricom.co.ke/mpesa/c2b/v1/simulate", method: :post} -> + %Tesla.Env{ + status: 200, + body: %{ + "ConversationID" => "AG_20200921_00006e93a78f009f7025", + "OriginatorCoversationID" => "9769-145819182-2", + "ResponseDescription" => "Accept the service request successfully." + } + } + end) + + :ok + end + + describe "Mpesa C2B" do + test "registerurl/1 should register a URL" do + register_details = %{ + ConfirmationURL: "https://58cb49b30213.ngrok.io/confirmation", + ValidationURL: "https://58cb49b30213.ngrok.io/validation", + ResponseType: "Completed" + } + + {:ok, result} = C2B.registerurl(register_details) + + assert result["ResponseDescription"] == "success" + end + + test "request/1 should error out without required parameter" do + {:error, result} = C2B.registerurl(%{}) + "Required Parameter missing, 'ConfirmationURL', 'ValidationURL','ResponseType'" = result + end + + test "simulate/1 should send money successfully" do + {:ok, result} = + C2B.simulate(%{ + command_id: "CustomerPayBillOnline", + phone_number: "254728833100", + amount: 10, + bill_reference: "Some Reference" + }) + + assert result["OriginatorCoversationID"] == "9769-145819182-2" + assert result["ResponseDescription"] == "Accept the service request successfully." + end + + test "validate/1 should error out without required parameter" do + {:error, result} = C2B.simulate(%{}) + "Required Parameter missing, 'CommandID','Amount','Msisdn', 'BillRefNumber'" = result + end + end +end diff --git a/test/ex_pesa/Mpesa/mpesa_base_test.exs b/test/ex_pesa/Mpesa/mpesa_base_test.exs new file mode 100644 index 0000000..628231f --- /dev/null +++ b/test/ex_pesa/Mpesa/mpesa_base_test.exs @@ -0,0 +1,53 @@ +defmodule ExPesa.Mpesa.MpesaBaseTest do + @moduledoc false + + use ExUnit.Case, async: true + + alias ExPesa.Mpesa.MpesaBase + + describe "Process Results" do + test "response with 201 status with map body" do + resp = + {:ok, + %{ + status: 201, + body: %{ + responseCode: 0, + success: true + } + }} + + MpesaBase.process_result(resp) + end + + test "response with 201 status with json body" do + resp = + {:ok, + %{ + status: 201, + body: """ + { + "responseCode": 0, + "success": true + } + """ + }} + + MpesaBase.process_result(resp) + end + + test "response with result OK" do + resp = + {:ok, + %{ + status: 400, + body: %{ + responseCode: 0, + success: true + } + }} + + MpesaBase.process_result(resp) + end + end +end diff --git a/test/ex_pesa/Mpesa/reversal_test.exs b/test/ex_pesa/Mpesa/reversal_test.exs new file mode 100644 index 0000000..42f5861 --- /dev/null +++ b/test/ex_pesa/Mpesa/reversal_test.exs @@ -0,0 +1,60 @@ +defmodule ExPesa.Mpesa.ReversalTest do + @moduledoc false + + use ExUnit.Case, async: true + + import Tesla.Mock + doctest ExPesa.Mpesa.Reversal + + alias ExPesa.Mpesa.Reversal + + @base_url "https://sandbox.safaricom.co.ke" + + setup do + mock(fn + %{ + url: "#{@base_url}/oauth/v1/generate?grant_type=client_credentials", + method: :get + } -> + %Tesla.Env{ + status: 200, + body: %{ + "access_token" => "SGWcJPtNtYNPGm6uSYR9yPYrAI3Bm", + "expires_in" => "3599" + } + } + + %{url: "#{@base_url}/mpesa/reversal/v1/request", method: :post} -> + %Tesla.Env{ + status: 200, + body: + Jason.encode!(%{ + "ConversationID" => "AG_20201011_00006511c0024c170286", + "OriginatorConversationID" => "8094-41340768-1", + "ResponseCode" => "0", + "ResponseDescription" => "Accept the service request successfully." + }) + } + end) + + :ok + end + + describe "Mpesa Reversal" do + test "reverse/2 makes a successful request" do + {:ok, result} = Reversal.reverse(%{amount: 30, transaction_id: "LGR013H3J2"}) + + assert result["ResponseCode"] == "0" + end + + test "reverse/2 returns an error if amount is missing" do + {:error, result} = Reversal.reverse(%{transaction_id: "LGR013H3J2"}) + assert result =~ "either transaction_id or amount is missing from the given params" + end + + test "reverse/2 returns an error if transaction_id is missing" do + {:error, result} = Reversal.reverse(%{amount: 30}) + assert result =~ "either transaction_id or amount is missing from the given params" + end + end +end diff --git a/test/ex_pesa/Mpesa/stk_test.exs b/test/ex_pesa/Mpesa/stk_test.exs index 64417b3..7962359 100644 --- a/test/ex_pesa/Mpesa/stk_test.exs +++ b/test/ex_pesa/Mpesa/stk_test.exs @@ -34,12 +34,26 @@ defmodule ExPesa.Mpesa.StkTest do "ResponseDescription" => "Success. Request accepted for processing" }) } + + %{url: "https://sandbox.safaricom.co.ke/mpesa/stkpushquery/v1/query", method: :post} -> + %Tesla.Env{ + status: 200, + body: + Jason.encode!(%{ + "CheckoutRequestID" => "ws_CO_260820202102496165", + "MerchantRequestID" => "11130-78831728-4", + "ResponseCode" => "0", + "ResponseDescription" => "The service request has been accepted successsfully", + "ResultCode" => "1032", + "ResultDesc" => "Request cancelled by user" + }) + } end) :ok end - describe "Mpesa STK Push" do + describe "Mpesa STK Push/ Validate Transaction" do test "request/1 should Initiate STK with required parameters" do request_details = %{ amount: 10, @@ -59,4 +73,17 @@ defmodule ExPesa.Mpesa.StkTest do {:error, result} = Stk.request(%{}) "Required Parameters missing, 'phone, 'amount', 'reference', 'description'" = result end + + test "validate/1 should validate transaction successfully" do + {:ok, result} = Stk.validate(%{checkout_request_id: "ws_CO_260820202102496165"}) + + assert result["CheckoutRequestID"] == "ws_CO_260820202102496165" + assert result["ResponseCode"] == "0" + assert result["ResultDesc"] == "Request cancelled by user" + end + + test "validate/1 should error out without required parameter" do + {:error, result} = Stk.validate(%{}) + "Required Parameter missing, 'CheckoutRequestID'" = result + end end diff --git a/test/ex_pesa/Mpesa/token_server_test.exs b/test/ex_pesa/Mpesa/token_server_test.exs new file mode 100644 index 0000000..7000388 --- /dev/null +++ b/test/ex_pesa/Mpesa/token_server_test.exs @@ -0,0 +1,52 @@ +defmodule ExPesa.Mpesa.TokenServerTest do + @moduledoc false + + import Tesla.Mock + use ExUnit.Case, async: true + alias ExPesa.Mpesa.TokenServer + alias ExPesa.Mpesa.MpesaBase + + setup do + mock(fn + %{ + url: "https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials", + method: :get + } -> + %Tesla.Env{ + status: 200, + body: %{ + "access_token" => "SGWcJPtNtYNPGm6uSYR9yPYrAI3Bm", + "expires_in" => "3599" + } + } + end) + + :ok + end + + test "starts OTP server" do + TokenServer.start_link() + end + + test "stores and retrieves token" do + org_token = "SGWcJPtNtYNPGm6uSYR9yPYrAI3Bm" + TokenServer.insert({org_token, DateTime.add(DateTime.utc_now(), 3550, :second)}) + + assert {token, datetime} = TokenServer.get() + assert token === org_token + assert DateTime.compare(datetime, DateTime.utc_now()) === :gt + end + + test "new token generated when expired" do + org_token = "SGWcJPtNtYNPGm6uSYR9yPYrAI" + TokenServer.insert({org_token, DateTime.add(DateTime.utc_now(), -60, :second)}) + {_token, datetime} = TokenServer.get() + assert DateTime.compare(datetime, DateTime.utc_now()) !== :gt + + MpesaBase.token(MpesaBase.auth_client()) + + {token, datetime} = TokenServer.get() + assert token !== org_token + assert DateTime.compare(datetime, DateTime.utc_now()) === :gt + end +end diff --git a/test/ex_pesa/Mpesa/transaction_status_test.exs b/test/ex_pesa/Mpesa/transaction_status_test.exs new file mode 100644 index 0000000..055e7a0 --- /dev/null +++ b/test/ex_pesa/Mpesa/transaction_status_test.exs @@ -0,0 +1,65 @@ +defmodule ExPesa.Mpesa.TransactionStatusTest do + @moduledoc false + + use ExUnit.Case, async: true + + import Tesla.Mock + doctest ExPesa.Mpesa.TransactionStatus + + alias ExPesa.Mpesa.TransactionStatus + + setup do + mock(fn + %{ + url: "https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials", + method: :get + } -> + %Tesla.Env{ + status: 200, + body: %{ + "access_token" => "SGWcJPtNtYNPGm6uSYR9yPYrAI3Bm", + "expires_in" => "3599" + } + } + + %{ + url: "https://sandbox.safaricom.co.ke/mpesa/transactionstatus/v1/query", + method: :post + } -> + %Tesla.Env{ + status: 200, + body: %{ + "ConversationID" => "AG_20201010_000056be35a7b266b43e", + "OriginatorConversationID" => "27288-72545279-2", + "ResponseCode" => "0", + "ResponseDescription" => "Accept the service request successfully." + } + } + end) + + :ok + end + + describe "Mpesa Transaction Status" do + test "request/1 should Initiate a TransactionStatus request" do + query_details = %{ + transaction_id: "SOME7803", + receiver_party: "600247", + identifier_type: 4, + remarks: "CustomerPayBillOnline", + occasion: "TransactionReversal" + } + + {:ok, result} = TransactionStatus.request(query_details) + + assert result["ResponseDescription"] == "Accept the service request successfully." + end + + test "request/1 should error out without required parameter" do + {:error, result} = TransactionStatus.request({}) + + "Some Required Parameter missing, check whether you have 'transaction_id', 'receiver_party', 'identifier_type', 'remarks'" = + result + end + end +end diff --git a/test/ex_pesa/utils_test.exs b/test/ex_pesa/utils_test.exs new file mode 100644 index 0000000..947ea85 --- /dev/null +++ b/test/ex_pesa/utils_test.exs @@ -0,0 +1,12 @@ +defmodule ExPesa.UtilsTest do + @moduledoc false + + use ExUnit.Case, async: true + + alias ExPesa.Util + + describe "generates secutityCredential" do + result = Util.get_security_credential_for(:b2b) + assert is_bitstring(result) + end +end