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