bml_connect 0.2.0 → 0.3.0

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.
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "faraday"
6
+
7
+ module BMLConnect
8
+ # Shared transport base for the value-object resources (Customers, and the
9
+ # token/charge resources that follow in features 002/004).
10
+ #
11
+ # Responsibilities: build the request URL from the client's base URL, dispatch
12
+ # over the client's Faraday connection, apply bounded retry with backoff on
13
+ # transient failures, and map HTTP status codes to the library error
14
+ # hierarchy. Value-object construction, validation, and auditing live in the
15
+ # concrete resource, not here.
16
+ class Resource
17
+ def initialize(client)
18
+ @client = client
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :client
24
+
25
+ # Dispatch +method+ to +path+ (a host-root path like "/public-customers").
26
+ # Returns the Faraday response on 2xx; raises a mapped error otherwise.
27
+ #
28
+ # +retries+ defaults to true (bounded retry with backoff on transient
29
+ # failures). Pass +retries: false+ for money-moving calls that MUST make
30
+ # exactly one attempt — transaction create and capture (FR-016): retry there
31
+ # risks a double charge, and no server-side idempotency key is documented.
32
+ def request(method, path, body: nil, retries: true)
33
+ url = request_url(path)
34
+
35
+ dispatch = lambda do
36
+ client.http_client.public_send(method, url) do |req|
37
+ req.body = JSON.generate(body) if body
38
+ end
39
+ end
40
+
41
+ response = retries ? with_retries(&dispatch) : single_attempt(&dispatch)
42
+
43
+ handle(response)
44
+ end
45
+
46
+ # Exactly one attempt, no retry. A transport failure becomes an
47
+ # AvailabilityError immediately so the caller can reconcile rather than
48
+ # blindly re-send (FR-016, SC-007).
49
+ def single_attempt
50
+ yield
51
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
52
+ raise AvailabilityError, e.message
53
+ end
54
+
55
+ # Customer paths live at the host root ("/public-customers"), NOT under the
56
+ # "/public/" prefix the transaction endpoints use. URI.join with an absolute
57
+ # path replaces base_url's path while keeping its scheme+host, so the URL is
58
+ # still derived from the client (no hardcoded host) per constitution/R10.
59
+ def request_url(path)
60
+ URI.join(client.base_url, path).to_s
61
+ end
62
+
63
+ def handle(response)
64
+ return response if (200..299).cover?(response.status)
65
+
66
+ raise map_error(response)
67
+ end
68
+
69
+ def with_retries(&block)
70
+ attempts = 0
71
+
72
+ loop do
73
+ begin
74
+ response = block.call
75
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
76
+ raise AvailabilityError, e.message if attempts >= max_retries
77
+
78
+ attempts += 1
79
+ backoff(attempts)
80
+ next
81
+ end
82
+
83
+ if retryable_status?(response.status) && attempts < max_retries
84
+ attempts += 1
85
+ backoff(attempts)
86
+ next
87
+ end
88
+
89
+ return response
90
+ end
91
+ end
92
+
93
+ # Transient statuses worth retrying (FR-013a): request timeout, rate limit,
94
+ # and any 5xx. A 429 that survives the retries is still surfaced as a
95
+ # RateLimitError by map_error, carrying BML's Retry-After hint for the
96
+ # caller — the internal wait stays governed by the client's bounded backoff,
97
+ # not by an arbitrary server-supplied delay.
98
+ def retryable_status?(status)
99
+ status == 408 || status == 429 || (500..599).cover?(status)
100
+ end
101
+
102
+ def backoff(attempts)
103
+ seconds = retry_backoff * attempts
104
+ sleep(seconds) if seconds.positive?
105
+ end
106
+
107
+ def max_retries
108
+ client.respond_to?(:max_retries) && client.max_retries ? client.max_retries : 2
109
+ end
110
+
111
+ def retry_backoff
112
+ value = client.respond_to?(:retry_backoff) ? client.retry_backoff : nil
113
+ value.to_f
114
+ end
115
+
116
+ def map_error(response)
117
+ status = response.status
118
+ body = response.body
119
+ # Masked at the source: BML's own error text is data we did not author, and
120
+ # a card-like value in it must never reach an error message, a log, or an
121
+ # audit record (Constitution I; feature 005 FR-011).
122
+ message = Masking.scrub(extract_message(body) || "BML request failed with status #{status}")
123
+
124
+ case status
125
+ when 400, 422 then ValidationError.new(message)
126
+ when 401, 403 then AuthenticationError.new(message)
127
+ when 404 then NotFoundError.new(message)
128
+ when 409 then ConflictError.new(message, body: body)
129
+ when 429 then RateLimitError.new(message, retry_after: retry_after(response))
130
+ else AvailabilityError.new(message)
131
+ end
132
+ end
133
+
134
+ def extract_message(body)
135
+ return nil unless body.is_a?(Hash)
136
+
137
+ body[:message] || body["message"] || body[:error] || body["error"]
138
+ end
139
+
140
+ def retry_after(response)
141
+ headers = response.respond_to?(:headers) ? response.headers : nil
142
+ headers && (headers["Retry-After"] || headers["retry-after"])
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BMLConnect
4
+ # Stored-card tokens: list, retrieve, and delete a customer's saved cards,
5
+ # all nested under /public-customers/{customerId}/tokens.
6
+ #
7
+ # Read-and-delete ONLY, by contract. BML publishes no endpoint that creates a
8
+ # token — a token is a side effect of a tokenizing transaction (feature 003),
9
+ # completed by the cardholder on BML's hosted page. This resource therefore
10
+ # exposes no create/tokenize/store/detokenize method, and its absence is
11
+ # deliberate and test-enforced (FR-005/FR-008, SC-006). No operation here
12
+ # accepts a card number, security code, or capture handle (FR-006).
13
+ #
14
+ # Reached through a configured client as `client.tokens`, mirroring
15
+ # `client.customers`. Returns whitelisted value objects and raises the
16
+ # library's error hierarchy on failure. Transient failures are retried by the
17
+ # shared transport (research R9); only delete is audited (FR-014).
18
+ class Tokens < Resource
19
+ PATH = "/public-customers/%<customer_id>s/tokens"
20
+
21
+ # List a customer's stored cards. Returns a Models::TokenList (Enumerable).
22
+ # An empty collection is an empty list, never an error, and an auth failure
23
+ # is never swallowed into one (FR-016, research R6). Not audited (read).
24
+ def list(customer_id, actor: nil)
25
+ require_customer_id!(customer_id)
26
+ screen_actor!(actor)
27
+
28
+ response = request(:get, collection_path(customer_id))
29
+ Models::TokenList.new(response.body)
30
+ end
31
+
32
+ # Retrieve one stored card by id. Raises NotFoundError for an unknown token
33
+ # or one belonging to another customer (tokens are scoped to their
34
+ # customer). Not audited (read).
35
+ def retrieve(customer_id, token_id, actor: nil)
36
+ require_customer_id!(customer_id)
37
+ require_token_id!(token_id)
38
+ screen_actor!(actor)
39
+
40
+ response = request(:get, member_path(customer_id, token_id))
41
+ Models::Token.new(response.body)
42
+ end
43
+
44
+ # Delete (soft-delete) a stored card. Returns true on 204. Emits exactly one
45
+ # audit record, written after the request settles — including after any
46
+ # transient retries — so a retried delete is audited once, capturing the
47
+ # final outcome (FR-013a x FR-014). The optional `actor` names who initiated
48
+ # the deletion for the audit "who"; it must not carry cardholder data.
49
+ def delete(customer_id, token_id, actor: nil)
50
+ require_customer_id!(customer_id)
51
+ require_token_id!(token_id)
52
+ screen_actor!(actor)
53
+
54
+ request(:delete, member_path(customer_id, token_id))
55
+ audit(:delete, actor: actor, subject: { customer_id: customer_id, token_id: token_id })
56
+ true
57
+ end
58
+
59
+ private
60
+
61
+ def collection_path(customer_id)
62
+ format(PATH, customer_id: customer_id)
63
+ end
64
+
65
+ def member_path(customer_id, token_id)
66
+ "#{collection_path(customer_id)}/#{token_id}"
67
+ end
68
+
69
+ def require_customer_id!(customer_id)
70
+ return unless blank?(customer_id)
71
+
72
+ raise ValidationError.new("customer_id is required", field: :customer_id)
73
+ end
74
+
75
+ def require_token_id!(token_id)
76
+ return unless blank?(token_id)
77
+
78
+ raise ValidationError.new("token_id is required", field: :token_id)
79
+ end
80
+
81
+ # The actor reference is the only caller-supplied string this resource takes.
82
+ # Screen it for card data so nothing PAN-like can reach an audit record.
83
+ def screen_actor!(actor)
84
+ return if actor.nil?
85
+ return unless actor.is_a?(String)
86
+ return unless Masking.looks_like_pan?(actor)
87
+
88
+ raise ValidationError.new("actor must not contain card data", field: :actor)
89
+ end
90
+
91
+ def audit(action, actor:, subject:)
92
+ Audit.emit_event(client, action: action, actor: actor, outcome: :success, subject: subject)
93
+ end
94
+
95
+ def blank?(value)
96
+ value.nil? || (value.is_a?(String) && value.strip.empty?)
97
+ end
98
+ end
99
+ end
@@ -1,18 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
3
+ require "json"
4
4
 
