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.
@@ -3,6 +3,7 @@
3
3
  require 'faraday'
4
4
  require 'faraday_middleware'
5
5
  require 'deep_merge/rails_compat'
6
+ require 'logger'
6
7
 
7
8
  module BMLConnect
8
9
  class Client
@@ -12,16 +13,62 @@ module BMLConnect
12
13
  BML_SANDBOX_ENDPOINT = "https://api.uat.merchants.bankofmaldives.com.mv/public/"
13
14
  BML_PRODUCTION_ENDPOINT = "https://api.merchants.bankofmaldives.com.mv/public/"
14
15
 
15
- attr_reader(:api_key, :http_client, :transactions)
16
+ DEFAULT_TIMEOUT = 30
17
+ DEFAULT_MAX_RETRIES = 2
18
+ DEFAULT_RETRY_BACKOFF = 0.5
19
+
20
+ # Seconds to wait before the webhook handler's single re-check (feature 005,
21
+ # FR-010b). Spent INSIDE the caller's request, so an endpoint whose own
22
+ # response deadline is at or below this MUST set it to 0, which disables the
23
+ # re-check entirely. A judgment, not an observed figure: see
24
+ # specs/005-webhook-handler/contracts/bml-remote.md [UNVERIFIED] #4.
25
+ DEFAULT_WEBHOOK_RECHECK_DELAY = 3
26
+
27
+ attr_reader(:api_key, :app_id, :mode, :http_client, :transactions, :logger,
28
+ :webhook_secret, :webhook_recheck_delay)
29
+ attr_accessor(:timeout, :max_retries, :retry_backoff)
16
30
 
17
31
  def initialize(api_key: nil, app_id: nil, mode: nil, options: {})
18
32
  @api_key = api_key || (defined?(BML_API_KEY) ? BML_API_KEY : 'not-set')
19
33
  @app_id = app_id || (defined?(BML_APP_ID) ? BML_APP_ID : 'not-set')
20
34
  @mode = mode || (defined?(BML_MODE) ? BML_MODE : 'production')
21
- @http_client = initialize_http_client(options)
35
+
36
+ # Pull the customers-resource knobs out of options before the rest is
37
+ # handed to Faraday, which would reject unknown keys.
38
+ opts = options.dup
39
+ @logger = pull_option(opts, :logger) { default_logger }
40
+ @timeout = pull_option(opts, :timeout) { DEFAULT_TIMEOUT }
41
+ @max_retries = pull_option(opts, :max_retries) { DEFAULT_MAX_RETRIES }
42
+ @retry_backoff = pull_option(opts, :retry_backoff) { DEFAULT_RETRY_BACKOFF }
43
+
44
+ # Webhook-handler knobs (feature 005). Pulled out for the same reason as the
45
+ # four above: Faraday rejects keys it does not know. webhook_secret is
46
+ # configuration supplied by the integrator (typically from the environment)
47
+ # and MUST NEVER be hardcoded (FR-012).
48
+ @webhook_secret = pull_option(opts, :webhook_secret) { nil }
49
+ @webhook_recheck_delay = pull_option(opts, :webhook_recheck_delay) { DEFAULT_WEBHOOK_RECHECK_DELAY }
50
+
51
+ @http_client = initialize_http_client(opts)
22
52
  @transactions = Transactions.new(self)
23
53
  end
24
54
 
55
+ # Memoized customers resource, bound to this client's mode and credentials.
56
+ def customers
57
+ @customers ||= Customers.new(self)
58
+ end
59
+
60
+ # Memoized stored-card tokens resource, bound to this client's mode and
61
+ # credentials. Read-and-delete only — see BMLConnect::Tokens.
62
+ def tokens
63
+ @tokens ||= Tokens.new(self)
64
+ end
65
+
66
+ # Memoized inbound notification handler, bound to this client's mode,
67
+ # credentials, secret and re-check delay. See BMLConnect::Webhooks.
68
+ def webhooks
69
+ @webhooks ||= Webhooks.new(self)
70
+ end
71
+
25
72
  def base_url
26
73
  @mode == 'production' ? BML_PRODUCTION_ENDPOINT : BML_SANDBOX_ENDPOINT
27
74
  end
@@ -44,6 +91,21 @@ module BMLConnect
44
91
  end
45
92
 
