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
data/lib/bml_connect/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|