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,501 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module BMLConnect
8
+ # Handles an inbound BML transaction-status notification (feature 005).
9
+ #
10
+ # == The trust model, in one paragraph
11
+ #
12
+ # +reference/Connect-API.json+ documents how a merchant REGISTERS a hook and
13
+ # nothing whatsoever about the notification it produces — not the HTTP method,
14
+ # not the payload, and above all no signature, secret, or authentication. The
15
+ # entire inbound surface is therefore [UNVERIFIED], and this handler is built to
16
+ # need almost nothing from it: the payload is an untrusted HINT that something
17
+ # changed, and every consequential fact comes from re-retrieving the transaction
18
+ # from BML (FR-006). A forged "payment confirmed" can at worst cost one wasted
19
+ # retrieve. There is no mode, option, or flag that reports a payload's own
20
+ # claims as fact (FR-007) — see spec/unit/webhooks_surface_spec.rb, which
21
+ # asserts the switch does not exist to be found.
22
+ #
23
+ # == Deliberately not a Resource
24
+ #
25
+ # This class issues no HTTP of its own: the verifying retrieve is delegated to
26
+ # +client.transactions.retrieve+, so the library has exactly one implementation
27
+ # of a transaction read and this path inherits its status-to-error mapping. It
28
+ # also avoids a real collision — +Resource#handle(response)+ is private, and a
29
+ # subclass defining a public +#handle(body:, ...)+ would be called with a Faraday
30
+ # response and blow up.
31
+ #
32
+ # == What it costs
33
+ #
34
+ # At most TWO retrieves per notification, neither auto-retried (FR-009): the
35
+ # verify, plus at most one delayed re-check when the payload's claimed status
36
+ # disagrees (FR-010). Nothing a payload says can produce a third.
37
+ #
38
+ # == The optional shared secret gates SPEND, not trust
39
+ #
40
+ # When +webhook_secret+ is configured, a delivery whose presented value is absent
41
+ # or wrong is rejected before any retrieve is spent (FR-009a). A matching secret
42
+ # never causes the payload to be believed and never skips the retrieve (FR-009b).
43
+ # The library does NOT infer where the secret travels — no default header, query
44
+ # parameter, or path segment, and it never asks for the request URL (FR-009e).
45
+ # BML chooses the callback's headers, so in practice a secret can only ride in the
46
+ # URL the merchant registered; extracting it is the caller's job, comparing it is
47
+ # this class's.
48
+ class Webhooks
49
+ # Candidate field names for the transaction identifier, in resolution order.
50
+ # ALL [UNVERIFIED] until a real delivery is observed (FR-002a, SC-011).
51
+ #
52
+ # * +transactionId+ — the exact spelling BML uses wherever this value IS
53
+ # documented: the path parameter of +/public/transactions/{transactionId}+
54
+ # and the +transactionId+ field of the charge body. The strongest inference
55
+ # available from the published document.
56
+ # * +transaction_id+ — the snake_case spelling of the same name, for a callback
57
+ # emitted by a different service than the REST API.
58
+ # * +id+ — the field name on the +Transaction+ schema itself, for a payload
59
+ # that embeds a record rather than naming one.
60
+ #
61
+ # Top-level keys only, and only a non-blank String counts. A nested payload is
62
+ # what +extract_id:+ is for; walking arbitrary nesting would mean inventing a
63
+ # search strategy over a document that describes no payload at all.
64
+ #
65
+ # +localId+ is deliberately ABSENT: it is the merchant's own reference, not
66
+ # BML's transaction id, so retrieving by it would 404 onto the permanent
67
+ # advisory path. A plausible-looking candidate that produces a confidently
68
+ # wrong outcome is worse than no candidate.
69
+ ID_CANDIDATES = %i[transactionId transaction_id id].freeze
70
+
71
+ # Candidate field names for the payload's CLAIMED status, in resolution order.
72
+ # Both [UNVERIFIED]. Read solely for the FR-010 disagreement comparison and
73
+ # never reported as fact. +state+ is the +Transaction+ schema's spelling;
74
+ # +status+ is the common alternative in callback payloads generally.
75
+ STATUS_CANDIDATES = %i[state status].freeze
76
+
77
+ # Candidate field names for the payload's EVENT CATEGORY, in resolution
78
+ # order. All [UNVERIFIED]: the published document describes how a hook is
79
+ # REGISTERED and says nothing about categories, yet BML's portal offers
80
+ # them per hook ("Transaction change", "Tokenisation status") and a live
81
+ # delivery named one in +eventType+ ("NOTIFY_TRANSACTION_CHANGE", observed
82
+ # 2026-10-21). +webhookType+ is read next because the same delivery
83
+ # carried that key too, holding null.
84
+ #
85
+ # Read for ROUTING ONLY and never reported as a fact — see
86
+ # Models::StatusChangeResult#event_type. A category cannot shorten the
87
+ # verify-by-retrieve path, and there is no option by which it could.
88
+ EVENT_TYPE_CANDIDATES = %i[eventType webhookType event_type type].freeze
89
+
90
+ FORM_MEDIA_TYPE = "application/x-www-form-urlencoded"
91
+ JSON_MEDIA_TYPE = "application/json"
92
+
93
+ # Advisory HTTP statuses (FR-016a). Advice only: this class never writes a
94
+ # response (FR-016b). The full mapping and its reasoning live in
95
+ # specs/005-webhook-handler/contracts/library-api.md.
96
+ ADVISORY_ACCEPTED = 200 # handled; an authoritative record was returned
97
+ ADVISORY_MALFORMED = 400 # permanent: unparseable body, do not redeliver
98
+ ADVISORY_REJECTED = 401 # permanent: secret absent or wrong
99
+ ADVISORY_UNUSABLE = 422 # permanent: received, but nothing usable in it
100
+ ADVISORY_UNEXPECTED = 500 # transient: our bug or our misconfiguration
101
+ ADVISORY_TRANSIENT = 503 # transient: please redeliver
102
+
103
+ def initialize(client)
104
+ @client = client
105
+ end
106
+
107
+ # Handle one inbound notification and return a Models::StatusChangeResult.
108
+ #
109
+ # +body+:: the raw request body (String)
110
+ # +headers+:: the delivery's headers; a Rails +request.headers+, a
111
+ # Rack +request.env+, or a plain hash all work
112
+ # +presented_secret+:: the secret value YOU extracted from the delivery,
113
+ # required only when +webhook_secret+ is configured
114
+ # (FR-009a). The library never guesses its location.
115
+ # +extract_id+:: a callable overriding the built-in candidate list
116
+ # OUTRIGHT (FR-002b). Receives +(payload, headers)+ —
117
+ # headers included so an identifier arriving outside the
118
+ # body does not block you (FR-002c) — and returns the
119
+ # identifier or nil for "not found".
120
+ # +actor+:: who or what is handling, for the audit record's +who+.
121
+ #
122
+ # The reported status ALWAYS comes from a fresh retrieve. Raises on every
123
+ # outcome other than success; every error carries +advisory_http_status+.
124
+ #
125
+ # Ordering note: the secret check runs BEFORE parsing, not merely before the
126
+ # retrieve FR-009a requires. An unauthenticated sender should not be able to
127
+ # reach the parser at all, since the library enforces no body size limit
128
+ # (FR-003c) and parsing is the only work left that an attacker can provoke.
129
+ # +router+:: an optional Router. When given, the verified result is passed
130
+ # to the handler the router's resolver names, and THAT handler's
131
+ # return value is returned instead of the result. Verification is
132
+ # unchanged: a router never causes a payload to be believed.
133
+ def handle(body:, headers: {}, presented_secret: nil, extract_id: nil, actor: nil, router: nil)
134
+ screen_actor!(actor)
135
+ delay = recheck_delay!(actor: actor, raw_body: body)
136
+ normalized = normalize_headers(headers)
137
+
138
+ verify_secret!(presented_secret, actor: actor, raw_body: body)
139
+ payload = parse_body!(body, normalized, actor: actor)
140
+ transaction_id = extract_identifier!(payload, normalized, extract_id, actor: actor, raw_body: body)
141
+ claimed, event = payload_hints(payload)
142
+
143
+ record = retrieve!(transaction_id, actor: actor, raw_body: body, claimed: claimed)
144
+ rechecked = false
145
+
146
+ if disagreement?(claimed, record.state) && delay.positive?
147
+ wait(delay)
148
+ record = retrieve!(transaction_id, actor: actor, raw_body: body, claimed: claimed)
149
+ rechecked = true
150
+ end
151
+
152
+ result = build_result(transaction_id, record, claimed, event, rechecked)
153
+ audit(:success, actor: actor, raw_body: body, result: result)
154
+
155
+ router ? router.dispatch(result) : result
156
+ end
157
+
158
+ private
159
+
160
+ attr_reader :client
161
+
162
+ # The two payload-derived values this class reads beyond the identifier:
163
+ # what the delivery CLAIMS the status is, and which category it says it
164
+ # belongs to. Neither can shorten or skip the verifying retrieve.
165
+ def payload_hints(payload)
166
+ [first_string(payload, STATUS_CANDIDATES), first_string(payload, EVENT_TYPE_CANDIDATES)]
167
+ end
168
+
169
+ # --- the wait ----------------------------------------------------------
170
+
171
+ # The single re-check delay, spent synchronously inside the CALLER's request
172
+ # (FR-010b). Isolated in one method so specs can observe it without any test
173
+ # ever sleeping for real.
174
+ def wait(seconds)
175
+ sleep(seconds)
176
+ end
177
+
178
+ # --- configuration -----------------------------------------------------
179
+
180
+ # Validated, never coerced: a delay we guessed at would be a silent behavior
181
+ # change on a latency-sensitive path.
182
+ def recheck_delay!(actor:, raw_body:)
183
+ value = client.respond_to?(:webhook_recheck_delay) ? client.webhook_recheck_delay : nil
184
+ value = Client::DEFAULT_WEBHOOK_RECHECK_DELAY if value.nil?
185
+
186
+ unless value.is_a?(Numeric) && !value.negative?
187
+ error = ValidationError.new(
188
+ "webhook_recheck_delay must be a non-negative number of seconds, got #{value.inspect}",
189
+ field: :webhook_recheck_delay
190
+ )
191
+ # Our misconfiguration, not the sender's fault, so advise transient: a
192
+ # redelivery after an operator fixes the setting should succeed.
193
+ reject!(error, ADVISORY_UNEXPECTED, reason: :configuration, actor: actor,
194
+ raw_body: raw_body)
195
+ end
196
+
197
+ value
198
+ end
199
+
200
+ def configured_secret
201
+ client.respond_to?(:webhook_secret) ? client.webhook_secret : nil
202
+ end
203
+
204
+ # --- the secret --------------------------------------------------------
205
+
206
+ def verify_secret!(presented, actor:, raw_body:)
207
+ secret = configured_secret
208
+ return if blank?(secret)
209
+
210
+ return if !blank?(presented) && secure_equal?(secret, presented)
211
+
212
+ # Neither value appears in the message (FR-012).
213
+ reject!(
214
+ WebhookRejectedError.new("inbound notification did not present a matching shared secret"),
215
+ ADVISORY_REJECTED, reason: :secret_mismatch, actor: actor, raw_body: raw_body
216
+ )
217
+ end
218
+
219
+ # Constant-time comparison (FR-009d). OpenSSL.fixed_length_secure_compare is
220
+ # unavailable on this toolchain (verified on Ruby 2.7.4) and Rack::Utils is out
221
+ # of reach (FR-001 forbids the dependency), so both operands are digested to a
222
+ # fixed 32 bytes and XOR-accumulated over every byte. Digesting first means
223
+ # neither the loop count nor the comparison time leaks the secret's length —
224
+ # the weakness a naive length check introduces.
225
+ def secure_equal?(expected, presented)
226
+ left = Digest::SHA256.digest(expected.to_s)
227
+ right = Digest::SHA256.digest(presented.to_s)
228
+
229
+ difference = 0
230
+ left.bytesize.times { |index| difference |= left.getbyte(index) ^ right.getbyte(index) }
231
+ difference.zero?
232
+ end
233
+
234
+ # --- parsing -----------------------------------------------------------
235
+
236
+ # Two formats, selected by declared Content-Type, with a documented fallback
237
+ # (FR-003a). No size or nesting limit is enforced (FR-003c): the library does
238
+ # not own the socket, so bounding the request is the host application's job.
239
+ def parse_body!(body, headers, actor:)
240
+ raw = body.to_s
241
+ malformed!("notification body is empty", actor: actor, raw_body: body) if raw.strip.empty?
242
+
243
+ unless raw.dup.force_encoding(Encoding::UTF_8).valid_encoding?
244
+ malformed!("notification body is not valid UTF-8", actor: actor, raw_body: body)
245
+ end
246
+
247
+ parsed = parse_by_media_type(raw, headers["content-type"])
248
+
249
+ unless parsed.is_a?(Hash)
250
+ malformed!(
251
+ "notification body could not be parsed as JSON or form-encoded data",
252
+ actor: actor, raw_body: body
253
+ )
254
+ end
255
+
256
+ deep_symbolize(parsed)
257
+ end
258
+
259
+ def parse_by_media_type(raw, content_type)
260
+ media = media_type(content_type)
261
+
262
+ if json_media?(media)
263
+ try_json(raw)
264
+ elsif media == FORM_MEDIA_TYPE
265
+ try_form(raw)
266
+ else
267
+ # Documented order, and the order matters: URI.decode_www_form does NOT
268
+ # raise on a JSON body — it returns one garbage key holding the whole
269
+ # string — so trying form first would mask every JSON payload instead of
270
+ # failing. JSON.parse, by contrast, raises cleanly on a form body.
271
+ try_json(raw) || try_form(raw)
272
+ end
273
+ end
274
+
275
+ def media_type(content_type)
276
+ content_type.to_s.split(";").first.to_s.strip.downcase
277
+ end
278
+
279
+ def json_media?(media)
280
+ media == JSON_MEDIA_TYPE || media.end_with?("+json")
281
+ end
282
+
283
+ def try_json(raw)
284
+ parsed = JSON.parse(raw)
285
+ parsed.is_a?(Hash) ? parsed : nil
286
+ rescue JSON::ParserError
287
+ nil
288
+ end
289
+
290
+ # A form body always contains "=" if it carries any field at all. Requiring it
291
+ # stops "hello world" from becoming {"hello world" => ""} — decode_www_form is
292
+ # permissive enough to "succeed" on arbitrary text.
293
+ def try_form(raw)
294
+ return nil unless raw.include?("=")
295
+
296
+ pairs = URI.decode_www_form(raw)
297
+ return nil if pairs.empty? || pairs.any? { |key, _| key.to_s.strip.empty? }
298
+
299
+ pairs.to_h
300
+ rescue ArgumentError
301
+ nil
302
+ end
303
+
304
+ # --- headers -----------------------------------------------------------
305
+
306
+ # Normalized once so Content-Type resolves whether the caller passed
307
+ # "Content-Type", "content_type", "CONTENT_TYPE", or Rack's
308
+ # "HTTP_CONTENT_TYPE" (research R5). First key wins on collision; a collision
309
+ # is not an error.
310
+ def normalize_headers(headers)
311
+ return {} unless headers.respond_to?(:each_pair)
312
+
313
+ headers.each_pair.each_with_object({}) do |(key, value), out|
314
+ name = key.to_s.downcase.tr("_", "-").sub(/\Ahttp-/, "")
315
+ out[name] = value unless out.key?(name)
316
+ end
317
+ end
318
+
319
+ # --- extraction --------------------------------------------------------
320
+
321
+ def extract_identifier!(payload, headers, extractor, actor:, raw_body:)
322
+ # A caller-supplied extractor is used INSTEAD of the built-in list, never
323
+ # alongside it (FR-002b): a caller who knows the true field must never be
324
+ # second-guessed by our inference.
325
+ value = extractor ? extractor.call(payload, headers) : first_string(payload, ID_CANDIDATES)
326
+
327
+ return value if value.is_a?(String) && !blank?(value)
328
+
329
+ error = ValidationError.new(
330
+ "no transaction identifier found in the notification " \
331
+ "(tried #{ID_CANDIDATES.join(", ")}; supply extract_id: to override)",
332
+ field: :transaction_id
333
+ )
334
+ reject!(error, ADVISORY_UNUSABLE, reason: :no_transaction_id, actor: actor, raw_body: raw_body)
335
+ end
336
+
337
+ # First candidate holding a non-blank String. A candidate present but holding a
338
+ # Hash, Array, number, or blank string is skipped as though absent: coercing it
339
+ # would only manufacture an identifier that fails one HTTP call later.
340
+ def first_string(payload, candidates)
341
+ candidates.each do |key|
342
+ value = payload[key]
343
+ return value if value.is_a?(String) && !blank?(value)
344
+ end
345
+ nil
346
+ end
347
+
348
+ # --- the retrieve ------------------------------------------------------
349
+
350
+ # retries: false is load-bearing — see Transactions#retrieve and FR-009.
351
+ def retrieve!(transaction_id, actor:, raw_body:, claimed:)
352
+ client.transactions.retrieve(transaction_id, retries: false)
353
+ rescue Error => e
354
+ reject!(e, advisory_for(e),
355
+ reason: reason_for(e),
356
+ actor: actor,
357
+ raw_body: raw_body,
358
+ transaction_id: transaction_id,
359
+ claimed: claimed)
360
+ end
361
+
362
+ def advisory_for(error)
363
+ case error
364
+ when NotFoundError then ADVISORY_UNUSABLE
365
+ when AvailabilityError, RateLimitError, AuthenticationError then ADVISORY_TRANSIENT
366
+ when ValidationError, ConflictError then ADVISORY_UNUSABLE
367
+ else ADVISORY_UNEXPECTED
368
+ end
369
+ end
370
+
371
+ def reason_for(error)
372
+ case error
373
+ when NotFoundError then :not_found
374
+ when RateLimitError then :rate_limited
375
+ when AvailabilityError then :unavailable
376
+ when AuthenticationError then :credential_rejected
377
+ else :rejected_by_bml
378
+ end
379
+ end
380
+
381
+ # --- the comparison ---------------------------------------------------
382
+
383
+ # Normalized for the COMPARISON ONLY (research R7). The two states observable
384
+ # in the published document are uppercase, so a callback spelling one of them
385
+ # differently would otherwise be read as a disagreement and pay the re-check
386
+ # delay on every delivery for nothing. The reported status stays verbatim
387
+ # (FR-005).
388
+ def disagreement?(claimed, authoritative)
389
+ return false if blank?(claimed) || !authoritative.is_a?(String)
390
+
391
+ normalize_status(claimed) != normalize_status(authoritative)
392
+ end
393
+
394
+ def normalize_status(value)
395
+ value.to_s.strip.upcase
396
+ end
397
+
398
+ # --- results, errors, auditing ----------------------------------------
399
+
400
+ def build_result(transaction_id, record, claimed, event, rechecked)
401
+ Models::StatusChangeResult.new(
402
+ transaction_id: transaction_id,
403
+ transaction: record,
404
+ claimed_status: claimed,
405
+ event_type: event,
406
+ disagreed: disagreement?(claimed, record.state),
407
+ rechecked: rechecked,
408
+ advisory_http_status: ADVISORY_ACCEPTED
409
+ )
410
+ end
411
+
412
+ def malformed!(message, actor:, raw_body:)
413
+ reject!(
414
+ ValidationError.new(message, field: :body),
415
+ ADVISORY_MALFORMED, reason: :malformed_body, actor: actor, raw_body: raw_body
416
+ )
417
+ end
418
+
419
+ # Tag the error with its advisory status, audit the outcome, then raise. Every
420
+ # failure path in this class goes through here, which is how FR-015 ("every
421
+ # notification handled, accepted or rejected, MUST emit an audit record") and
422
+ # FR-016a ("every result and every error MUST carry an advisory status") are
423
+ # kept true by construction rather than by remembering.
424
+ def reject!(error, advisory, reason:, actor:, raw_body:, transaction_id: nil, claimed: nil)
425
+ error.advisory_http_status = advisory
426
+ audit(:rejected, actor: actor, raw_body: raw_body, reason: reason,
427
+ transaction_id: transaction_id, claimed: claimed, advisory: advisory)
428
+ raise error
429
+ end
430
+
431
+ # One masked structured line through the client's existing logger — no separate
432
+ # audit sink. Audit.emit_event scrubs the whole serialized record through
433
+ # Masking.scrub, which is why the raw body is safe to include here (FR-004b)
434
+ # even though it never appears on the result.
435
+ #
436
+ # NEITHER secret value is ever put in the record: that is enforced by not
437
+ # writing them, since Masking.scrub detects card numbers, not secrets.
438
+ def audit(outcome, actor:, raw_body:, result: nil, reason: nil,
439
+ transaction_id: nil, claimed: nil, advisory: nil)
440
+ subject = {
441
+ transaction_id: result ? result.transaction_id : transaction_id,
442
+ claimed_status: result ? result.claimed_status : claimed,
443
+ event_type: result&.event_type,
444
+ disagreed: result ? result.disagreed? : nil,
445
+ rechecked: result ? result.rechecked? : nil,
446
+ advisory_http_status: result ? result.advisory_http_status : advisory,
447
+ reason: reason,
448
+ body: audit_safe_body(raw_body)
449
+ }
450
+
451
+ Audit.emit_event(
452
+ client, action: :webhook_status_change, actor: actor, outcome: outcome, subject: subject
453
+ )
454
+ end
455
+
456
+ # --- small shared helpers ---------------------------------------------
457
+
458
+ # Inbound card-data screening (FR-011/FR-011a). Note what this does NOT do:
459
+ # a PAN in the PAYLOAD does not reject the delivery. That deliberately diverges
460
+ # from the library's treatment of caller-supplied input (Transactions,
461
+ # Customers), where a PAN raises. The screen matches any Luhn-valid 13-19 digit
462
+ # run, so on an inbound payload it can fire on a coincidental merchant
463
+ # reference — and rejecting would advise a permanent status and discard a
464
+ # genuine payment confirmation for good. Masking already satisfies FR-011 in
465
+ # full, so refusal would buy nothing and cost availability. Do not "fix" this.
466
+ #
467
+ # An +actor+ IS caller-supplied, so it follows the outbound rule and raises.
468
+ def screen_actor!(actor)
469
+ return unless actor.is_a?(String)
470
+ return unless Masking.looks_like_pan?(actor)
471
+
472
+ error = ValidationError.new("actor must not contain card data", field: :actor)
473
+ error.advisory_http_status = ADVISORY_UNEXPECTED
474
+ raise error
475
+ end
476
+
477
+ # The raw body belongs in the audit record (FR-004b/FR-015), and an
478
+ # invalid-UTF-8 body is exactly the kind that most needs recording — so the
479
+ # bytes are made serializable before JSON.generate sees them rather than
480
+ # letting a malformed delivery take down its own audit trail. Masking.scrub
481
+ # then removes card-like data, as it does for every other field.
482
+ def audit_safe_body(raw_body)
483
+ raw_body.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?")
484
+ end
485
+
486
+ def deep_symbolize(value)
487
+ case value
488
+ when Hash
489
+ value.each_with_object({}) { |(key, nested), out| out[key.to_sym] = deep_symbolize(nested) }
490
+ when Array
491
+ value.map { |nested| deep_symbolize(nested) }
492
+ else
493
+ value
494
+ end
495
+ end
496
+
497
+ def blank?(value)
498
+ value.nil? || (value.is_a?(String) && value.strip.empty?)
499
+ end
500
+ end
501
+ end
data/lib/bml_connect.rb CHANGED
@@ -1,12 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "bml_connect/version"
4
+ require "bml_connect/errors"
5
+ require "bml_connect/masking"
6
+ require "bml_connect/audit"
7
+ require "bml_connect/resource"
4
8
  require_relative "bml_connect/client"
