lipwa 0.1.2 → 0.1.3

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +219 -204
  4. data/Rakefile +11 -0
  5. data/docs/README.md +15 -0
  6. data/docs/_config.yml +11 -0
  7. data/docs/_layouts/default.html +37 -0
  8. data/docs/assets/css/style.css +8 -0
  9. data/docs/capabilities.md +116 -0
  10. data/docs/gateways.md +158 -0
  11. data/docs/getting-started.md +64 -0
  12. data/docs/index.md +46 -0
  13. data/docs/reliability.md +50 -0
  14. data/docs/webhooks.md +72 -0
  15. data/lib/lipwa/auth_strategies/api_key.rb +28 -0
  16. data/lib/lipwa/auth_strategies/base.rb +4 -0
  17. data/lib/lipwa/auth_strategies/bearer_token.rb +5 -0
  18. data/lib/lipwa/auth_strategies/jenga.rb +58 -0
  19. data/lib/lipwa/auth_strategies.rb +2 -0
  20. data/lib/lipwa/capabilities/authorize.rb +26 -0
  21. data/lib/lipwa/capabilities/bank_transfer.rb +66 -0
  22. data/lib/lipwa/capabilities/c2b.rb +11 -10
  23. data/lib/lipwa/capabilities/capture.rb +25 -0
  24. data/lib/lipwa/capabilities/disbursement.rb +16 -17
  25. data/lib/lipwa/capabilities/lightning_invoice.rb +75 -0
  26. data/lib/lipwa/capabilities/lightning_payment.rb +71 -0
  27. data/lib/lipwa/capabilities/purchase.rb +26 -0
  28. data/lib/lipwa/capabilities/refund.rb +14 -14
  29. data/lib/lipwa/capabilities/status_query.rb +21 -12
  30. data/lib/lipwa/capabilities/stk_push.rb +8 -5
  31. data/lib/lipwa/capabilities/void.rb +25 -0
  32. data/lib/lipwa/configuration_snapshot.rb +50 -0
  33. data/lib/lipwa/context.rb +60 -0
  34. data/lib/lipwa/contracts/bank_transfer_contract.rb +61 -0
  35. data/lib/lipwa/contracts/card_payment_contract.rb +59 -0
  36. data/lib/lipwa/contracts/card_transaction_contract.rb +21 -0
  37. data/lib/lipwa/contracts/lightning_invoice_check_contract.rb +18 -0
  38. data/lib/lipwa/contracts/lightning_invoice_contract.rb +27 -0
  39. data/lib/lipwa/contracts/lightning_payment_check_contract.rb +18 -0
  40. data/lib/lipwa/contracts/lightning_payment_contract.rb +14 -0
  41. data/lib/lipwa/contracts/status_query_contract.rb +29 -6
  42. data/lib/lipwa/errors.rb +4 -2
  43. data/lib/lipwa/gateway.rb +24 -5
  44. data/lib/lipwa/gateways/coop_bank/auth.rb +68 -0
  45. data/lib/lipwa/gateways/coop_bank.rb +115 -0
  46. data/lib/lipwa/gateways/flutterwave.rb +160 -0
  47. data/lib/lipwa/gateways/jenga/auth.rb +76 -0
  48. data/lib/lipwa/gateways/jenga.rb +282 -0
  49. data/lib/lipwa/gateways/lnbits.rb +67 -0
  50. data/lib/lipwa/gateways/mpesa/auth.rb +18 -2
  51. data/lib/lipwa/gateways/mpesa.rb +16 -11
  52. data/lib/lipwa/gateways/paystack.rb +119 -0
  53. data/lib/lipwa/gateways/pesapal/auth.rb +72 -0
  54. data/lib/lipwa/gateways/pesapal.rb +123 -0
  55. data/lib/lipwa/gateways.rb +7 -1
  56. data/lib/lipwa/http_adapter.rb +56 -17
  57. data/lib/lipwa/logging.rb +100 -0
  58. data/lib/lipwa/money.rb +33 -4
  59. data/lib/lipwa/types.rb +3 -3
  60. data/lib/lipwa/version.rb +1 -1
  61. data/lib/lipwa/webhooks/coop_bank.rb +28 -0
  62. data/lib/lipwa/webhooks/jenga.rb +55 -0
  63. data/lib/lipwa/webhooks/lnbits.rb +46 -0
  64. data/lib/lipwa.rb +29 -0
  65. metadata +42 -1
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../gateway"
4
+ require_relative "../gateways"
5
+ require_relative "../capabilities/purchase"
6
+ require_relative "../capabilities/authorize"
7
+ require_relative "../capabilities/capture"
8
+ require_relative "../capabilities/void"
9
+
10
+ module Lipwa
11
+ module Gateways
12
+ # Paystack hosted card payments. Paystack captures card payments in its
13
+ # checkout flow, so capture verifies the transaction and void refunds it.
14
+ class Paystack < Lipwa::Gateway
15
+ include Lipwa::Capabilities::Purchase
16
+ include Lipwa::Capabilities::Authorize
17
+ include Lipwa::Capabilities::Capture
18
+ include Lipwa::Capabilities::Void
19
+
20
+ BASE_URL = "https://api.paystack.co"
21
+ INITIALIZE_PATH = "/transaction/initialize"
22
+ VERIFY_PATH = "/transaction/verify"
23
+ REFUND_PATH = "/refund"
24
+ SUBUNIT_FACTOR = 100
25
+
26
+ setting :secret_key
27
+
28
+ private
29
+
30
+ def perform_purchase(params, idempotency_key) = initialize_transaction(params, idempotency_key)
31
+ def perform_authorize(params, idempotency_key) = initialize_transaction(params, idempotency_key)
32
+
33
+ def perform_capture(params, idempotency_key)
34
+ response = http.get("#{VERIFY_PATH}/#{params[:authorization]}", idempotency_key: idempotency_key)
35
+ build_response(response.body, fallback_reference: params[:authorization], require_completed: true)
36
+ rescue Lipwa::GatewayError => e
37
+ Failure(e)
38
+ end
39
+
40
+ def perform_void(params, idempotency_key)
41
+ response = http.post(REFUND_PATH, body: { transaction: params[:authorization] },
42
+ idempotency_key: idempotency_key)
43
+ build_response(response.body, fallback_reference: params[:authorization])
44
+ rescue Lipwa::GatewayError => e
45
+ Failure(e)
46
+ end
47
+
48
+ def initialize_transaction(params, idempotency_key)
49
+ email = address_value(params[:billing_address], :email_address)
50
+ return Failure(Lipwa::GatewayError.new("billing_address email_address is required by Paystack")) unless email
51
+
52
+ response = http.post(INITIALIZE_PATH, body: initialize_body(params, email),
53
+ idempotency_key: idempotency_key)
54
+ build_response(response.body, fallback_reference: params[:reference])
55
+ rescue Lipwa::GatewayError => e
56
+ Failure(e)
57
+ end
58
+
59
+ def initialize_body(params, email)
60
+ {
61
+ email: email,
62
+ amount: paystack_amount(params[:amount]),
63
+ currency: params[:amount].currency,
64
+ reference: params[:reference],
65
+ callback_url: params[:callback_url],
66
+ channels: ["card"],
67
+ metadata: { description: params[:description], notification_id: params[:notification_id] }
68
+ }
69
+ end
70
+
71
+ def paystack_amount(money)
72
+ subunits = money.amount * SUBUNIT_FACTOR
73
+ raise Lipwa::GatewayError, "amount must resolve to a whole Paystack subunit" unless subunits.frac.zero?
74
+
75
+ subunits.to_i
76
+ end
77
+
78
+ def address_value(address, key) = address[key] || address[key.to_s]
79
+
80
+ def build_response(body, fallback_reference:, require_completed: false)
81
+ data = body["data"].is_a?(Hash) ? body["data"] : {}
82
+ Success(Lipwa::Response.new(
83
+ success: response_successful?(body, data, require_completed),
84
+ provider_reference: (data["reference"] || fallback_reference)&.to_s,
85
+ message: body["message"] || data["status"],
86
+ code: (data["status"] || body["status"])&.to_s,
87
+ raw: body
88
+ ))
89
+ end
90
+
91
+ def response_successful?(body, data, require_completed)
92
+ return false unless body["status"] == true
93
+ return true unless require_completed
94
+
95
+ data["status"].to_s.casecmp?("success")
96
+ end
97
+
98
+ # rubocop:disable Metrics/AbcSize
99
+ def build_http_adapter
100
+ config = self.config
101
+ ensure_paystack_config_present!(config)
102
+ auth = config.auth_strategy || AuthStrategies::BearerToken.new(-> { config.secret_key })
103
+ HttpAdapter.new(base_url: config.base_url || BASE_URL, auth_strategy: auth,
104
+ timeout: config.timeout || global_config.default_timeout,
105
+ open_timeout: config.open_timeout, logger: config.logger || global_config.logger,
106
+ adapter: global_config.adapter)
107
+ end
108
+ # rubocop:enable Metrics/AbcSize
109
+
110
+ def ensure_paystack_config_present!(config)
111
+ return if config.auth_strategy || config.secret_key
112
+
113
+ raise Lipwa::ConfigurationError, "#{self.class} is missing secret_key — set it via .configure"
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ Lipwa::Gateways.register(:paystack, Lipwa::Gateways::Paystack)
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require_relative "../../errors"
6
+
7
+ module Lipwa
8
+ module Gateways
9
+ class Pesapal
10
+ # Fetches and briefly caches Pesapal API bearer tokens.
11
+ class Auth
12
+ TOKEN_PATH = "/api/Auth/RequestToken"
13
+
14
+ def initialize(consumer_key:, consumer_secret:, base_url:, clock: -> { Time.now })
15
+ @consumer_key = consumer_key
16
+ @consumer_secret = consumer_secret
17
+ @base_url = base_url
18
+ @clock = clock
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def initialize_copy(source)
23
+ super
24
+ @token = nil
25
+ @expires_at = nil
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ def inspect
30
+ "#<#{self.class}:0x#{object_id.to_s(16)}>"
31
+ end
32
+
33
+ def call
34
+ return @token if @token && @expires_at && @clock.call < @expires_at
35
+
36
+ @mutex.synchronize do
37
+ fetch_token unless @token && @expires_at && @clock.call < @expires_at
38
+ end
39
+
40
+ @token
41
+ end
42
+
43
+ private
44
+
45
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
46
+ def fetch_token
47
+ response = Faraday.post("#{@base_url}#{TOKEN_PATH}") do |request|
48
+ request.headers["Accept"] = "application/json"
49
+ request.headers["Content-Type"] = "application/json"
50
+ request.body = JSON.generate(consumer_key: @consumer_key, consumer_secret: @consumer_secret)
51
+ end
52
+ body = JSON.parse(response.body)
53
+ token = body["token"]
54
+ raise_auth_error(response, body) unless response.success? && token
55
+
56
+ @token = token
57
+ @expires_at = @clock.call + 270
58
+ @token
59
+ rescue Faraday::Error, JSON::ParserError => e
60
+ raise Lipwa::GatewayError.new("Pesapal authentication failed: #{e.message}", raw: e)
61
+ end
62
+
63
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
64
+ def raise_auth_error(response, body)
65
+ error = body["error"].is_a?(Hash) ? body["error"] : {}
66
+ message = error["message"] || body["message"] || "Pesapal authentication failed"
67
+ raise Lipwa::GatewayError.new(message, code: (error["code"] || response.status).to_s, raw: body)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../gateway"
4
+ require_relative "../gateways"
5
+ require_relative "../capabilities/purchase"
6
+ require_relative "../capabilities/authorize"
7
+ require_relative "../capabilities/capture"
8
+ require_relative "../capabilities/void"
9
+
10
+ module Lipwa
11
+ module Gateways
12
+ # Pesapal API 3.0 hosted-checkout gateway.
13
+ class Pesapal < Lipwa::Gateway
14
+ include Lipwa::Capabilities::Purchase
15
+ include Lipwa::Capabilities::Authorize
16
+ include Lipwa::Capabilities::Capture
17
+ include Lipwa::Capabilities::Void
18
+
19
+ BASE_URLS = {
20
+ sandbox: "https://cybqa.pesapal.com/pesapalv3",
21
+ production: "https://pay.pesapal.com/v3"
22
+ }.freeze
23
+ SUBMIT_ORDER_PATH = "/api/Transactions/SubmitOrderRequest"
24
+ STATUS_PATH = "/api/Transactions/GetTransactionStatus"
25
+ CANCEL_PATH = "/api/Transactions/CancelOrder"
26
+
27
+ setting :consumer_key
28
+ setting :consumer_secret
29
+ setting :clock
30
+
31
+ private
32
+
33
+ def perform_purchase(params, idempotency_key) = submit_order(params, idempotency_key)
34
+ def perform_authorize(params, idempotency_key) = submit_order(params, idempotency_key)
35
+
36
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
37
+ def perform_capture(params, idempotency_key)
38
+ response = http.get(STATUS_PATH, params: { orderTrackingId: params[:authorization] },
39
+ idempotency_key: idempotency_key)
40
+ body = response.body
41
+ completed = body["payment_status_description"].to_s.casecmp?("COMPLETED") ||
42
+ body["status_code"].to_i == 1
43
+ Success(Lipwa::Response.new(
44
+ success: completed,
45
+ provider_reference: body["confirmation_code"] || params[:authorization],
46
+ message: body["payment_status_description"] || pesapal_message(body),
47
+ code: (body["status_code"] || body["status"])&.to_s,
48
+ raw: body
49
+ ))
50
+ rescue Lipwa::GatewayError => e
51
+ Failure(e)
52
+ end
53
+
54
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
55
+ def perform_void(params, idempotency_key)
56
+ response = http.post(CANCEL_PATH, body: { order_tracking_id: params[:authorization] },
57
+ idempotency_key: idempotency_key)
58
+ build_response(response.body, fallback_reference: params[:authorization])
59
+ rescue Lipwa::GatewayError => e
60
+ Failure(e)
61
+ end
62
+
63
+ def submit_order(params, idempotency_key)
64
+ response = http.post(SUBMIT_ORDER_PATH, body: order_body(params), idempotency_key: idempotency_key)
65
+ build_response(response.body)
66
+ rescue Lipwa::GatewayError => e
67
+ Failure(e)
68
+ end
69
+
70
+ def order_body(params)
71
+ {
72
+ id: params[:reference], currency: params[:amount].currency, amount: params[:amount].amount,
73
+ description: params[:description], callback_url: params[:callback_url],
74
+ notification_id: params[:notification_id], billing_address: params[:billing_address],
75
+ cancellation_url: params[:cancellation_url], redirect_mode: params[:redirect_mode],
76
+ branch: params[:branch]
77
+ }.compact
78
+ end
79
+
80
+ def build_response(body, fallback_reference: nil)
81
+ status = body["status"].to_s
82
+ error = pesapal_error(body)
83
+ Success(Lipwa::Response.new(
84
+ success: error.empty? && status.start_with?("2"),
85
+ provider_reference: body["order_tracking_id"] || fallback_reference,
86
+ message: body["message"] || error["message"],
87
+ code: status.empty? ? error["code"]&.to_s : status,
88
+ raw: body
89
+ ))
90
+ end
91
+
92
+ def pesapal_error(body) = body["error"].is_a?(Hash) ? body["error"] : {}
93
+ def pesapal_message(body) = pesapal_error(body)["message"]
94
+
95
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
96
+ def build_http_adapter
97
+ config = self.config
98
+ ensure_pesapal_config_present!(config)
99
+ base_url = config.base_url || BASE_URLS.fetch(config.env)
100
+ auth = config.auth_strategy || AuthStrategies::BearerToken.new(
101
+ Auth.new(consumer_key: config.consumer_key, consumer_secret: config.consumer_secret,
102
+ base_url: base_url, clock: config.clock || -> { Time.now })
103
+ )
104
+ HttpAdapter.new(base_url: base_url, auth_strategy: auth,
105
+ timeout: config.timeout || global_config.default_timeout,
106
+ open_timeout: config.open_timeout, logger: config.logger || global_config.logger,
107
+ adapter: global_config.adapter)
108
+ end
109
+
110
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
111
+ def ensure_pesapal_config_present!(config)
112
+ return if config.auth_strategy || (config.consumer_key && config.consumer_secret)
113
+
114
+ raise Lipwa::ConfigurationError,
115
+ "#{self.class} is missing consumer_key/consumer_secret — set them via .configure"
116
+ end
117
+ end
118
+ end
119
+ end
120
+
121
+ require_relative "pesapal/auth"
122
+
123
+ Lipwa::Gateways.register(:pesapal, Lipwa::Gateways::Pesapal)
@@ -18,7 +18,13 @@ module Lipwa
18
18
  raise Lipwa::ConfigurationError, "#{gateway_class} must be a subclass of Lipwa::Gateway"
