lipwa 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +288 -0
- data/Rakefile +12 -0
- data/lib/lipwa/auth_strategies/base.rb +18 -0
- data/lib/lipwa/auth_strategies/bearer_token.rb +27 -0
- data/lib/lipwa/auth_strategies/none.rb +14 -0
- data/lib/lipwa/auth_strategies.rb +5 -0
- data/lib/lipwa/capabilities/c2b.rb +102 -0
- data/lib/lipwa/capabilities/disbursement.rb +129 -0
- data/lib/lipwa/capabilities/refund.rb +102 -0
- data/lib/lipwa/capabilities/status_query.rb +101 -0
- data/lib/lipwa/capabilities/stk_push.rb +86 -0
- data/lib/lipwa/capability.rb +37 -0
- data/lib/lipwa/configuration.rb +13 -0
- data/lib/lipwa/contracts/c2b_register_urls_contract.rb +32 -0
- data/lib/lipwa/contracts/c2b_simulate_contract.rb +41 -0
- data/lib/lipwa/contracts/disbursement_contract.rb +66 -0
- data/lib/lipwa/contracts/refund_contract.rb +39 -0
- data/lib/lipwa/contracts/status_query_contract.rb +30 -0
- data/lib/lipwa/contracts/stk_push_contract.rb +40 -0
- data/lib/lipwa/errors.rb +48 -0
- data/lib/lipwa/gateway.rb +74 -0
- data/lib/lipwa/gateways/mpesa/auth.rb +98 -0
- data/lib/lipwa/gateways/mpesa/security_credential.rb +30 -0
- data/lib/lipwa/gateways/mpesa.rb +69 -0
- data/lib/lipwa/gateways.rb +25 -0
- data/lib/lipwa/http_adapter.rb +110 -0
- data/lib/lipwa/money.rb +36 -0
- data/lib/lipwa/response.rb +21 -0
- data/lib/lipwa/types.rb +18 -0
- data/lib/lipwa/version.rb +5 -0
- data/lib/lipwa/webhook.rb +68 -0
- data/lib/lipwa/webhooks/mpesa.rb +101 -0
- data/lib/lipwa.rb +32 -0
- data/plan.md +282 -0
- data/sig/lipwa.rbs +4 -0
- metadata +212 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8c3c39d3377082721898d3509aad157a7736fe63907624e3af5c4a55268f9329
|
|
4
|
+
data.tar.gz: 1a6d082d6196adb81e1ee610609663156dbf3f35e4c2f82a5f7ab54b66797a7f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c5fdb5ec67a5b767ef60d0b709540a666cbede85e80d12880e8eaf956a09b57ae0088727ad35bff6c7c5b0ef65c5791bd290b146b093bf65c191c2b011b27a39
|
|
7
|
+
data.tar.gz: 4e078b48e6ca4b10cf76bcff8fc1c2a5d8e5c0c0e1534dd56131b282dfb8b23c0f560adf77af02498a16d55da4c33cd636ccf31a70440248ad6673962a4dc76d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
## [0.1.1]
|
|
2
|
+
|
|
3
|
+
- Add `Lipwa::HttpAdapter`, a Faraday-based HTTP wrapper
|
|
4
|
+
- Add `Lipwa::Gateways` container/registry for gateway implementations
|
|
5
|
+
- Wire dry-rb dependencies into the gemspec and `Lipwa.configure`
|
|
6
|
+
- Add M-Pesa OAuth2 client-credentials token handling
|
|
7
|
+
- Implement the `StkPush` capability
|
|
8
|
+
- Implement the `C2B` capability (validation/confirmation)
|
|
9
|
+
- Implement the `WebhookHandling` capability and `Lipwa::Webhook`
|
|
10
|
+
- Add VCR/WebMock cassettes for M-Pesa sandbox flows
|
|
11
|
+
- Implement the `Disbursement` capability (B2C/B2B)
|
|
12
|
+
- Implement the `StatusQuery` capability
|
|
13
|
+
|
|
14
|
+
## [0.1.0] - 2026-08-28
|
|
15
|
+
|
|
16
|
+
- Initial release
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"lipwa" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["mcpaul2058@gmail.com"](mailto:"mcpaul2058@gmail.com").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Oguda
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# Lipwa
|
|
2
|
+
|
|
3
|
+
Lipwa is a unified Ruby gem for accepting and disbursing payments across
|
|
4
|
+
African payment providers — mobile money, bank APIs, and (eventually) card
|
|
5
|
+
rails — behind one consistent, capability-based interface. It currently
|
|
6
|
+
ships a full integration with Safaricom's **M-Pesa Daraja API**: STK Push,
|
|
7
|
+
C2B, B2C/B2B disbursements, and inbound webhook handling.
|
|
8
|
+
|
|
9
|
+
Every gateway call returns a `Dry::Monads::Result`
|
|
10
|
+
(`Success(Lipwa::Response)` / `Failure(Lipwa::Error)`) instead of raising —
|
|
11
|
+
see [Error handling](#error-handling) below. Only genuine programmer
|
|
12
|
+
mistakes (bad config, calling a capability the gateway doesn't support) are
|
|
13
|
+
raised as exceptions.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Install the gem and add it to the application's Gemfile by executing:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bundle add lipwa
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
If bundler is not being used to manage dependencies, install the gem by
|
|
24
|
+
executing:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
gem install lipwa
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Configuration
|
|
31
|
+
|
|
32
|
+
Set gem-wide defaults once (logger, default timeout, Faraday adapter — all
|
|
33
|
+
optional):
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
Lipwa.configure do |config|
|
|
37
|
+
config.logger = Rails.logger
|
|
38
|
+
config.default_timeout = 10
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then configure each gateway you use. For M-Pesa:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
Lipwa::Gateways::Mpesa.configure do |c|
|
|
46
|
+
c.env = :sandbox # or :production
|
|
47
|
+
c.consumer_key = ENV["MPESA_CONSUMER_KEY"]
|
|
48
|
+
c.consumer_secret = ENV["MPESA_CONSUMER_SECRET"]
|
|
49
|
+
c.shortcode = ENV["MPESA_SHORTCODE"]
|
|
50
|
+
c.passkey = ENV["MPESA_PASSKEY"] # STK Push only
|
|
51
|
+
|
|
52
|
+
# B2C/B2B disbursements only:
|
|
53
|
+
c.initiator_name = ENV["MPESA_INITIATOR_NAME"]
|
|
54
|
+
c.initiator_password = ENV["MPESA_INITIATOR_PASSWORD"]
|
|
55
|
+
c.security_credential_cert = File.read(ENV["MPESA_CERT_PATH"])
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`consumer_key`/`consumer_secret` authenticate every Daraja call (OAuth2
|
|
60
|
+
client-credentials, cached and auto-refreshed). `shortcode`/`passkey` are
|
|
61
|
+
only needed for STK Push and C2B. `initiator_name`/`initiator_password`/
|
|
62
|
+
`security_credential_cert` are only needed if you call `#disburse` — see
|
|
63
|
+
[Disbursement](#disbursement-b2c--b2b) for what the cert is and where to
|
|
64
|
+
get it. Configuring only what you actually use is fine; each capability
|
|
65
|
+
raises `Lipwa::ConfigurationError` at call time if something it needs is
|
|
66
|
+
missing, not at load time.
|
|
67
|
+
|
|
68
|
+
Fetch a configured gateway by name instead of referencing the class
|
|
69
|
+
directly:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
Lipwa.gateway(:mpesa).stk_push(...)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
### STK Push (Lipa Na M-Pesa Online)
|
|
78
|
+
|
|
79
|
+
Pushes a payment prompt to the payer's phone. The actual result of the
|
|
80
|
+
payment arrives later at `callback_url` — a successful call here only
|
|
81
|
+
confirms Daraja *accepted* the request, not that the customer paid.
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
result = Lipwa.gateway(:mpesa).stk_push(
|
|
85
|
+
amount: Lipwa::Money.new(amount: 100, currency: "KES"), # 100 = 1.00 KES, minor units
|
|
86
|
+
phone_number: "254712345678",
|
|
87
|
+
account_reference: "ORDER-123",
|
|
88
|
+
callback_url: "https://example.com/webhooks/mpesa/stk"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
result.either(
|
|
92
|
+
->(response) { response.provider_reference }, # CheckoutRequestID
|
|
93
|
+
->(error) { logger.error(error.message) }
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### C2B (Customer to Business)
|
|
98
|
+
|
|
99
|
+
Registers the validation/confirmation webhook URLs Daraja calls when a
|
|
100
|
+
customer pays your paybill/till directly (outside STK Push), and — sandbox
|
|
101
|
+
only — simulates such a payment so you can exercise those URLs without a
|
|
102
|
+
real transaction.
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
Lipwa.gateway(:mpesa).register_urls(
|
|
106
|
+
validation_url: "https://example.com/webhooks/mpesa/validate",
|
|
107
|
+
confirmation_url: "https://example.com/webhooks/mpesa/confirm"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Sandbox only:
|
|
111
|
+
Lipwa.gateway(:mpesa).simulate(
|
|
112
|
+
amount: Lipwa::Money.new(amount: 100, currency: "KES"),
|
|
113
|
+
phone_number: "254712345678",
|
|
114
|
+
bill_ref_number: "ORDER-123"
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Disbursement (B2C / B2B)
|
|
119
|
+
|
|
120
|
+
`#disburse` sends money out from your shortcode — to a customer's phone
|
|
121
|
+
(B2C: salaries, promotions, business payments) or to another business
|
|
122
|
+
(B2B: paybill/till settlement) — driven by `command_id` rather than two
|
|
123
|
+
separate methods:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# B2C — pay out to a customer's phone
|
|
127
|
+
result = Lipwa.gateway(:mpesa).disburse(
|
|
128
|
+
command_id: "SalaryPayment", # or "BusinessPayment" / "PromotionPayment"
|
|
129
|
+
amount: Lipwa::Money.new(amount: 5_000_00, currency: "KES"),
|
|
130
|
+
party_b: "254712345678", # payee MSISDN
|
|
131
|
+
remarks: "August salary",
|
|
132
|
+
result_url: "https://example.com/webhooks/mpesa/b2c/result",
|
|
133
|
+
queue_timeout_url: "https://example.com/webhooks/mpesa/b2c/timeout",
|
|
134
|
+
occasion: "August payroll"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# B2B — settle with another business shortcode
|
|
138
|
+
result = Lipwa.gateway(:mpesa).disburse(
|
|
139
|
+
command_id: "BusinessPayBill", # or "BusinessBuyGoods" / "MerchantToMerchantTransfer"
|
|
140
|
+
amount: Lipwa::Money.new(amount: 10_000_00, currency: "KES"),
|
|
141
|
+
party_b: "600000", # payee business shortcode
|
|
142
|
+
remarks: "Supplier settlement",
|
|
143
|
+
result_url: "https://example.com/webhooks/mpesa/b2b/result",
|
|
144
|
+
queue_timeout_url: "https://example.com/webhooks/mpesa/b2b/timeout",
|
|
145
|
+
account_reference: "INV-2026-08-001" # required for B2B command IDs
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Like STK Push, `#disburse` only confirms Daraja *accepted* the request
|
|
150
|
+
(`ConversationID`) — the outcome (success or failure of the actual
|
|
151
|
+
payout) arrives later at `result_url`.
|
|
152
|
+
|
|
153
|
+
**About `security_credential_cert`**: Daraja requires every B2C/B2B
|
|
154
|
+
request to carry a `SecurityCredential` — your initiator password,
|
|
155
|
+
RSA-encrypted with Safaricom's public certificate. Lipwa does this
|
|
156
|
+
encryption for you (see `Lipwa::Gateways::Mpesa::SecurityCredential`); you
|
|
157
|
+
just need to supply the certificate itself as PEM/DER content via
|
|
158
|
+
`security_credential_cert`. Download it from the Daraja developer
|
|
159
|
+
portal — the **Test Credentials** page for sandbox, or your app's
|
|
160
|
+
production certificate for production — since sandbox and production use
|
|
161
|
+
different certificates and mixing them up causes every B2C/B2B request to
|
|
162
|
+
fail. Don't hardcode certificate content in source; load it from a file or
|
|
163
|
+
secret store, e.g. `c.security_credential_cert = File.read("certs/mpesa_production.cer")`.
|
|
164
|
+
|
|
165
|
+
### Refund
|
|
166
|
+
|
|
167
|
+
`#refund` reverses a completed M-Pesa transaction by its `TransactionID`
|
|
168
|
+
(Daraja's Transaction Reversal API):
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
result = Lipwa.gateway(:mpesa).refund(
|
|
172
|
+
transaction_id: "OEI2AK4Q16",
|
|
173
|
+
amount: Lipwa::Money.new(amount: 100_00, currency: "KES"),
|
|
174
|
+
remarks: "Missing item",
|
|
175
|
+
result_url: "https://example.com/webhooks/mpesa/reversal/result",
|
|
176
|
+
queue_timeout_url: "https://example.com/webhooks/mpesa/reversal/timeout",
|
|
177
|
+
occasion: "Customer complaint"
|
|
178
|
+
)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Like `#disburse`, `#refund` only confirms Daraja *accepted* the reversal
|
|
182
|
+
request — the outcome arrives later at `result_url`. It requires the same
|
|
183
|
+
`security_credential_cert` as `#disburse` — see
|
|
184
|
+
[Disbursement](#disbursement-b2c--b2b) for what the cert is and where to
|
|
185
|
+
get it.
|
|
186
|
+
|
|
187
|
+
### Webhook handling
|
|
188
|
+
|
|
189
|
+
Daraja delivers STK Push results and C2B validation/confirmation as
|
|
190
|
+
inbound HTTP callbacks. Parse and (for M-Pesa) verify them with
|
|
191
|
+
`Lipwa::Webhook`:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# in your webhook controller
|
|
195
|
+
result = Lipwa::Webhook.parse_webhook(provider: :mpesa, body: request.body.read, headers: request.headers)
|
|
196
|
+
|
|
197
|
+
result.either(
|
|
198
|
+
lambda do |event|
|
|
199
|
+
if event.verify_signature(source_ip: request.remote_ip)
|
|
200
|
+
# event.event_type => :stk_callback or :c2b
|
|
201
|
+
# event.success? => whether the STK push succeeded (always true for C2B —
|
|
202
|
+
# C2B callbacks only fire for an already-completed payment)
|
|
203
|
+
# event.provider_reference => CheckoutRequestID (STK) or TransID (C2B)
|
|
204
|
+
handle(event)
|
|
205
|
+
else
|
|
206
|
+
head :forbidden
|
|
207
|
+
end
|
|
208
|
+
end,
|
|
209
|
+
->(error) { logger.error(error.message) }
|
|
210
|
+
)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
M-Pesa doesn't cryptographically sign callbacks, so `verify_signature`
|
|
214
|
+
checks the request's source IP against Safaricom's published callback IP
|
|
215
|
+
ranges instead — always call it before trusting a callback's contents.
|
|
216
|
+
|
|
217
|
+
### `Lipwa::Money`
|
|
218
|
+
|
|
219
|
+
Amounts are always a `Lipwa::Money` — an immutable value object storing
|
|
220
|
+
minor currency units (e.g. `100` = KES 1.00) plus an ISO 4217 currency
|
|
221
|
+
code (defaults to `"KES"`):
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
Lipwa::Money.new(amount: 100, currency: "KES")
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Error handling
|
|
228
|
+
|
|
229
|
+
Gateway methods (`#stk_push`, `#register_urls`, `#simulate`, `#disburse`)
|
|
230
|
+
never raise for expected failure modes — they return a
|
|
231
|
+
`Dry::Monads::Result`:
|
|
232
|
+
|
|
233
|
+
- `Success(Lipwa::Response)` — `#success?`, `#provider_reference`,
|
|
234
|
+
`#message`, `#code`, `#raw` (the parsed provider response). Note a
|
|
235
|
+
`Success` can still wrap `response.success? == false` — Daraja
|
|
236
|
+
synchronous validation errors (bad shortcode, malformed request) come
|
|
237
|
+
back as a normal 200 response with a non-zero `ResponseCode`.
|
|
238
|
+
- `Failure(Lipwa::ValidationError)` — your params failed contract
|
|
239
|
+
validation before any network call was made.
|
|
240
|
+
- `Failure(Lipwa::GatewayError)` — the HTTP call itself failed (timeout,
|
|
241
|
+
connection error) or the provider returned an HTTP error status.
|
|
242
|
+
|
|
243
|
+
`Lipwa::ConfigurationError` and `Lipwa::UnsupportedCapabilityError` are
|
|
244
|
+
raised, not wrapped — they represent programmer/ops mistakes (missing
|
|
245
|
+
credentials, calling a capability a gateway doesn't include) that should
|
|
246
|
+
fail loudly at call time rather than be routed through error-handling
|
|
247
|
+
code.
|
|
248
|
+
|
|
249
|
+
## Development
|
|
250
|
+
|
|
251
|
+
After checking out the repo, run `bin/setup` to install dependencies.
|
|
252
|
+
Then, run `rake test` to run the tests. You can also run `bin/console` for
|
|
253
|
+
an interactive prompt that will allow you to experiment.
|
|
254
|
+
|
|
255
|
+
Tests run against hand-authored VCR cassettes
|
|
256
|
+
(`test/fixtures/vcr_cassettes/mpesa/`) with fake sandbox credentials —
|
|
257
|
+
they never hit Safaricom's real sandbox, so no network access or real
|
|
258
|
+
credentials are needed to run the suite.
|
|
259
|
+
|
|
260
|
+
`examples/rails_api` is a small Rails API app that exercises every
|
|
261
|
+
capability against a real (sandbox) Daraja account — STK Push, C2B,
|
|
262
|
+
disbursement, refund, and inbound webhooks — useful both for evaluating
|
|
263
|
+
the gem and for manually smoke-testing changes. It's excluded from the
|
|
264
|
+
released gem package. See its own README for setup.
|
|
265
|
+
|
|
266
|
+
To install this gem onto your local machine, run `bundle exec rake
|
|
267
|
+
install`. To release a new version, update the version number in
|
|
268
|
+
`version.rb`, and then run `bundle exec rake release`, which will create a
|
|
269
|
+
git tag for the version, push git commits and the created tag, and push
|
|
270
|
+
the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
271
|
+
|
|
272
|
+
## Contributing
|
|
273
|
+
|
|
274
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
275
|
+
https://github.com/kamalogudah/lipwa. This project is intended to be a
|
|
276
|
+
safe, welcoming space for collaboration, and contributors are expected to
|
|
277
|
+
adhere to the [code of conduct](https://github.com/kamalogudah/lipwa/blob/master/CODE_OF_CONDUCT.md).
|
|
278
|
+
|
|
279
|
+
## License
|
|
280
|
+
|
|
281
|
+
The gem is available as open source under the terms of the
|
|
282
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
|
283
|
+
|
|
284
|
+
## Code of Conduct
|
|
285
|
+
|
|
286
|
+
Everyone interacting in the Lipwa project's codebases, issue trackers,
|
|
287
|
+
chat rooms and mailing lists is expected to follow the
|
|
288
|
+
[code of conduct](https://github.com/kamalogudah/lipwa/blob/master/CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lipwa
|
|
4
|
+
module AuthStrategies
|
|
5
|
+
# Interface every auth strategy implements. An HttpAdapter is handed
|
|
6
|
+
# one of these and calls #apply on every outgoing request, letting
|
|
7
|
+
# the strategy mutate headers (bearer token, HMAC/RSA signature,
|
|
8
|
+
# timestamp, whatever the provider needs) without HttpAdapter knowing
|
|
9
|
+
# which kind of auth it's dealing with.
|
|
10
|
+
class Base
|
|
11
|
+
# env is a Faraday::Env — mutate env.request_headers / env.body
|
|
12
|
+
# in place. Must be implemented by subclasses.
|
|
13
|
+
def apply(env)
|
|
14
|
+
raise NotImplementedError, "#{self.class} must implement #apply"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Lipwa
|
|
6
|
+
module AuthStrategies
|
|
7
|
+
# Sets `Authorization: Bearer <token>` on every request. Covers the
|
|
8
|
+
# OAuth2 client-credentials providers (M-Pesa Daraja, Co-op Bank):
|
|
9
|
+
# each gateway supplies its own token source — typically an object
|
|
10
|
+
# that fetches and caches a token from the provider's OAuth endpoint
|
|
11
|
+
# and refreshes it once expired — as `token_provider`.
|
|
12
|
+
#
|
|
13
|
+
# token_provider must respond to #call and return a token String.
|
|
14
|
+
class BearerToken < Base
|
|
15
|
+
def initialize(token_provider)
|
|
16
|
+
raise ArgumentError, "token_provider must respond to #call" unless token_provider.respond_to?(:call)
|
|
17
|
+
|
|
18
|
+
super()
|
|
19
|
+
@token_provider = token_provider
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def apply(env)
|
|
23
|
+
env.request_headers["Authorization"] = "Bearer #{@token_provider.call}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Lipwa
|
|
6
|
+
module AuthStrategies
|
|
7
|
+
# Default strategy: adds nothing. Used for endpoints that need no
|
|
8
|
+
# auth (e.g. some public sandbox calls) or as a placeholder while a
|
|
9
|
+
# gateway is configured.
|
|
10
|
+
class None < Base
|
|
11
|
+
def apply(env); end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/monads"
|
|
4
|
+
require_relative "../capability"
|
|
5
|
+
require_relative "../contracts/c2b_register_urls_contract"
|
|
6
|
+
require_relative "../contracts/c2b_simulate_contract"
|
|
7
|
+
|
|
8
|
+
module Lipwa
|
|
9
|
+
module Capabilities
|
|
10
|
+
# Customer To Business (C2B): registers the validation/confirmation
|
|
11
|
+
# webhook URLs Daraja calls when a customer pays a paybill/till
|
|
12
|
+
# directly (outside STK Push), and — sandbox only — simulates such
|
|
13
|
+
# a payment for testing. The actual payment notification arrives
|
|
14
|
+
# later at the registered confirmation URL; these calls only manage
|
|
15
|
+
# that registration and drive the sandbox simulator.
|
|
16
|
+
module C2B
|
|
17
|
+
extend Lipwa::Capability
|
|
18
|
+
include Dry::Monads[:result]
|
|
19
|
+
self.capability_name = :c2b
|
|
20
|
+
|
|
21
|
+
REGISTER_URLS_PATH = "/mpesa/c2b/v1/registerurl"
|
|
22
|
+
SIMULATE_PATH = "/mpesa/c2b/v1/simulate"
|
|
23
|
+
|
|
24
|
+
REGISTER_URLS_CONTRACT = Lipwa::Contracts::C2bRegisterUrlsContract.new
|
|
25
|
+
SIMULATE_CONTRACT = Lipwa::Contracts::C2bSimulateContract.new
|
|
26
|
+
|
|
27
|
+
def register_urls(validation_url:, confirmation_url:, response_type: "Completed")
|
|
28
|
+
validation = REGISTER_URLS_CONTRACT.call(
|
|
29
|
+
validation_url: validation_url,
|
|
30
|
+
confirmation_url: confirmation_url,
|
|
31
|
+
response_type: response_type
|
|
32
|
+
)
|
|
33
|
+
return Failure(Lipwa::ValidationError.new(validation)) if validation.failure?
|
|
34
|
+
|
|
35
|
+
perform_register_urls(validation.to_h)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Sandbox-only: triggers a simulated C2B payment so the
|
|
39
|
+
# registered validation/confirmation URLs can be exercised
|
|
40
|
+
# without a real customer transaction.
|
|
41
|
+
def simulate(amount:, phone_number:, bill_ref_number:, command_id: "CustomerPayBillOnline")
|
|
42
|
+
validation = SIMULATE_CONTRACT.call(
|
|
43
|
+
amount: amount,
|
|
44
|
+
phone_number: phone_number,
|
|
45
|
+
bill_ref_number: bill_ref_number,
|
|
46
|
+
command_id: command_id
|
|
47
|
+
)
|
|
48
|
+
return Failure(Lipwa::ValidationError.new(validation)) if validation.failure?
|
|
49
|
+
|
|
50
|
+
perform_simulate(validation.to_h)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def perform_register_urls(params)
|
|
56
|
+
response = http.post(REGISTER_URLS_PATH, body: register_urls_body(params))
|
|
57
|
+
|
|
58
|
+
build_c2b_response(response.body)
|
|
59
|
+
rescue Lipwa::GatewayError => e
|
|
60
|
+
Failure(e)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def register_urls_body(params)
|
|
64
|
+
{
|
|
65
|
+
ShortCode: self.class.config.shortcode,
|
|
66
|
+
ResponseType: params[:response_type],
|
|
67
|
+
ConfirmationURL: params[:confirmation_url],
|
|
68
|
+
ValidationURL: params[:validation_url]
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def perform_simulate(params)
|
|
73
|
+
response = http.post(SIMULATE_PATH, body: simulate_body(params))
|
|
74
|
+
|
|
75
|
+
build_c2b_response(response.body)
|
|
76
|
+
rescue Lipwa::GatewayError => e
|
|
77
|
+
Failure(e)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def simulate_body(params)
|
|
81
|
+
{
|
|
82
|
+
ShortCode: self.class.config.shortcode,
|
|
83
|
+
CommandID: params[:command_id],
|
|
84
|
+
Amount: params[:amount].amount,
|
|
85
|
+
Msisdn: params[:phone_number],
|
|
86
|
+
BillRefNumber: params[:bill_ref_number]
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_c2b_response(body)
|
|
91
|
+
Success(Lipwa::Response.new(
|
|
92
|
+
success: body["ResponseCode"] == "0",
|
|
93
|
+
provider_reference: body["ConversationID"] || body["OriginatorConversationID"] ||
|
|
94
|
+
body["OriginatorCoversationID"],
|
|
95
|
+
message: body["ResponseDescription"] || body["errorMessage"],
|
|
96
|
+
code: (body["ResponseCode"] || body["errorCode"])&.to_s,
|
|
97
|
+
raw: body
|
|
98
|
+
))
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/monads"
|
|
4
|
+
require_relative "../capability"
|
|
5
|
+
require_relative "../contracts/disbursement_contract"
|
|
6
|
+
|
|
7
|
+
module Lipwa
|
|
8
|
+
module Capabilities
|
|
9
|
+
# Disburses funds from the gateway's shortcode to a customer (B2C —
|
|
10
|
+
# salary/promotion/business payments to an MSISDN) or another
|
|
11
|
+
# business (B2B — paybill/till settlement), driven by `command_id`
|
|
12
|
+
# rather than two separate methods, per the target #disburse API.
|
|
13
|
+
# Unlike StkPush/C2B, Daraja requires a SecurityCredential — the
|
|
14
|
+
# initiator password RSA-encrypted with Safaricom's public
|
|
15
|
+
# certificate — computed fresh on every call since PKCS#1 padding is
|
|
16
|
+
# randomized.
|
|
17
|
+
module Disbursement
|
|
18
|
+
extend Lipwa::Capability
|
|
19
|
+
include Dry::Monads[:result]
|
|
20
|
+
self.capability_name = :disbursement
|
|
21
|
+
|
|
22
|
+
B2C_PATH = "/mpesa/b2c/v1/paymentrequest"
|
|
23
|
+
B2B_PATH = "/mpesa/b2b/v1/paymentrequest"
|
|
24
|
+
B2C_COMMAND_IDS = Lipwa::Contracts::DisbursementContract::B2C_COMMAND_IDS
|
|
25
|
+
B2B_COMMAND_IDS = Lipwa::Contracts::DisbursementContract::B2B_COMMAND_IDS
|
|
26
|
+
|
|
27
|
+
CONTRACT = Lipwa::Contracts::DisbursementContract.new
|
|
28
|
+
|
|
29
|
+
# rubocop:disable Metrics/ParameterLists
|
|
30
|
+
def disburse(command_id:, amount:, party_b:, remarks:, result_url:, queue_timeout_url:, occasion: nil,
|
|
31
|
+
account_reference: nil)
|
|
32
|
+
validate_and_disburse(
|
|
33
|
+
command_id: command_id, amount: amount, party_b: party_b, remarks: remarks,
|
|
34
|
+
result_url: result_url, queue_timeout_url: queue_timeout_url,
|
|
35
|
+
occasion: occasion, account_reference: account_reference
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
# rubocop:enable Metrics/ParameterLists
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def validate_and_disburse(args)
|
|
43
|
+
validation = CONTRACT.call(args)
|
|
44
|
+
return Failure(Lipwa::ValidationError.new(validation)) if validation.failure?
|
|
45
|
+
|
|
46
|
+
perform_disburse(validation.to_h)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def perform_disburse(params)
|
|
50
|
+
ensure_disbursement_config_present!
|
|
51
|
+
path = B2C_COMMAND_IDS.include?(params[:command_id]) ? B2C_PATH : B2B_PATH
|
|
52
|
+
|
|
53
|
+
response = http.post(path, body: disbursement_body(params))
|
|
54
|
+
|
|
55
|
+
build_disbursement_response(response.body)
|
|
56
|
+
rescue Lipwa::GatewayError => e
|
|
57
|
+
Failure(e)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def disbursement_body(params)
|
|
61
|
+
if B2C_COMMAND_IDS.include?(params[:command_id])
|
|
62
|
+
b2c_body(params)
|
|
63
|
+
else
|
|
64
|
+
b2b_body(params)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def b2c_body(params)
|
|
69
|
+
{
|
|
70
|
+
InitiatorName: self.class.config.initiator_name,
|
|
71
|
+
SecurityCredential: security_credential,
|
|
72
|
+
CommandID: params[:command_id],
|
|
73
|
+
PartyA: self.class.config.shortcode,
|
|
74
|
+
PartyB: params[:party_b],
|
|
75
|
+
Occasion: params[:occasion]
|
|
76
|
+
}.merge(shared_disbursement_fields(params))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def b2b_body(params)
|
|
80
|
+
{
|
|
81
|
+
Initiator: self.class.config.initiator_name,
|
|
82
|
+
SecurityCredential: security_credential,
|
|
83
|
+
CommandID: params[:command_id],
|
|
84
|
+
SenderIdentifierType: "4",
|
|
85
|
+
RecieverIdentifierType: "4",
|
|
86
|
+
PartyA: self.class.config.shortcode,
|
|
87
|
+
PartyB: params[:party_b],
|
|
88
|
+
AccountReference: params[:account_reference]
|
|
89
|
+
}.merge(shared_disbursement_fields(params))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def shared_disbursement_fields(params)
|
|
93
|
+
{
|
|
94
|
+
Amount: params[:amount].amount,
|
|
95
|
+
Remarks: params[:remarks],
|
|
96
|
+
QueueTimeOutURL: params[:queue_timeout_url],
|
|
97
|
+
ResultURL: params[:result_url]
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def security_credential
|
|
102
|
+
Lipwa::Gateways::Mpesa::SecurityCredential.encrypt(
|
|
103
|
+
self.class.config.initiator_password,
|
|
104
|
+
cert: self.class.config.security_credential_cert
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build_disbursement_response(body)
|
|
109
|
+
Success(Lipwa::Response.new(
|
|
110
|
+
success: body["ResponseCode"] == "0",
|
|
111
|
+
provider_reference: body["ConversationID"] || body["OriginatorConversationID"] ||
|
|
112
|
+
body["OriginatorCoversationID"],
|
|
113
|
+
message: body["ResponseDescription"] || body["errorMessage"],
|
|
114
|
+
code: (body["ResponseCode"] || body["errorCode"])&.to_s,
|
|
115
|
+
raw: body
|
|
116
|
+
))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def ensure_disbursement_config_present!
|
|
120
|
+
config = self.class.config
|
|
121
|
+
return if config.initiator_name && config.initiator_password && config.security_credential_cert
|
|
122
|
+
|
|
123
|
+
raise Lipwa::ConfigurationError,
|
|
124
|
+
"#{self.class} is missing initiator_name/initiator_password/security_credential_cert " \
|
|
125
|
+
"— set them via .configure to use #disburse"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|