5
9
  require "bml_connect/crypt"
6
10
  require "bml_connect/models"
7
11
  require "bml_connect/transactions"
12
+ require "bml_connect/customers"
13
+ require "bml_connect/tokens"
14
+ require "bml_connect/webhooks"
15
+ require "bml_connect/webhooks/router"
8
16
 
9
17
  module BMLConnect
10
- class Error < StandardError; end
11
- # Your code goes here...
18
+ # Base error is defined in bml_connect/errors.rb (required above).
12
19
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bml_connect
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - oxiqa
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-09-16 00:00:00.000000000 Z
11
+ date: 2026-10-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -52,6 +52,34 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: webmock
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.14'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.14'
69
+ - !ruby/object:Gem::Dependency
70
+ name: dotenv
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.8'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.8'
55
83
  - !ruby/object:Gem::Dependency
56
84
  name: faraday
57
85
  requirement: !ruby/object:Gem::Requirement
@@ -101,28 +129,32 @@ executables: []
101
129
  extensions: []
102
130
  extra_rdoc_files: []
103
131
  files:
104
- - ".github/workflows/main.yml"
105
- - ".gitignore"
106
- - ".rspec"
107
- - ".rubocop.yml"
108
132
  - CHANGELOG.md
109
- - CODE_OF_CONDUCT.md
110
- - Gemfile
111
- - Gemfile.lock
112
133
  - LICENSE.txt
