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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +288 -0
  6. data/Rakefile +12 -0
  7. data/lib/lipwa/auth_strategies/base.rb +18 -0
  8. data/lib/lipwa/auth_strategies/bearer_token.rb +27 -0
  9. data/lib/lipwa/auth_strategies/none.rb +14 -0
  10. data/lib/lipwa/auth_strategies.rb +5 -0
  11. data/lib/lipwa/capabilities/c2b.rb +102 -0
  12. data/lib/lipwa/capabilities/disbursement.rb +129 -0
  13. data/lib/lipwa/capabilities/refund.rb +102 -0
  14. data/lib/lipwa/capabilities/status_query.rb +101 -0
  15. data/lib/lipwa/capabilities/stk_push.rb +86 -0
  16. data/lib/lipwa/capability.rb +37 -0
  17. data/lib/lipwa/configuration.rb +13 -0
  18. data/lib/lipwa/contracts/c2b_register_urls_contract.rb +32 -0
  19. data/lib/lipwa/contracts/c2b_simulate_contract.rb +41 -0
  20. data/lib/lipwa/contracts/disbursement_contract.rb +66 -0
  21. data/lib/lipwa/contracts/refund_contract.rb +39 -0
  22. data/lib/lipwa/contracts/status_query_contract.rb +30 -0
  23. data/lib/lipwa/contracts/stk_push_contract.rb +40 -0
  24. data/lib/lipwa/errors.rb +48 -0
  25. data/lib/lipwa/gateway.rb +74 -0
  26. data/lib/lipwa/gateways/mpesa/auth.rb +98 -0
  27. data/lib/lipwa/gateways/mpesa/security_credential.rb +30 -0
  28. data/lib/lipwa/gateways/mpesa.rb +69 -0
  29. data/lib/lipwa/gateways.rb +25 -0
  30. data/lib/lipwa/http_adapter.rb +110 -0
  31. data/lib/lipwa/money.rb +36 -0
  32. data/lib/lipwa/response.rb +21 -0
  33. data/lib/lipwa/types.rb +18 -0
  34. data/lib/lipwa/version.rb +5 -0
  35. data/lib/lipwa/webhook.rb +68 -0
  36. data/lib/lipwa/webhooks/mpesa.rb +101 -0
  37. data/lib/lipwa.rb +32 -0
  38. data/plan.md +282 -0
  39. data/sig/lipwa.rbs +4 -0
  40. 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
@@ -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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auth_strategies/base"
4
+ require_relative "auth_strategies/none"
5
+ require_relative "auth_strategies/bearer_token"
@@ -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