5
- require 'deep_merge/rails_compat'
5
+ require "deep_merge/rails_compat"
6
6
 
7
7
  module BMLConnect
8
- class Transactions
9
- END_POINT = 'transactions'
8
+ # The transactions resource.
9
+ #
10
+ # This resource straddles two eras. The v1 methods — #create, #get, #list —
11
+ # are RELEASED and depended on by six call sites in msgowl/website; they return
12
+ # raw Faraday::Response objects and MUST NOT change behavior, signature, or
13
+ # return type (FR-010). #create still posts to the undocumented-but-live
14
+ # /public/transactions with the SHA1 signature, and is deprecated (FR-011).
15
+ #
16
+ # The v2 methods — #create_v2, #retrieve, #update, #capture, #cancel — are
17
+ # additive. They speak the documented endpoints, return whitelisted
18
+ # TransactionRecord value objects, raise the library's error hierarchy, and
19
+ # emit audit records on state change (FR-019). They send no signature until UAT
20
+ # observation settles whether v2 accepts one (FR-012).
21
+ class Transactions < Resource
22
+ END_POINT = "transactions"
10
23
 
11
- def initialize(client)
12
- @client = client
24
+ # Documented v2 create endpoint (FR-001).
25
+ V2_CREATE_PATH = "/public/v2/transactions"
26
+ # Shared retrieve/update/capture/cancel base (FR-006/007/008/009). The v1
27
+ # retrieve path too, so a v1-created transaction is retrievable here.
28
+ BASE_PATH = "/public/transactions"
29
+
30
+ # Fields accepted on a v2 create body (variants 3 and 4 — direct amount, with
31
+ # an existing customerId or an inline customer). Variants 1/2 (shop orders)
32
+ # and 5 (foreign exchange) are out of scope and rejected by name (FR-020).
33
+ CREATE_V2_FIELDS = %i[
34
+ amount currency redirectUrl webhook localId customerReference
35
+ customerId customer expires tokenizationDetails
36
+ ].freeze
37
+
38
+ # Presence of any of these signals an unsupported create variant.
39
+ UNSUPPORTED_VARIANT_KEYS = %i[order fxQuoteId].freeze
40
+
41
+ # Fields amendable via #update; only supplied keys are sent (FR-007).
42
+ UPDATE_FIELDS = %i[customerReference localData pnr].freeze
43
+
44
+ # Process-wide guard so the v1 deprecation warning fires at most once per
45
+ # process (FR-011). Resettable so tests can assert the once-only behavior.
46
+ class << self
47
+ attr_accessor :v1_deprecation_warned
13
48
  end