113
134
  - README.md
114
- - Rakefile
115
- - bin/console
116
- - bin/setup
117
- - bml_connect.gemspec
118
135
  - lib/bml_connect.rb
136
+ - lib/bml_connect/audit.rb
119
137
  - lib/bml_connect/client.rb
120
138
  - lib/bml_connect/crypt.rb
121
139
  - lib/bml_connect/crypt/signature.rb
140
+ - lib/bml_connect/customers.rb
141
+ - lib/bml_connect/errors.rb
142
+ - lib/bml_connect/masking.rb
122
143
  - lib/bml_connect/models.rb
144
+ - lib/bml_connect/models/customer.rb
145
+ - lib/bml_connect/models/customer_list.rb
146
+ - lib/bml_connect/models/status_change_result.rb
147
+ - lib/bml_connect/models/token.rb
148
+ - lib/bml_connect/models/token_list.rb
149
+ - lib/bml_connect/models/tokenization_details.rb
123
150
  - lib/bml_connect/models/transaction.rb
151
+ - lib/bml_connect/models/transaction_record.rb
152
+ - lib/bml_connect/resource.rb
153
+ - lib/bml_connect/tokens.rb
124
154
  - lib/bml_connect/transactions.rb
125
155
  - lib/bml_connect/version.rb