46
93
  private
94
+
95
+ # Take +key+ out of the options hash (so Faraday never sees it) and fall back
96
+ # to the block when the caller did not supply it. An explicit nil is honored.
97
+ def pull_option(opts, key)
98
+ opts.key?(key) ? opts.delete(key) : yield
99
+ end
100
+
101
+ # Default audit/diagnostic logger. Emits masked structured lines to stdout;
102
+ # integrators can inject their own via `options: { logger: ... }`.
103
+ def default_logger
104
+ logger = Logger.new($stdout)
105
+ logger.progname = 'bml_connect'
106
+ logger
107
+ end
108
+
47
109
  def initialize_http_client(options)
48
110
  defaults = {
49
111
  url: base_url,
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BMLConnect
4
+ # The customers resource: create, retrieve, list, partial-update, archive, and
5
+ # charge a stored card under the /public-customers path family.
6
+ #
7
+ # Reached through a configured client as `client.customers`, mirroring the way
8
+ # `client.transactions` is already exposed. Unlike transactions (which return
9
+ # raw Faraday responses), this resource returns whitelisted value objects and
10
+ # raises the library's error hierarchy on failure.
11
+ #
12
+ # `#charge` (feature 004) lives here because its endpoint is
13
+ # `POST /public-customers/charge` — the customers path family this resource
14
+ # already owns (FR-001, research R8). It is the only money-moving method on the
15
+ # resource and is deliberately asymmetric with the others: it never
16
+ # auto-retries and it audits failures as well as successes.
17
+ class Customers < Resource
18
+ PATH = "/public-customers"
19
+
20
+ # Fields BML accepts on create (per contracts/bml-remote.md). `currency` and
21
+ # `paymentDue` are NOT here — they are update-only.
22
+ CREATE_FIELDS = %i[
23
+ name email billingEmail billingAddress1 billingAddress2 billingCity
24
+ billingCountry billingPostCode customerGroup invoicePrefix
25
+ taxInformation taxId
26
+ ].freeze
27
+
28
+ # Fields BML accepts on update. `customerGroup` is NOT here (create-only);
29
+ # `currency` and `paymentDue` are (update-only).
30
+ UPDATE_FIELDS = %i[
31
+ name email billingEmail billingAddress1 billingAddress2 billingCity
32
+ billingCountry billingPostCode currency paymentDue invoicePrefix
33
+ taxInformation taxId
34
+ ].freeze
35
+
36
+ # Fields that get the lightweight email shape check (2026-09-08 clarification).
37
+ EMAIL_FIELDS = %i[email billingEmail].freeze
38
+
39
+ # Create a customer. Requires `name` and `email`; accepts any documented
40
+ # optional field. Returns a Models::Customer carrying the BML-assigned id.
41
+ def create(details, actor: nil)
42
+ attributes = symbolize(details)
43
+ require_present!(attributes, :name)
44
+ require_present!(attributes, :email)
45
+ validate_email_shape!(attributes)
46
+ screen_for_pan!(attributes)
47
+ screen_actor!(actor)
48
+
49
+ body = attributes.select { |key, _| CREATE_FIELDS.include?(key) }
50
+ response = request(:post, PATH, body: body)
51
+ customer = Models::Customer.new(response.body)
52
+ audit(:create, actor: actor, subject: { customer_id: customer.id })
53
+ customer
54
+ end
55
+
56
+ # Retrieve a single customer by id. Not audited (read).
57
+ def retrieve(customer_id, actor: nil)
58
+ require_id!(customer_id)
59
+ screen_actor!(actor)
60
+
61
+ response = request(:get, "#{PATH}/#{customer_id}")
62
+ Models::Customer.new(response.body)
63
+ end
64
+
65
+ # List customers under the company. No page parameters are sent — the
66
+ # contract documents none (research R4). Not audited (read).
67
+ def list(actor: nil)
68
+ screen_actor!(actor)
69
+
70
+ response = request(:get, PATH)
71
+ Models::CustomerList.new(response.body)
72
+ end
73
+
74
+ # Partially update a customer. Sends only the keys supplied; never
75
+ # serializes an unset field as null. Rejects an empty change set locally.
76
+ def update(customer_id, changes, actor: nil)
77
+ require_id!(customer_id)
78
+ attributes = symbolize(changes)
79
+ raise ValidationError.new("no changes supplied", field: :changes) if attributes.empty?
80
+
81
+ validate_email_shape!(attributes)
82
+ screen_for_pan!(attributes)
83
+ screen_actor!(actor)
84
+
85
+ body = attributes.select { |key, _| UPDATE_FIELDS.include?(key) }
86
+ raise ValidationError.new("no updatable fields supplied", field: :changes) if body.empty?
87
+
88
+ response = request(:patch, "#{PATH}/#{customer_id}", body: body)
89
+ customer = Models::Customer.new(response.body)
90
+ audit(:update, actor: actor, subject: { customer_id: customer_id })
91
+ customer
92
+ end
93
+
94
+ # Archive (soft-delete) a customer. Named `archive`, not `delete`, because
95
+ # BML retains the record with `deleted: true`. Returns true on 204.
96
+ def archive(customer_id, actor: nil)
97
+ require_id!(customer_id)
98
+ screen_actor!(actor)
99
+
100
+ request(:delete, "#{PATH}/#{customer_id}")
101
+ audit(:archive, actor: actor, subject: { customer_id: customer_id })
102
+ true
103
+ end
104
+
105
+ # Charge a stored card against an already-created transaction — a
106
+ # merchant-initiated payment with no cardholder present. Posts the three-key
107
+ # body to `"#{PATH}/charge"` (POST /public-customers/charge). All three ids
108
+ # are REQUIRED and validated locally; a missing or blank one is rejected by
109
+ # name with NO remote call (FR-002). The charge carries no `amount` — the
110
+ # named transaction supplies it (FR-003); to bill a different amount, create
111
+ # a different transaction.
112
+ #
113
+ # NEVER auto-retried (FR-004): the charge schema carries no idempotency key,
114
+ # so a retry could take payment twice. Exactly one attempt is made. A timeout
115
+ # or transport failure raises AvailabilityError whose message names the
116
+ # `transaction_id` (FR-005) — the only safe recovery is to retrieve the
117
+ # transaction and reconcile, never to re-charge blindly.
118
+ #
119
+ # A returned TransactionRecord is a BUSINESS outcome (BML answered); a raised
120
+ # error is a TRANSPORT or VALIDATION outcome. The two are never converted
121
+ # into each other (FR-007). `state` is passed through verbatim. Every charge
122
+ # — success or failure — emits an audit record (FR-010/FR-011); no card data
123
+ # ever enters it (FR-008). The charge is applied in the environment the
124
+ # client is configured for (FR-012), since it rides the same transport.
125
+ #
126
+ # `token_id` expects the stored card's `Token#id` — the value used as the
127
+ # tokenId path parameter in `client.tokens.retrieve`. This is an inference
128
+ # from consistent path-parameter naming, NOT yet confirmed against a live
129
+ # environment (contracts/bml-remote.md [UNVERIFIED] #1 / task T001). This
130
+ # method MUST NOT be relied on in production until it is confirmed (FR-013).
131
+ def charge(customer_id:, transaction_id:, token_id:, actor: nil)
132
+ subject = { customer_id: customer_id, transaction_id: transaction_id, token_id: token_id }
133
+
134
+ validate_charge!(subject, actor)
135
+ response = post_charge(subject, actor)
136
+
137
+ record = Models::TransactionRecord.new(response.body)
138
+ charge_audit(actor, subject.merge(state: record.state), :success)
139
+ record
140
+ end
141
+
142
+ private
143
+
144
+ # Local, pre-remote validation for a charge. A failure is audited (FR-011)
145
+ # and re-raised with NO remote call made (FR-002, US1 scenario 2).
146
+ def validate_charge!(subject, actor)
147
+ require_charge_field!(subject[:customer_id], :customer_id)
148
+ require_charge_field!(subject[:transaction_id], :transaction_id)
149
+ require_charge_field!(subject[:token_id], :token_id)
150
+ screen_for_pan!(subject)
151
+ screen_actor!(actor)
152
+ rescue ValidationError
153
+ charge_audit(actor, subject, :validation_error)
154
+ raise
155
+ end
156
+
157
+ # The single, never-retried remote attempt. Each failure mode is audited
158
+ # before the error is re-raised so a raise never loses the trail (FR-011).
159
+ def post_charge(subject, actor)
160
+ body = {
161
+ customerId: subject[:customer_id],
162
+ transactionId: subject[:transaction_id],
163
+ tokenId: subject[:token_id]
164
+ }
165
+ request(:post, "#{PATH}/charge", body: body, retries: false)
166
+ rescue AvailabilityError => e
167
+ charge_audit(actor, subject, :error)
168
+ raise AvailabilityError, reconcile_message(e.message, subject[:transaction_id])
169
+ rescue ValidationError
170
+ # A non-2xx business rejection of the charge itself (400/422).
171
+ charge_audit(actor, subject, :declined)
172
+ raise
173
+ rescue Error
174
+ # Any other mapped failure (auth, not-found, rate limit): the charge did
175
+ # not go through, but this is not a card decline.
176
+ charge_audit(actor, subject, :error)
177
+ raise
178
+ end
179
+
180
+ def symbolize(attributes)
181
+ raise ValidationError, "expected a hash of attributes" unless attributes.respond_to?(:each_pair)
182
+
183
+ attributes.transform_keys(&:to_sym)
184
+ end
185
+
186
+ def require_present!(attributes, field)
187
+ value = attributes[field]
188
+ return unless blank?(value)
189
+
190
+ raise ValidationError.new("#{field} is required", field: field)
191
+ end
192
+
193
+ def require_id!(customer_id)
194
+ return unless blank?(customer_id)
195
+
196
+ raise ValidationError.new("customer_id is required", field: :customer_id)
197
+ end
198
+
199
+ # Per-field presence check for the charge, so the error names exactly which
200
+ # of the three required ids is missing (FR-002).
201
+ def require_charge_field!(value, field)
202
+ return unless blank?(value)
203
+
204
+ raise ValidationError.new("#{field} is required", field: field)
205
+ end
206
+
207
+ # Charge auditing is deliberately separate from the shared #audit helper,
208
+ # which hardcodes outcome: :success. A charge audits failures too (FR-011),
209
+ # so the real outcome (:success, :declined, :validation_error, :error) is
210
+ # passed through. Card data is scrubbed by Audit before the line is written.
211
+ def charge_audit(actor, subject, outcome)
212
+ Audit.emit_event(client, action: :charge, actor: actor, outcome: outcome, subject: subject)
213
+ end
214
+
215
+ # An AvailabilityError from a charge MUST name the transaction so the caller
216
+ # can reconcile by retrieving it — the outcome is genuinely unknown (FR-005).
217
+ def reconcile_message(original, transaction_id)
218
+ "#{original} (charge outcome for transactionId #{transaction_id} is unknown; " \
219
+ "retrieve the transaction to reconcile — do NOT re-charge blindly)"
220
+ end
221
+
222
+ # Lightweight shape check only (2026-09-08 clarification / research R11):
223
+ # the value must contain an @ and a domain part. Not strict RFC validation —
224
+ # BML remains the authority on acceptance beyond this basic shape.
225
+ def validate_email_shape!(attributes)
226
+ EMAIL_FIELDS.each do |field|
227
+ next unless attributes.key?(field)
228
+
229
+ value = attributes[field]
230
+ next if value.nil?
231
+ next if valid_email_shape?(value)
232
+
233
+ raise ValidationError.new("#{field} is not a valid email address", field: field)
234
+ end
235
+ end
236
+
237
+ def valid_email_shape?(value)
238
+ return false unless value.is_a?(String)
239
+
240
+ !(value =~ /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/).nil?
241
+ end
242
+
243
+ # Screen every caller-supplied string value for card data (2026-09-08
244
+ # clarification): no field is exempt.
245
+ def screen_for_pan!(attributes)
246
+ attributes.each do |field, value|
247
+ next unless value.is_a?(String)
248
+ next unless Masking.looks_like_pan?(value)
249
+
250
+ raise ValidationError.new("#{field} contains data that looks like a card number", field: field)
251
+ end
252
+ end
253
+
254
+ def screen_actor!(actor)
255
+ return if actor.nil?
256
+ return unless actor.is_a?(String)
257
+ return unless Masking.looks_like_pan?(actor)
258
+
259
+ raise ValidationError.new("actor must not contain card data", field: :actor)
260
+ end
261
+
262
+ def audit(action, actor:, subject:)
263
+ Audit.emit_event(client, action: action, actor: actor, outcome: :success, subject: subject)
264
+ end
265
+
266
+ def blank?(value)
267
+ value.nil? || (value.is_a?(String) && value.strip.empty?)
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BMLConnect
4
+ # Base error for the library. Reopened here (also declared in bml_connect.rb)
5
+ # so this file is self-contained regardless of require order.
6
+ class Error < StandardError
7
+ # The HTTP status a host application SHOULD return to BML when this error
8
+ # ends the handling of an inbound notification (feature 005, FR-016a).
9
+ #
10
+ # It is ADVICE, not control: the library never writes a response and does not
11
+ # require the caller to honor it (FR-016b). The webhook path tags the error
12
+ # object before it propagates; nil everywhere else, so no existing raise site
13
+ # changes behavior. Carried on the base class rather than as a parallel
14
+ # hierarchy of status-specific classes, which would have broken callers that
15
+ # match on AvailabilityError.
16
+ attr_accessor :advisory_http_status
17
+ end
18
+
19
+ # Raised for local validation failures (before any remote call) and for
20
+ # BML 400/422 responses. Carries the offending field where known.
21
+ class ValidationError < Error
22
+ attr_reader :field
23
+
24
+ def initialize(message = nil, field: nil)
25
+ @field = field
26
+ super(message)
27
+ end
28
+ end
29
+
30
+ # 401/403 — missing, invalid, or unprovisioned credentials.
31
+ class AuthenticationError < Error; end
32
+
33
+ # 404 — no customer matches the given id.
34
+ class NotFoundError < Error; end
35
+
36
+ # 409 — conflicting state. Carries the parsed response body for inspection.
37
+ class ConflictError < Error
38
+ attr_reader :body
39
+
40
+ def initialize(message = nil, body: nil)
41
+ @body = body
42
+ super(message)
43
+ end
44
+ end
45
+
46
+ # 429 — rate limited. Carries the Retry-After hint when BML supplies one.
47
+ class RateLimitError < Error
48
+ attr_reader :retry_after
49
+
50
+ def initialize(message = nil, retry_after: nil)
51
+ @retry_after = retry_after
52
+ super(message)
53
+ end
54
+ end
55
+
56
+ # Timeout, connection failure, 408, or 5xx after bounded retries. Distinct
57
+ # from every other error so callers can safely retry later.
58
+ class AvailabilityError < Error; end
59
+
60
+ # Raised when an inbound notification fails the optional shared-secret check
61
+ # (feature 005, FR-009a). A DISTINCT class rather than a ValidationError with a
62
+ # field, because FR-016/SC-008 require a failed secret check to be observably
63
+ # distinguishable from a malformed body, and separating a security rejection
64
+ # only by a `field:` value is too weak for something operators alert on.
65
+ #
66
+ # The secret gates SPEND, not trust: this error means no retrieve was spent, and
67
+ # a matching secret never causes a payload to be believed (FR-009b).
68
+ class WebhookRejectedError < Error; end
69
+
70
+ # Raised when a value the caller asks for depends on a response field that is
71
+ # not yet verified against a live BML environment. Specifically:
72
+ # TransactionRecord#payment_url raises this when no hosted-payment-URL field is
73
+ # present, rather than returning nil and sending a cardholder to a blank page
74
+ # (contracts/bml-remote.md [UNVERIFIED] #1; research R7).
75
+ class UnverifiedFieldError < Error; end
76
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BMLConnect
4
+ # Detects and redacts card-number-like data. This library never has a
5
+ # legitimate reason to carry a PAN, so any value that looks like one is
6
+ # rejected on input and scrubbed from any log output.
7
+ #
8
+ # Ported from the retired bml_tokenization gem, whose masking was the one
9
+ # part of its engineering that was sound.
10
+ module Masking
11
+ # 13–19 digits, allowing a single space or dash between digits (the common
12
+ # ways a PAN gets pasted). Deliberately broad; the Luhn check below removes
13
+ # the false positives a bare length match would produce.
14
+ PAN_PATTERN = /\d(?:[ -]?\d){12,18}/.freeze
15
+
16
+ REDACTION = "[FILTERED]"
17
+
18
+ module_function
19
+
20
+ # True when +value+ contains a substring that both matches the PAN shape
21
+ # and passes the Luhn checksum. The Luhn gate keeps ordinary long numbers
22
+ # (tax ids, references) from being misread as card numbers.
23
+ def looks_like_pan?(value)
24
+ return false unless value.is_a?(String)
25
+
26
+ value.scan(PAN_PATTERN).any? { |candidate| luhn_valid?(candidate.gsub(/[ -]/, "")) }
27
+ end
28
+
29
+ # Standard Luhn (mod-10) validation over a digit string.
30
+ def luhn_valid?(digits)
31
+ return false unless digits.length.between?(13, 19)
32
+
33
+ sum = 0
34
+ digits.reverse.each_char.each_with_index do |char, index|
35
+ n = char.to_i
36
+ if index.odd?
37
+ n *= 2
38
+ n -= 9 if n > 9
39
+ end
40
+ sum += n
41
+ end
42
+ (sum % 10).zero?
43
+ end
44
+
45
+ # Replace any PAN-like substring in +text+ with the redaction marker.
46
+ # Used to guarantee no card data survives into a log line.
47
+ def scrub(text)
48
+ return text unless text.is_a?(String)
49
+
50
+ text.gsub(PAN_PATTERN) do |candidate|
51
+ luhn_valid?(candidate.gsub(/[ -]/, "")) ? REDACTION : candidate
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BMLConnect
4
+ module Models
5
+ # A read-only value object for a BML customer.
6
+ #
7
+ # Attributes are whitelisted: only the fields below are pulled from a BML
8
+ # response, so an unexpected key (e.g. a card field BML should never return
9
+ # on a customer) cannot reach the object, a log line, or an audit record.
10
+ class Customer
11
+ ATTRIBUTES = %i[
12
+ id name email companyId currency customerGroupId deleted
13
+ billingEmail billingAddress1 billingAddress2 billingCity
14
+ billingCountry billingPostCode invoicePrefix taxInformation taxId
15
+ createdAt updatedAt
16
+ ].freeze
17
+
18
+ ATTRIBUTES.each { |attribute| attr_reader attribute }
19
+
20
+ def initialize(data = {})
21
+ source = data || {}
22
+ ATTRIBUTES.each do |attribute|
23
+ value = source.key?(attribute) ? source[attribute] : source[attribute.to_s]
24
+ instance_variable_set("@#{attribute}", value)
25
+ end
26
+ end
27
+
28
+ def deleted?
29
+ deleted == true
30
+ end
31
+
32
+ def to_h
33
+ ATTRIBUTES.each_with_object({}) do |attribute, hash|
34
+ hash[attribute] = public_send(attribute)
35
+ end
36
+ end
37
+
38
+ def ==(other)
39
+ other.is_a?(Customer) && to_h == other.to_h
40
+ end
41
+ alias eql? ==
42
+
43
+ def hash
44
+ to_h.hash
45
+ end
46
+
47
+ def inspect
48
+ present = to_h.reject { |_, value| value.nil? }
49
+ "#<BMLConnect::Models::Customer #{present.inspect}>"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BMLConnect
4
+ module Models
5
+ # BML's list response: a { count, items } envelope, exposed as an
6
+ # Enumerable over Customer value objects.
7
+ #
8
+ # BML types `count` as a string in the published contract. Whether it
9
+ # arrives as "42" or 42 is unobserved (an [UNVERIFIED] marker), so the count
10
+ # is coerced to an Integer and falls back to the actual item count if the
11
+ # value is missing or non-numeric — it can never raise on a surprising type.
12
+ class CustomerList
13
+ include Enumerable
14
+
15
+ attr_reader :count, :items
16
+
17
+ def initialize(data = {})
18
+ source = data || {}
19
+ raw_items = source[:items] || source["items"] || []
20
+ @items = raw_items.map { |item| Customer.new(item) }
21
+ @count = coerce_count(source[:count] || source["count"])
22
+ end
23
+
24
+ def each(&block)
25
+ items.each(&block)
26
+ end
27
+
28
+ def size
29
+ items.size
30
+ end
31
+
32
+ def empty?
33
+ items.empty?
34
+ end
35
+
36
+ private
37
+
38
+ def coerce_count(raw)
39
+ Integer(raw)
40
+ rescue ArgumentError, TypeError
41
+ items.size
42
+ end
43
+ end
44
+ end
45
+ end