19
19
  end
20
20
 
21
- super(key.to_sym, memoize: true) { gateway_class.new }
21
+ super(key.to_sym, memoize: true) { gateway_class.new }.tap do
22
+ (@gateway_classes ||= {})[key.to_s] = gateway_class
23
+ end
24
+ end
25
+
26
+ def gateway_classes
27
+ (@gateway_classes || {}).dup
22
28
  end
23
29
  end
24
30
  end
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bigdecimal"
3
4
  require "faraday"
4
5
  require "faraday/retry"
6
+ require "json"
5
7
  require_relative "errors"
6
8
  require_relative "auth_strategies"
9
+ require_relative "logging"
7
10
 
8
11
  module Lipwa
9
12
  # Thin wrapper around Faraday shared by every gateway's HTTP calls:
@@ -12,17 +15,28 @@ module Lipwa
12
15
  # bearer tokens (M-Pesa, Co-op) and request-signing (Jenga) plug into
13
16
  # the same request path without HttpAdapter knowing which is which.
14
17
  class HttpAdapter
18
+ IDEMPOTENCY_HEADER = "Idempotency-Key"
19
+ RETRYABLE_METHODS = %i[get head options].freeze
20
+ RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
21
+ RETRYABLE_EXCEPTIONS = Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS.freeze
22
+ RETRY_IF_IDEMPOTENT = lambda do |env, _exception|
23
+ value = env.request_headers[IDEMPOTENCY_HEADER]
24
+ !value.nil? && !value.empty?
25
+ end
26
+
15
27
  DEFAULT_RETRY_OPTIONS = {
16
28
  max: 2,
17
29
  interval: 0.5,
18
30
  interval_randomness: 0.5,
19
31
  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
32
+ max_interval: 5,
33
+ retry_statuses: RETRYABLE_STATUSES,
34
+ methods: RETRYABLE_METHODS,
35
+ exceptions: RETRYABLE_EXCEPTIONS,
36
+ retry_if: RETRY_IF_IDEMPOTENT
23
37
  }.freeze