156
+ - lib/bml_connect/webhooks.rb
157
+ - lib/bml_connect/webhooks/router.rb
126
158
  homepage: https://github.com/oxiqa/bml-connect-ruby
127
159
  licenses:
128
160
  - MIT
@@ -131,7 +163,7 @@ metadata:
131
163
  homepage_uri: https://github.com/oxiqa/bml-connect-ruby
132
164
  source_code_uri: https://github.com/oxiqa/bml-connect-ruby
133
165
  changelog_uri: https://github.com/oxiqa/bml-connect-ruby/CHANGELOG.md
134
- post_install_message:
166
+ post_install_message:
135
167
  rdoc_options: []
136
168
  require_paths:
137
169
  - lib
@@ -146,8 +178,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
146
178
  - !ruby/object:Gem::Version
147
179
  version: '0'
148
180
  requirements: []
149
- rubygems_version: 3.2.21
150
- signing_key:
181
+ rubygems_version: 3.1.6
182
+ signing_key:
151
183
  specification_version: 4
152
184
  summary: Ruby gem for the Bank of Maldives Connect API
153
185
  test_files: []
@@ -1,16 +0,0 @@
1
- name: Ruby
2
-
3
- on: [push,pull_request]
4
-
5
- jobs:
6
- build:
7
- runs-on: ubuntu-latest
8
- steps:
9
- - uses: actions/checkout@v2
10
- - name: Set up Ruby
11
- uses: ruby/setup-ruby@v1
12
- with:
13
- ruby-version: 3.0.2
14
- bundler-cache: true
15
- - name: Run the default task
16
- run: bundle exec rake
data/.gitignore DELETED
@@ -1,11 +0,0 @@
1
- /.bundle/
2
- /.yardoc
3
- /_yardoc/
4
- /coverage/
5
- /doc/
6
- /pkg/
7
- /spec/reports/
8
- /tmp/
9
-
10
- # rspec failure tracking
11
- .rspec_status
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper
data/.rubocop.yml DELETED
@@ -1,13 +0,0 @@
1
- AllCops:
2
- TargetRubyVersion: 2.4
3
-
4
- Style/StringLiterals:
5
- Enabled: true
6
- EnforcedStyle: double_quotes
7
-
8
- Style/StringLiteralsInInterpolation:
9
- Enabled: true
10
- EnforcedStyle: double_quotes
11
-
12
- Layout/LineLength:
13
- Max: 120