14
49
 
50
+ # Construction is inherited from Resource (which stores the client); no
51
+ # override needed. @client is available to every method below.
52
+
53
+ # --- v1, released surface (UNCHANGED behavior) --------------------------
54
+
55
+ # Deprecated. Posts the signed body to the undocumented-but-live v1 endpoint
56
+ # and returns a Faraday::Response, exactly as before. Emits a one-time
57
+ # deprecation warning (FR-011) that does not alter behavior or return value.
15
58
  def create(params)
59
+ warn_v1_deprecation
16
60
  transaction = BMLConnect::Models::Transaction.new(params)
17
61
  # generate signature
18
62
  transaction.sign(@client.api_key)
@@ -26,5 +70,245 @@ module BMLConnect
26
70
  def list(params = {})
27
71
  @client.get(END_POINT, params)
28
72
  end
73
+
74
+ # --- v2, additive value-object surface ----------------------------------
75
+
76
+ # Create a transaction on the documented v2 endpoint. Validates locally
77
+ # (amount is a positive Integer in minor units; currency present; customer
78
+ # variant; tokenization rules; PAN screen) before any remote call. NEVER
79
+ # auto-retried (FR-016): a timeout raises AvailabilityError after exactly one
80
+ # attempt. Returns a TransactionRecord and emits a create audit record.
81
+ def create_v2(details, actor: nil)
82
+ attributes = symbolize(details)
83
+ reject_unsupported_variants!(attributes)
84
+ validate_amount!(attributes[:amount])
85
+ require_present!(attributes, :currency)
86
+ validate_customer_variant!(attributes)
87
+ tokenization = build_tokenization(attributes)
88
+ warn_tokenization_without_customer(attributes, tokenization)
89
+ screen_for_pan!(attributes)
90
+ screen_actor!(actor)
91
+
92
+ body = build_create_body(attributes, tokenization)
93
+ response = request(:post, V2_CREATE_PATH, body: body, retries: false)
94
+ record = Models::TransactionRecord.new(response.body)
95
+ audit(:create, actor: actor, subject: { transaction_id: record.id })
96
+ record
97
+ end
98
+
99
+ # Retrieve a transaction by id. Value-object counterpart to #get, which
100
+ # remains available. `state` is passed through verbatim (research R8). Not
101
+ # audited (read). MAY retry (FR-016) — hence `retries: true` by default,
102
+ # which is feature 003's released behavior, unchanged.
103
+ #
104
+ # Pass `retries: false` when the CALLER must bound the number of HTTP calls
105
+ # rather than the transport. The webhook handler (feature 005) does exactly
106
+ # that: FR-009 caps a notification at two retrieves counted in HTTP calls, so
107
+ # with retry left on, one delivery arriving while BML is flaky would make up
108
+ # to six requests and a forger could use a wobbly BML to triple their
109
+ # amplification (005 research R3). A transport failure then raises
110
+ # AvailabilityError after exactly one attempt.
111
+ def retrieve(id, actor: nil, retries: true)
112
+ require_id!(id)
113
+ screen_actor!(actor)
114
+
115
+ response = request(:get, "#{BASE_PATH}/#{id}", retries: retries)
116
+ Models::TransactionRecord.new(response.body)
117
+ end
118
+
119
+ # Partially amend a transaction. Sends only the supplied keys among
120
+ # customerReference/localData/pnr; never serializes an unset field as null.
121
+ # Rejects an empty change set locally. Emits an update audit record (FR-017).
122
+ def update(id, changes, actor: nil)
123
+ require_id!(id)
124
+ attributes = symbolize(changes)
125
+ raise ValidationError.new("no changes supplied", field: :changes) if attributes.empty?
126
+
127
+ screen_for_pan!(attributes)
128
+ screen_actor!(actor)
129
+
130
+ body = attributes.select { |key, _| UPDATE_FIELDS.include?(key) }
131
+ raise ValidationError.new("no updatable fields supplied", field: :changes) if body.empty?
132
+
133
+ response = request(:patch, "#{BASE_PATH}/#{id}", body: body)
134
+ record = Models::TransactionRecord.new(response.body)
135
+ audit(:update, actor: actor, subject: { transaction_id: id })
136
+ record
137
+ end
138
+
139
+ # Capture a pre-authorized amount. `amount` obeys the same rule as create
140
+ # (FR-008): a positive Integer in minor units. Money-moving, so NEVER
141
+ # auto-retried (FR-016). Emits a capture audit record.
142
+ def capture(id, amount:, actor: nil)
143
+ require_id!(id)
144
+ validate_amount!(amount)
145
+ screen_actor!(actor)
146
+
147
+ body = { id: id, amount: amount }
148
+ response = request(:post, "#{BASE_PATH}/#{id}/capture", body: body, retries: false)
149
+ record = Models::TransactionRecord.new(response.body)
150
+ audit(:capture, actor: actor, subject: { transaction_id: id })
151
+ record
152
+ end
153
+
154
+ # Cancel a transaction. Emits a cancel audit record. MAY retry (FR-016):
155
+ # cancellation is not money-moving and is safe to re-send.
156
+ def cancel(id, actor: nil)
157
+ require_id!(id)
158
+ screen_actor!(actor)
159
+
160
+ response = request(:post, "#{BASE_PATH}/#{id}/cancel")
161
+ record = Models::TransactionRecord.new(response.body)
162
+ audit(:cancel, actor: actor, subject: { transaction_id: id })
163
+ record
164
+ end
165
+
166
+ private
167
+
168
+ # Diagnostic-only: send a minimal v2 create and return the RAW Faraday
169
+ # response, for the UAT probe that resolves which field carries the hosted
170
+ # payment URL ([UNVERIFIED] #1 / T021). Not part of the public surface.
171
+ def raw_create_v2(details)
172
+ attributes = symbolize(details)
173
+ validate_amount!(attributes[:amount])
174
+ require_present!(attributes, :currency)
175
+ body = build_create_body(attributes, build_tokenization(attributes))
176
+ request(:post, V2_CREATE_PATH, body: body, retries: false)
177
+ end
178
+
179
+ def build_create_body(attributes, tokenization)
180
+ body = attributes.select { |key, _| CREATE_V2_FIELDS.include?(key) }
181
+ body[:tokenizationDetails] = tokenization.to_h if tokenization
182
+ body
183
+ end
184
+
185
+ # Validation object if tokenizationDetails supplied (validates on construct,
186
+ # before any remote call); nil otherwise.
187
+ def build_tokenization(attributes)
188
+ return nil unless attributes.key?(:tokenizationDetails)
189
+
190
+ Models::TokenizationDetails.new(attributes[:tokenizationDetails])
191
+ end
192
+
193
+ # Edge case: tokenization with no customer to attach the token to is
194
+ # [UNVERIFIED] (#5). The library WARNS and forwards rather than blocking,
195
+ # pending observation — it does not invent a rule BML has not stated.
196
+ def warn_tokenization_without_customer(attributes, tokenization)
197
+ return unless tokenization
198
+ return unless blank?(attributes[:customerId]) && attributes[:customer].nil?
199
+
200
+ logger = @client.respond_to?(:logger) ? @client.logger : nil
201
+ logger&.warn(
202
+ "[bml_connect] tokenizationDetails supplied without a customerId; " \
203
+ "whether BML stores a token with no customer to attach it to is unverified"
204
+ )
205
+ end
206
+
207
+ def reject_unsupported_variants!(attributes)
208
+ UNSUPPORTED_VARIANT_KEYS.each do |key|
209
+ next unless attributes.key?(key)
210
+
211
+ raise ValidationError.new(
212
+ "create variant using `#{key}` is not supported; use amount/currency (variant 3 or 4)",
213
+ field: key
214
+ )
215
+ end
216
+ end
217
+
218
+ # FR-005/FR-008: a positive Integer in minor units. A Float, a String, zero,
219
+ # a negative, or a boolean is rejected by name with no remote call. Never
220
+ # coerced — guessing on a money field is unacceptable (research R5).
221
+ def validate_amount!(amount)
222
+ raise ValidationError.new("amount is required", field: :amount) if amount.nil?
223
+ unless amount.is_a?(Integer)
224
+ raise ValidationError.new("amount must be an Integer in minor units, not #{amount.class}", field: :amount)
225
+ end
226
+ return if amount.positive?
227
+
228
+ raise ValidationError.new("amount must be a positive number of minor units", field: :amount)
229
+ end
230
+
231
+ def validate_customer_variant!(attributes)
232
+ customer = attributes[:customer]
233
+ if !blank?(attributes[:customerId]) && !customer.nil?
234
+ raise ValidationError.new("customerId and inline customer are mutually exclusive", field: :customerId)
235
+ end
236
+
237
+ validate_inline_customer!(customer) unless customer.nil?
238
+ end
239
+
240
+ def validate_inline_customer!(customer)
241
+ unless customer.respond_to?(:each_pair)
242
+ raise ValidationError.new("customer must be an object with name and email", field: :customer)
243
+ end
244
+
245
+ inline = customer.transform_keys(&:to_sym)
246
+ raise ValidationError.new("customer.name is required", field: :customer) if blank?(inline[:name])
247
+ raise ValidationError.new("customer.email is required", field: :customer) if blank?(inline[:email])
248
+ end
249
+
250
+ def warn_v1_deprecation
251
+ return if self.class.v1_deprecation_warned
252
+
253
+ self.class.v1_deprecation_warned = true
254
+ logger = @client.respond_to?(:logger) ? @client.logger : nil
255
+ logger&.warn(
256
+ "[bml_connect] transactions.create (legacy v1 POST /public/transactions) is deprecated; " \
257
+ "migrate to transactions.create_v2 (POST /public/v2/transactions)"
258
+ )
259
+ end
260
+
261
+ # --- shared local helpers (mirror the Customers resource) ---------------
262
+
263
+ def symbolize(attributes)
264
+ raise ValidationError, "expected a hash of attributes" unless attributes.respond_to?(:each_pair)
265
+
266
+ attributes.transform_keys(&:to_sym)
267
+ end
268
+
269
+ def require_present!(attributes, field)
270
+ return unless blank?(attributes[field])
271
+
272
+ raise ValidationError.new("#{field} is required", field: field)
273
+ end
274
+
275
+ def require_id!(id)
276
+ return unless blank?(id)
277
+
278
+ raise ValidationError.new("transaction id is required", field: :id)
279
+ end
280
+
281
+ # Screen every caller-supplied string value for card data (FR-014): no field
282
+ # is exempt. Recurses into nested hashes (inline customer, tokenization) and
283
+ # arrays so a PAN cannot hide one level down.
284
+ def screen_for_pan!(value, field: nil)
285
+ case value
286
+ when String then reject_pan_string!(value, field)
287
+ when Hash then value.each { |key, nested| screen_for_pan!(nested, field: field || key) }
288
+ when Array then value.each { |nested| screen_for_pan!(nested, field: field) }
289
+ end
290
+ end
291
+
292
+ def reject_pan_string!(value, field)
293
+ return unless Masking.looks_like_pan?(value)
294
+
295
+ raise ValidationError.new("#{field} contains data that looks like a card number", field: field)
296
+ end
297
+
298
+ def screen_actor!(actor)
299
+ return if actor.nil?
300
+ return unless actor.is_a?(String)
301
+ return unless Masking.looks_like_pan?(actor)
302
+
303
+ raise ValidationError.new("actor must not contain card data", field: :actor)
304
+ end
305
+
306
+ def audit(action, actor:, subject:)
307
+ Audit.emit_event(@client, action: action, actor: actor, outcome: :success, subject: subject)
308
+ end
309
+
310
+ def blank?(value)
311
+ value.nil? || (value.is_a?(String) && value.strip.empty?)
312
+ end
29
313
  end