24
38
 
25
- attr_reader :base_url, :auth_strategy, :timeout, :open_timeout, :logger, :adapter
39
+ attr_reader :base_url, :auth_strategy, :timeout, :open_timeout, :logger, :adapter, :retry_options
26
40
 
27
41
  # rubocop:disable Metrics/ParameterLists
28
42
  def initialize(base_url:, auth_strategy: AuthStrategies::None.new, timeout: 10,
@@ -33,22 +47,28 @@ module Lipwa
33
47
  @timeout = timeout
34
48
  @open_timeout = open_timeout
35
49
  @logger = logger
36
- @retry_options = DEFAULT_RETRY_OPTIONS.merge(retry_options)
50
+ @retry_options = DEFAULT_RETRY_OPTIONS.merge(retry_options).freeze
37
51
  @stubs = stubs
38
52
  @adapter = adapter
39
53
  end
40
54
  # rubocop:enable Metrics/ParameterLists
41
55
 
42
- def get(path, params: {}, headers: {})
43
- request(:get, path, params: params, headers: headers)
56
+ def get(path, params: {}, headers: {}, idempotency_key: nil)
57
+ request(:get, path, params: params, headers: idempotency_headers(headers, idempotency_key))
58
+ end
59
+
60
+ def post(path, body: nil, params: {}, headers: {}, idempotency_key: nil)
61
+ request(:post, path, body: body, params: params, headers: idempotency_headers(headers, idempotency_key))
44
62
  end
45
63
 
46
- def post(path, body: nil, params: {}, headers: {})
47
- request(:post, path, body: body, params: params, headers: headers)
64
+ def put(path, body: nil, params: {}, headers: {}, idempotency_key: nil)
65
+ request(:put, path, body: body, params: params, headers: idempotency_headers(headers, idempotency_key))
48
66
  end
49
67
 
50
- def put(path, body: nil, params: {}, headers: {})
51
- request(:put, path, body: body, params: params, headers: headers)
68
+ # Auth strategies may own API keys or OAuth clients. Avoid recursively
69
+ # exposing them through the adapter's default instance-variable dump.
70
+ def inspect
71
+ "#<#{self.class}:0x#{object_id.to_s(16)}>"
52
72
  end
53
73
 
54
74
  private
@@ -65,7 +85,28 @@ module Lipwa
65
85
  req.url(path)
66
86
  req.params.update(params) if params && !params.empty?
67
87
  req.headers.update(headers)
68
- req.body = body if body
88
+ req.body = json_safe_decimals(body) if body
89
+ end
90
+
91
+ def idempotency_headers(headers, idempotency_key)
92
+ return headers unless idempotency_key
93
+
94
+ headers.merge(IDEMPOTENCY_HEADER => idempotency_key)
95
+ end
96
+
97
+ # JSON otherwise encodes BigDecimal as a quoted scientific-notation string.
98
+ # A fragment preserves the exact base-10 value as a JSON number.
99
+ def json_safe_decimals(value)
100
+ case value
101
+ when BigDecimal
102
+ JSON::Fragment.new(value.to_s("F"))
103
+ when Hash
104
+ value.transform_values { |item| json_safe_decimals(item) }
105
+ when Array
106
+ value.map { |item| json_safe_decimals(item) }
107
+ else
108
+ value
109
+ end
69
110
  end
70
111
 
71
112
  def connection
@@ -77,17 +118,15 @@ module Lipwa
77
118
  end
78
119
 
79
120
  def configure_middleware(conn)
80
- conn.request :retry, @retry_options
121
+ conn.request :retry, retry_options
81
122
  conn.request :json
82
123
  conn.response :json, content_type: /\bjson$/
83
- configure_logger(conn) if logger
84
124
  conn.use AuthMiddleware, auth_strategy
125
+ configure_logger(conn) if logger
85
126
  end
86
127
 
87
128
  def configure_logger(conn)
88
- conn.response :logger, logger, headers: true, bodies: true do |l|
89
- l.filter(/(Authorization: )(.+)/, '\1[REDACTED]')
90
- end
129
+ conn.use Logging::Middleware, logger
91
130
  end
92
131
 
93
132
  def configure_timeouts(conn)
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Lipwa
7
+ module Logging
8
+ REDACTED = "[REDACTED]"
9
+
10
+ # Produces sanitized copies of nested payloads without mutating caller data.
11
+ module Redactor
12
+ SECRET_NAMES = "authorization|signature|cookie|api[_-]?key|access[_-]?token|" \
13
+ "refresh[_-]?token|token|consumer[_-]?key|client[_-]?id|" \
14
+ "secret|password|passkey|" \
15
+ "security[_-]?credential|private[_-]?key|admin[_-]?key|invoice[_-]?key|encryption[_-]?key"
16
+ SENSITIVE_KEY = /(?:#{SECRET_NAMES})/i
17
+ STRING_PATTERNS = [
18
+ /((?:authorization|proxy-authorization|signature)\s*:\s*)[^\r\n,]+/i,
19
+ /((?:^|[?&])(?:#{SECRET_NAMES})=)[^&#\s]+/i,
20
+ /(["']?(?:#{SECRET_NAMES})["']?\s*[:=]\s*["'])[^"']*(["'])/i
21
+ ].freeze
22
+
23
+ module_function
24
+
25
+ def call(value, key: nil) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
26
+ return REDACTED if sensitive_key?(key)
27
+
28
+ case value
29
+ when Hash
30
+ value.each_with_object({}) do |(child_key, child_value), copy|
31
+ copy[child_key] = call(child_value, key: child_key)
32
+ end
33
+ when Array then value.map { |item| call(item) }
34
+ when String then redact_string(value)
35
+ when Exception then { class: value.class.name, message: redact_string(value.message) }
36
+ else value
37
+ end
38
+ end
39
+
40
+ def sensitive_key?(key) = key&.to_s&.match?(SENSITIVE_KEY)
41
+
42
+ def redact_string(value)
43
+ STRING_PATTERNS.reduce(value.dup) do |redacted, pattern|
44
+ redacted.gsub(pattern) { "#{Regexp.last_match(1)}#{REDACTED}#{Regexp.last_match(2)}" }
45
+ end
46
+ end
47
+ end
48
+
49
+ # Faraday middleware that emits one structured, sanitized event per call.
50
+ class Middleware < Faraday::Middleware
51
+ def initialize(app, logger)
52
+ super(app)
53
+ @logger = logger
54
+ end
55
+
56
+ def call(env)
57
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
58
+ @app.call(env).on_complete { |completed| log(completed, started_at) }
59
+ rescue StandardError => e
60
+ log(env, started_at, error: e)
61
+ raise
62
+ end
63
+
64
+ private
65
+
66
+ def log(env, started_at, error: nil) # rubocop:disable Metrics/AbcSize
67
+ event = {
68
+ event: "lipwa.http", method: env.method&.to_s&.upcase,
69
+ url: sanitized_url(env.url), status: env.status,
70
+ duration_ms: elapsed_ms(started_at),
71
+ request: { headers: Redactor.call(env.request_headers.to_h), body: redact_body(env.request_body) },
72
+ response: { headers: Redactor.call(env.response_headers.to_h), body: redact_body(env.response_body) }
73
+ }
74
+ event[:error] = Redactor.call(error) if error
75
+ @logger.public_send(error || env.status.to_i >= 400 ? :error : :info, event)
76
+ end
77
+
78
+ def sanitized_url(url)
79
+ return unless url
80
+
81
+ copy = url.dup
82
+ copy.user = REDACTED if copy.user
83
+ copy.password = REDACTED if copy.password
84
+ copy.query = Redactor.redact_string(copy.query) if copy.query
85
+ copy.to_s
86
+ end
87
+
88
+ def redact_body(body)
89
+ parsed = body.is_a?(String) ? JSON.parse(body) : body
90
+ Redactor.call(parsed)
91
+ rescue JSON::ParserError
92
+ Redactor.call(body)
93
+ end
94
+
95
+ def elapsed_ms(started_at)
96
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
97
+ end
98
+ end
99
+ end
100
+ end
data/lib/lipwa/money.rb CHANGED
@@ -4,10 +4,10 @@ require "dry/struct"
4
4
  require_relative "types"
5
5
 
6
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.
7
+ # Immutable, decimal representation of an amount in one currency.
10
8
  class Money < Dry::Struct
9
+ include Comparable
10
+
11
11
  attribute :amount, Types::Amount
12
12
  attribute :currency, Types::Currency.default("KES")
13
13
 
@@ -21,16 +21,45 @@ module Lipwa
21
21
  self.class.new(amount: amount - other.amount, currency: currency)
22
22
  end
23
23
 
24
+ def *(other)
25
+ self.class.new(amount: amount * decimal_scalar(other), currency: currency)
26
+ end
27
+
28
+ def /(other)
29
+ divisor = decimal_scalar(other)
30
+ raise ZeroDivisionError, "divided by 0" if divisor.zero?
31
+
32
+ self.class.new(amount: amount / divisor, currency: currency)
33
+ end
34
+
35
+ def <=>(other)
36
+ return nil unless other.is_a?(self.class)
37
+
38
+ assert_same_currency!(other)
39
+ amount <=> other.amount
40
+ end
41
+
42
+ def zero?
43
+ amount.zero?
44
+ end
45
+
24
46
  def to_s
25
- "#{amount} #{currency}"
47
+ "#{amount.to_s("F")} #{currency}"
26
48
  end
27
49
 
28
50
  private
29
51
 
30
52
  def assert_same_currency!(other)
53
+ raise TypeError, "expected #{self.class}, got #{other.class}" unless other.is_a?(self.class)
31
54
  return if currency == other.currency
32
55
 
33
56
  raise ArgumentError, "currency mismatch: #{currency} vs #{other.currency}"
34
57
  end
58
+
59
+ def decimal_scalar(value)
60
+ Types::Coercible::Decimal[value]
61
+ rescue Dry::Types::CoercionError, Dry::Types::ConstraintError
62
+ raise TypeError, "expected a numeric scalar, got #{value.inspect}"
63
+ end
35
64
  end
36
65
  end
data/lib/lipwa/types.rb CHANGED
@@ -7,9 +7,9 @@ module Lipwa
7
7
  module Types
8
8
  include Dry.Types()
9
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)
10
+ # BigDecimal-backed amounts avoid binary floating-point errors while
11
+ # accepting the numeric and string inputs commonly received from APIs.
12
+ Amount = Types::Coercible::Decimal.constrained(gteq: 0)
13
13
 
14
14
  Currency = Types::Strict::String.constrained(format: /\A[A-Z]{3}\z/)
15
15
 
data/lib/lipwa/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lipwa
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../webhook"
4
+
5
+ module Lipwa
6
+ module Webhooks
7
+ # Normalizes Co-op Bank transaction callbacks into WebhookEvent.
8
+ module CoopBank
9
+ module_function
10
+
11
+ def call(body:, headers: {}) # rubocop:disable Lint/UnusedMethodArgument
12
+ payload = body.is_a?(String) ? JSON.parse(body) : body
13
+ code = payload["MessageCode"]
14
+
15
+ Lipwa::WebhookEvent.new(
16
+ provider: :coop_bank,
17
+ event_type: :transaction_status,
18
+ success: code.to_s == "0",
19
+ provider_reference: payload["MessageReference"]&.to_s,
20
+ message: payload["MessageDescription"]&.to_s,
21
+ raw: payload
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ Lipwa::Webhook.register(:coop_bank, Lipwa::Webhooks::CoopBank)