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
|
@@ -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
|
data/lib/lipwa/money.rb
ADDED
|
@@ -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
|
data/lib/lipwa/types.rb
ADDED
|
@@ -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,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)
|