30
314
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BMLConnect
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BMLConnect
4
+ class Webhooks
5
+ # Sends a verified status change to the handler that owns it.
6
+ #
7
+ # A merchant registers ONE hook in BML's portal, so a single endpoint
8
+ # receives notifications for every transaction the merchant creates —
9
+ # enrolments, settlement charges, interactive payments, top-ups. BML's
10
+ # payload is [UNVERIFIED] and in any case cannot say which of the
11
+ # consumer's flows a transaction belongs to; only the consumer knows that.
12
+ # So the resolver is supplied by the consumer and this class stays ignorant
13
+ # of what the names mean.
14
+ #
15
+ # router = BMLConnect::Webhooks::Router.new
16
+ # router.resolve { |result| Ownership.of(result.transaction_id) }
17
+ # router.on(:enrolment) { |result| ... }
18
+ # router.on(:settlement) { |result| ... }
19
+ # router.otherwise { |result| ... }
20
+ #
21
+ # client.webhooks.handle(body: body, headers: headers, router: router)
22
+ class Router
23
+ class UnroutableError < BMLConnect::Error; end
24
+
25
+ def initialize
26
+ @handlers = {}
27
+ @resolver = nil
28
+ @fallback = nil
29
+ end
30
+
31
+ def resolve(&block)
32
+ raise ArgumentError, "resolve requires a block" unless block
33
+
34
+ @resolver = block
35
+ self
36
+ end
37
+
38
+ # Registering twice is a mistake rather than an override: silently
39
+ # replacing a handler hides the duplicate until a delivery goes to the
40
+ # wrong place.
41
+ def on(name, &block)
42
+ raise ArgumentError, "a handler for #{name} is already registered" if @handlers.key?(key(name))
43
+ raise ArgumentError, "on(#{name}) requires a block" unless block
44
+
45
+ @handlers[key(name)] = block
46
+ self
47
+ end
48
+
49
+ def otherwise(&block)
50
+ raise ArgumentError, "otherwise requires a block" unless block
51
+
52
+ @fallback = block
53
+ self
54
+ end
55
+
56
+ def dispatch(result)
57
+ raise UnroutableError, "no resolver registered" if @resolver.nil?
58
+
59
+ name = @resolver.call(result)
60
+ return handler_for!(name, result).call(result) unless name.nil?
61
+
62
+ unless @fallback
63
+ raise UnroutableError,
64
+ "nothing owns transaction #{result.transaction_id} and no fallback is registered"
65
+ end
66
+
67
+ @fallback.call(result)
68
+ end
69
+
70
+ private
71
+
72
+ def handler_for!(name, result)
73
+ @handlers.fetch(key(name)) do
74
+ raise UnroutableError,
75
+ "resolver named #{name} for transaction #{result.transaction_id}, " \
76
+ "but no handler is registered under it"
77
+ end
78
+ end
79
+
80
+ def key(name)
81
+ name.to_sym
82
+ end
83
+ end
84
+ end
85
+ end