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
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/configurable"
4
+ require_relative "errors"
5
+ require_relative "types"
6
+ require_relative "capability"
7
+ require_relative "auth_strategies"
8
+ require_relative "http_adapter"
9
+ require_relative "configuration"
10
+
11
+ module Lipwa
12
+ # Abstract base every provider gateway (Mpesa, CoopBank, Jenga, ...)
13
+ # inherits from. Holds the gateway's Dry::Configurable settings, lazily
14
+ # builds the shared HttpAdapter from them, and answers `capability?`
15
+ # by checking which Lipwa::Capabilities::* modules the subclass
16
+ # actually included — see Lipwa::Capability.
17
+ class Gateway
18
+ extend Dry::Configurable
19
+
20
+ setting :env, default: :sandbox, constructor: Types::Environment
21
+ setting :base_url
22
+ setting :timeout
23
+ setting :open_timeout, default: 5
24
+ setting :logger
25
+ setting :auth_strategy
26
+
27
+ class << self
28
+ def capabilities
29
+ @capabilities ||= Set.new
30
+ end
31
+
32
+ # Called by Lipwa::Capability#included — not meant to be called
33
+ # directly.
34
+ def register_capability(name)
35
+ capabilities << name.to_sym
36
+ end
37
+
38
+ def inherited(subclass)
39
+ super
40
+ subclass.instance_variable_set(:@capabilities, capabilities.dup)
41
+ end
42
+ end
43
+
44
+ def capability?(name)
45
+ self.class.capabilities.include?(name.to_sym)
46
+ end
47
+
48
+ def http
49
+ @http ||= build_http_adapter
50
+ end
51
+
52
+ private
53
+
54
+ def build_http_adapter
55
+ config = self.class.config
56
+ ensure_base_url_configured!(config)
57
+
58
+ HttpAdapter.new(
59
+ base_url: config.base_url,
60
+ auth_strategy: config.auth_strategy || AuthStrategies::None.new,
61
+ timeout: config.timeout || Lipwa.config.default_timeout,
62
+ open_timeout: config.open_timeout,
63
+ logger: config.logger || Lipwa.config.logger,
64
+ adapter: Lipwa.config.adapter
65
+ )
66
+ end
67
+
68
+ def ensure_base_url_configured!(config)
69
+ return if config.base_url
70
+
71
+ raise Lipwa::ConfigurationError, "#{self.class} is missing `base_url` — set it via .configure"
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require_relative "../../errors"
5
+ require_relative "../../http_adapter"
6
+
7
+ module Lipwa
8
+ module Gateways
9
+ class Mpesa
10
+ # OAuth2 client-credentials token acquisition + caching for
11
+ # Safaricom Daraja. Exposes #call so an instance can be handed
12
+ # directly to Lipwa::AuthStrategies::BearerToken as its
13
+ # token_provider: the STK Push/C2B/B2C HttpAdapter calls #call on
14
+ # every request, and gets back a cached token until it's close to
15
+ # expiring, at which point a fresh one is fetched transparently.
16
+ #
17
+ # The token endpoint itself authenticates with HTTP Basic auth
18
+ # (consumer_key:consumer_secret) rather than a bearer token, so
19
+ # this class talks to it directly instead of going through
20
+ # AuthStrategies.
21
+ class Auth
22
+ BASE_URLS = {
23
+ sandbox: "https://sandbox.safaricom.co.ke",
24
+ production: "https://api.safaricom.co.ke"
25
+ }.freeze
26
+
27
+ OAUTH_PATH = "/oauth/v1/generate"
28
+
29
+ # Refresh this many seconds before the token's reported expiry,
30
+ # so a request that starts just before expiry doesn't race a
31
+ # token that goes stale mid-flight.
32
+ EXPIRY_BUFFER = 60
33
+
34
+ def initialize(consumer_key:, consumer_secret:, env: :sandbox, http: nil, clock: -> { Time.now })
35
+ ensure_credentials_present!(consumer_key, consumer_secret)
36
+
37
+ @consumer_key = consumer_key
38
+ @consumer_secret = consumer_secret
39
+ @clock = clock
40
+ @http = http || Lipwa::HttpAdapter.new(base_url: base_url_for(env))
41
+ @mutex = Mutex.new
42
+ @token = nil
43
+ @expires_at = nil
44
+ end
45
+
46
+ # Returns a cached access token, or fetches (and caches) a new
47
+ # one if there's none yet or the cached one is about to expire.
48
+ # Safe to call concurrently: only one refresh happens in flight,
49
+ # other callers block on it and reuse its result.
50
+ def call
51
+ return @token if fresh?
52
+
53
+ @mutex.synchronize do
54
+ refresh! unless fresh?
55
+ end
56
+
57
+ @token
58
+ end
59
+
60
+ private
61
+
62
+ def fresh?
63
+ @token && @expires_at && @clock.call < @expires_at
64
+ end
65
+
66
+ def refresh!
67
+ response = @http.get(
68
+ OAUTH_PATH,
69
+ params: { grant_type: "client_credentials" },
70
+ headers: { "Authorization" => "Basic #{encoded_credentials}" }
71
+ )
72
+ body = response.body
73
+
74
+ @token = body.fetch("access_token")
75
+ @expires_at = @clock.call + body.fetch("expires_in").to_i - EXPIRY_BUFFER
76
+ rescue KeyError
77
+ raise Lipwa::GatewayError.new("unexpected Daraja OAuth response: #{body.inspect}", raw: body)
78
+ end
79
+
80
+ def encoded_credentials
81
+ Base64.strict_encode64("#{@consumer_key}:#{@consumer_secret}")
82
+ end
83
+
84
+ def base_url_for(env)
85
+ BASE_URLS.fetch(env.to_sym) do
86
+ raise Lipwa::ConfigurationError, "unknown M-Pesa env #{env.inspect} — must be :sandbox or :production"
87
+ end
88
+ end
89
+
90
+ def ensure_credentials_present!(consumer_key, consumer_secret)
91
+ return if consumer_key && consumer_secret
92
+
93
+ raise Lipwa::ConfigurationError, "Mpesa::Auth requires both consumer_key and consumer_secret"
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "openssl"
5
+ require_relative "../../errors"
6
+
7
+ module Lipwa
8
+ module Gateways
9
+ class Mpesa
10
+ # Encrypts the B2C/B2B initiator password into the SecurityCredential
11
+ # Daraja expects: RSA-encrypted (PKCS#1 v1.5 padding) with Safaricom's
12
+ # public certificate, then base64-encoded. Sandbox and production use
13
+ # different certificates — download the right one from the Daraja
14
+ # portal (Test Credentials page for sandbox, the app's production
15
+ # cert for production) and pass its PEM/DER content as
16
+ # `security_credential_cert:` on the Mpesa gateway config. Daraja is
17
+ # the only party that ever decrypts this, so there's nothing to
18
+ # verify locally beyond "does this cert parse and encrypt".
19
+ module SecurityCredential
20
+ def self.encrypt(password, cert:)
21
+ certificate = OpenSSL::X509::Certificate.new(cert)
22
+ encrypted = certificate.public_key.public_encrypt(password, OpenSSL::PKey::RSA::PKCS1_PADDING)
23
+ Base64.strict_encode64(encrypted)
24
+ rescue OpenSSL::X509::CertificateError, OpenSSL::PKey::PKeyError => e
25
+ raise Lipwa::ConfigurationError, "invalid security_credential_cert: #{e.message}"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../gateway"
4
+ require_relative "../gateways"
5
+ require_relative "../capabilities/c2b"
6
+ require_relative "../capabilities/stk_push"
7
+ require_relative "../capabilities/disbursement"
8
+ require_relative "../capabilities/status_query"
9
+ require_relative "../capabilities/refund"
10
+
11
+ module Lipwa
12
+ module Gateways
13
+ # Safaricom Daraja gateway. base_url and auth_strategy are derived
14
+ # automatically from env/consumer_key/consumer_secret rather than set
15
+ # directly, since Daraja's OAuth + sandbox/production hosts are fixed
16
+ # per environment.
17
+ class Mpesa < Lipwa::Gateway
18
+ include Lipwa::Capabilities::C2B
19
+ include Lipwa::Capabilities::StkPush
20
+ include Lipwa::Capabilities::Disbursement
21
+ include Lipwa::Capabilities::StatusQuery
22
+ include Lipwa::Capabilities::Refund
23
+
24
+ setting :consumer_key
25
+ setting :consumer_secret
26
+ setting :shortcode
27
+ setting :passkey
28
+ setting :initiator_name
29
+ setting :initiator_password
30
+ setting :security_credential_cert
31
+
32
+ private
33
+
34
+ def build_http_adapter
35
+ config = self.class.config
36
+ ensure_mpesa_config_present!(config)
37
+
38
+ HttpAdapter.new(**http_adapter_options(config))
39
+ end
40
+
41
+ def http_adapter_options(config)
42
+ {
43
+ base_url: Auth::BASE_URLS.fetch(config.env),
44
+ auth_strategy: AuthStrategies::BearerToken.new(build_auth(config)),
45
+ timeout: config.timeout || Lipwa.config.default_timeout,
46
+ open_timeout: config.open_timeout,
47
+ logger: config.logger || Lipwa.config.logger,
48
+ adapter: Lipwa.config.adapter
49
+ }
50
+ end
51
+
52
+ def build_auth(config)
53
+ Auth.new(consumer_key: config.consumer_key, consumer_secret: config.consumer_secret, env: config.env)
54
+ end
55
+
56
+ def ensure_mpesa_config_present!(config)
57
+ return if config.consumer_key && config.consumer_secret && config.shortcode && config.passkey
58
+
59
+ raise Lipwa::ConfigurationError,
60
+ "#{self.class} is missing consumer_key/consumer_secret/shortcode/passkey — set them via .configure"
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ require_relative "mpesa/auth"
67
+ require_relative "mpesa/security_credential"
68
+
69
+ Lipwa::Gateways.register(:mpesa, Lipwa::Gateways::Mpesa)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/container"
4
+ require_relative "errors"
5
+ require_relative "gateway"
6
+
7
+ module Lipwa
8
+ # Dry::Container-based registry of provider gateways. Providers register
9
+ # their Gateway subclass once (typically at load time); consumers look
10
+ # them up by name instead of hardcoding gateway class names — see
11
+ # Lipwa.gateway.
12
+ module Gateways
13
+ extend Dry::Container::Mixin
14
+
15
+ class << self
16
+ def register(key, gateway_class)
17
+ unless gateway_class.is_a?(Class) && gateway_class <= Lipwa::Gateway
18
+ raise Lipwa::ConfigurationError, "#{gateway_class} must be a subclass of Lipwa::Gateway"
19
+ end
20
+
21
+ super(key.to_sym, memoize: true) { gateway_class.new }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require_relative "errors"
6
+ require_relative "auth_strategies"
7
+
8
+ module Lipwa
9
+ # Thin wrapper around Faraday shared by every gateway's HTTP calls:
10
+ # retries with backoff, request/open timeouts, optional logging, and
11
+ # a pluggable auth strategy (see Lipwa::AuthStrategies) so OAuth2
12
+ # bearer tokens (M-Pesa, Co-op) and request-signing (Jenga) plug into
13
+ # the same request path without HttpAdapter knowing which is which.
14
+ class HttpAdapter
15
+ DEFAULT_RETRY_OPTIONS = {
16
+ max: 2,
17
+ interval: 0.5,
18
+ interval_randomness: 0.5,
19
+ backoff_factor: 2,
20
+ retry_statuses: [429, 500, 502, 503, 504],
21
+ methods: %i[get post put patch delete],
22
+ exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS
23
+ }.freeze
24
+
25
+ attr_reader :base_url, :auth_strategy, :timeout, :open_timeout, :logger, :adapter
26
+
27
+ # rubocop:disable Metrics/ParameterLists
28
+ def initialize(base_url:, auth_strategy: AuthStrategies::None.new, timeout: 10,
29
+ open_timeout: 5, logger: nil, retry_options: {}, stubs: nil,
30
+ adapter: Faraday.default_adapter)
31
+ @base_url = base_url
32
+ @auth_strategy = auth_strategy
33
+ @timeout = timeout
34
+ @open_timeout = open_timeout
35
+ @logger = logger
36
+ @retry_options = DEFAULT_RETRY_OPTIONS.merge(retry_options)
37
+ @stubs = stubs
38
+ @adapter = adapter
39
+ end
40
+ # rubocop:enable Metrics/ParameterLists
41
+
42
+ def get(path, params: {}, headers: {})
43
+ request(:get, path, params: params, headers: headers)
44
+ end
45
+
46
+ def post(path, body: nil, params: {}, headers: {})
47
+ request(:post, path, body: body, params: params, headers: headers)
48
+ end
49
+
50
+ def put(path, body: nil, params: {}, headers: {})
51
+ request(:put, path, body: body, params: params, headers: headers)
52
+ end
53
+
54
+ private
55
+
56
+ def request(method, path, params: {}, body: nil, headers: {})
57
+ connection.public_send(method) { |req| build_request(req, path, params, body, headers) }
58
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
59
+ raise Lipwa::GatewayError.new("network error: #{e.message}", raw: e)
60
+ rescue Faraday::Error => e
61
+ raise Lipwa::GatewayError.new(e.message, code: e.response&.dig(:status)&.to_s, raw: e.response)
62
+ end
63
+
64
+ def build_request(req, path, params, body, headers)
65
+ req.url(path)
66
+ req.params.update(params) if params && !params.empty?
67
+ req.headers.update(headers)
68
+ req.body = body if body
69
+ end
70
+
71
+ def connection
72
+ @connection ||= Faraday.new(url: base_url) do |conn|
73
+ configure_middleware(conn)
74
+ configure_timeouts(conn)
75
+ @stubs ? conn.adapter(:test, @stubs) : conn.adapter(@adapter)
76
+ end
77
+ end
78
+
79
+ def configure_middleware(conn)
80
+ conn.request :retry, @retry_options
81
+ conn.request :json
82
+ conn.response :json, content_type: /\bjson$/
83
+ configure_logger(conn) if logger
84
+ conn.use AuthMiddleware, auth_strategy
85
+ end
86
+
87
+ def configure_logger(conn)
88
+ conn.response :logger, logger, headers: true, bodies: true do |l|
89
+ l.filter(/(Authorization: )(.+)/, '\1[REDACTED]')
90
+ end
91
+ end
92
+
93
+ def configure_timeouts(conn)
94
+ conn.options.timeout = timeout
95
+ conn.options.open_timeout = open_timeout
96
+ end
97
+
98
+ # Applies the configured AuthStrategy on every outgoing request.
99
+ class AuthMiddleware < Faraday::Middleware
100
+ def initialize(app, auth_strategy)
101
+ super(app)
102
+ @auth_strategy = auth_strategy
103
+ end
104
+
105
+ def on_request(env)
106
+ @auth_strategy.apply(env)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/struct"
4
+ require_relative "types"
5
+
6
+ module Lipwa
7
+ # Immutable representation of an amount in minor currency units.
8
+ # KES has no subunit in practice, but we keep minor units as the
9
+ # storage form so other currencies fit the same shape later.
10
+ class Money < Dry::Struct
11
+ attribute :amount, Types::Amount
12
+ attribute :currency, Types::Currency.default("KES")
13
+
14
+ def +(other)
15
+ assert_same_currency!(other)
16
+ self.class.new(amount: amount + other.amount, currency: currency)
17
+ end
18
+
19
+ def -(other)
20
+ assert_same_currency!(other)
21
+ self.class.new(amount: amount - other.amount, currency: currency)
22
+ end
23
+
24
+ def to_s
25
+ "#{amount} #{currency}"
26
+ end
27
+
28
+ private
29
+
30
+ def assert_same_currency!(other)
31
+ return if currency == other.currency
32
+
33
+ raise ArgumentError, "currency mismatch: #{currency} vs #{other.currency}"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/struct"
4
+ require_relative "types"
5
+
6
+ module Lipwa
7
+ # Normalized result of a successful gateway call. Provider-specific
8
+ # payloads stay available via `raw` for anything the common shape
9
+ # doesn't capture.
10
+ class Response < Dry::Struct
11
+ attribute :success, Types::Strict::Bool
12
+ attribute :provider_reference, Types::Strict::String.optional
13
+ attribute :message, Types::Strict::String.optional.default(nil)
14
+ attribute :code, Types::Strict::String.optional.default(nil)
15
+ attribute :raw, Types::Any.default(nil)
16
+
17
+ def success?
18
+ success
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/types"
4
+
5
+ module Lipwa
6
+ # Shared Dry::Types module for value objects across the gem.
7
+ module Types
8
+ include Dry.Types()
9
+
10
+ # Amounts are always integers in minor currency units (e.g. cents,
11
+ # or whole KES since M-Pesa has no subunit) to avoid float math.
12
+ Amount = Types::Strict::Integer.constrained(gteq: 0)
13
+
14
+ Currency = Types::Strict::String.constrained(format: /\A[A-Z]{3}\z/)
15
+
16
+ Environment = Types::Strict::Symbol.enum(:sandbox, :production)
17
+ end
18
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lipwa
4
+ VERSION = "0.1.1"
5
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/container"
4
+ require "dry/monads"
5
+ require "json"
6
+ require_relative "errors"
7
+
8
+ module Lipwa
9
+ # Normalized inbound-webhook event produced by Lipwa::Webhook.parse_webhook.
10
+ # `verify_signature` is bound to a provider-specific verifier at parse
11
+ # time, so callers get one uniform API regardless of how a given
12
+ # provider actually authenticates its callbacks (M-Pesa has no
13
+ # cryptographic signature, so it checks the request's source IP
14
+ # against Safaricom's published ranges; a provider that does sign
15
+ # payloads can verify an HMAC/header instead — see Lipwa::Webhooks::Mpesa).
16
+ class WebhookEvent
17
+ attr_reader :provider, :event_type, :provider_reference, :message, :raw
18
+
19
+ # rubocop:disable Metrics/ParameterLists
20
+ def initialize(provider:, event_type:, raw:, success: nil, provider_reference: nil, message: nil, verifier: nil)
21
+ @provider = provider
22
+ @event_type = event_type
23
+ @raw = raw
24
+ @success = success
25
+ @provider_reference = provider_reference
26
+ @message = message
27
+ @verifier = verifier
28
+ end
29
+ # rubocop:enable Metrics/ParameterLists
30
+
31
+ def success?
32
+ !!@success
33
+ end
34
+
35
+ # kwargs are provider-specific (e.g. `source_ip:` for M-Pesa,
36
+ # `secret:`/`signature_header:` for an HMAC-based provider) — a
37
+ # given verifier only looks at the ones it understands.
38
+ def verify_signature(**opts)
39
+ raise Lipwa::ConfigurationError, "#{provider} has no signature verifier registered" unless @verifier
40
+
41
+ @verifier.call(raw: raw, **opts)
42
+ end
43
+ end
44
+
45
+ # Inbound-callback parsing/verification, pluggable per provider.
46
+ # Providers register a parser once (typically at load time — see
47
+ # Lipwa::Webhooks::Mpesa); consumers call Lipwa::Webhook.parse_webhook
48
+ # instead of hand-rolling per-provider payload parsing in their
49
+ # controllers.
50
+ module Webhook
51
+ extend Dry::Container::Mixin
52
+ extend Dry::Monads[:result]
53
+
54
+ class << self
55
+ def register(provider, parser)
56
+ super(provider.to_sym, parser)
57
+ end
58
+
59
+ def parse_webhook(provider:, body:, headers: {})
60
+ Success(resolve(provider.to_sym).call(body: body, headers: headers))
61
+ rescue Dry::Container::KeyError
62
+ raise Lipwa::UnsupportedProviderError, "no webhook parser registered for #{provider.inspect}"
63
+ rescue JSON::ParserError => e
64
+ Failure(Lipwa::WebhookParseError.new(e.message))
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../webhook"
4
+
5
+ module Lipwa
6
+ module Webhooks
7
+ # Parses Daraja's distinct callback shapes into a normalized
8
+ # Lipwa::WebhookEvent:
9
+ #
10
+ # * STK Push result callback — {"Body"=>{"stkCallback"=>{...}}}
11
+ # * Transaction Status / Disbursement result callback —
12
+ # {"Result"=>{...}}, delivered to the ResultURL given to
13
+ # Lipwa::Capabilities::StatusQuery#status (and B2C/B2B) once
14
+ # Daraja finishes processing the query.
15
+ # * C2B validation/confirmation — a flat hash (TransID, MSISDN, ...),
16
+ # wire-identical for both requests. Daraja's only signal for which
17
+ # one fired is which registered URL it hit, not the payload, so
18
+ # both are normalized as :c2b — callers that registered distinct
19
+ # validation/confirmation URLs already know which is which.
20
+ #
21
+ # Daraja does not sign callbacks with any HMAC/shared secret, so
22
+ # #verify_signature checks the request's source IP against
23
+ # Safaricom's published callback IP ranges instead of a
24
+ # cryptographic signature.
25
+ module Mpesa
26
+ # Safaricom's published source IPs for STK/C2B callbacks
27
+ # (https://developer.safaricom.co.ke/docs, "Callback IP Addresses").
28
+ TRUSTED_IPS = %w[
29
+ 196.201.214.200 196.201.214.206 196.201.213.114 196.201.214.207
30
+ 196.201.214.208 196.201.213.44 196.201.212.127 196.201.212.138
31
+ 196.201.212.129 196.201.212.136 196.201.212.74 196.201.212.69
32
+ ].freeze
33
+
34
+ module_function
35
+
36
+ def call(body:, headers: {}) # rubocop:disable Lint/UnusedMethodArgument
37
+ payload = parse(body)
38
+ stk = payload["Body"]&.fetch("stkCallback", nil)
39
+ result = payload["Result"]
40
+
41
+ return stk_event(stk) if stk
42
+ return transaction_status_event(result) if result
43
+
44
+ c2b_event(payload)
45
+ end
46
+
47
+ def parse(body)
48
+ body.is_a?(String) ? JSON.parse(body) : body
49
+ end
50
+
51
+ def stk_event(stk)
52
+ Lipwa::WebhookEvent.new(
53
+ provider: :mpesa,
54
+ event_type: :stk_callback,
55
+ success: stk["ResultCode"].zero?,
56
+ provider_reference: stk["CheckoutRequestID"],
57
+ message: stk["ResultDesc"],
58
+ raw: stk,
59
+ verifier: method(:verify_signature)
60
+ )
61
+ end
62
+
63
+ def transaction_status_event(result)
64
+ Lipwa::WebhookEvent.new(
65
+ provider: :mpesa,
66
+ event_type: :transaction_status,
67
+ success: result["ResultCode"].zero?,
68
+ provider_reference: result["TransactionID"] || result["ConversationID"],
69
+ message: result["ResultDesc"],
70
+ raw: result,
71
+ verifier: method(:verify_signature)
72
+ )
73
+ end
74
+
75
+ # C2B callbacks only fire for an already-completed paybill/till
76
+ # payment — there is no ResultCode to key off, so `success` is
77
+ # unconditionally true. Accepting or rejecting the underlying
78
+ # transaction is the app's own response to the validation request,
79
+ # not something reflected in this event.
80
+ def c2b_event(payload)
81
+ Lipwa::WebhookEvent.new(
82
+ provider: :mpesa,
83
+ event_type: :c2b,
84
+ success: true,
85
+ provider_reference: payload["TransID"],
86
+ message: nil,
87
+ raw: payload,
88
+ verifier: method(:verify_signature)
89
+ )
90
+ end
91
+
92
+ def verify_signature(raw:, source_ip: nil, **) # rubocop:disable Lint/UnusedMethodArgument
93
+ return false unless source_ip
94
+
95
+ TRUSTED_IPS.include?(source_ip)
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ Lipwa::Webhook.register(:mpesa, Lipwa::Webhooks::Mpesa)