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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +186 -2
- data/README.md +384 -7
- data/lib/bml_connect/audit.rb +33 -0
- data/lib/bml_connect/client.rb +64 -2
- data/lib/bml_connect/customers.rb +270 -0
- data/lib/bml_connect/errors.rb +76 -0
- data/lib/bml_connect/masking.rb +55 -0
- data/lib/bml_connect/models/customer.rb +53 -0
- data/lib/bml_connect/models/customer_list.rb +45 -0
- data/lib/bml_connect/models/status_change_result.rb +126 -0
- data/lib/bml_connect/models/token.rb +68 -0
- data/lib/bml_connect/models/token_list.rb +64 -0
- data/lib/bml_connect/models/tokenization_details.rb +115 -0
- data/lib/bml_connect/models/transaction.rb +6 -1
- data/lib/bml_connect/models/transaction_record.rb +182 -0
- data/lib/bml_connect/models.rb +7 -0
- data/lib/bml_connect/resource.rb +145 -0
- data/lib/bml_connect/tokens.rb +99 -0
- data/lib/bml_connect/transactions.rb +290 -6
- data/lib/bml_connect/version.rb +1 -1
- data/lib/bml_connect/webhooks/router.rb +85 -0
- data/lib/bml_connect/webhooks.rb +501 -0
- data/lib/bml_connect.rb +9 -2
- metadata +49 -17
- data/.github/workflows/main.yml +0 -16
- data/.gitignore +0 -11
- data/.rspec +0 -3
- data/.rubocop.yml +0 -13
- data/CODE_OF_CONDUCT.md +0 -84
- data/Gemfile +0 -6
- data/Gemfile.lock +0 -82
- data/Rakefile +0 -12
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/bml_connect.gemspec +0 -43
|
@@ -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
|
-
|
|
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.
|
|
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:
|
|
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.
|
|
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: []
|
data/.github/workflows/main.yml
DELETED
|
@@ -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
data/.rspec
DELETED
data/.rubocop.yml
DELETED