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,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BMLConnect
|
|
4
|
+
module Models
|
|
5
|
+
# What BMLConnect::Webhooks#handle returns: the authoritative outcome of one
|
|
6
|
+
# inbound transaction-status notification (feature 005, FR-004).
|
|
7
|
+
#
|
|
8
|
+
# The important thing about this class is what it does NOT expose. There is no
|
|
9
|
+
# +payload+, +body+, +headers+ or +raw+ reader, and there never should be
|
|
10
|
+
# (FR-004a, SC-008i). A payload-shaped object here would invite
|
|
11
|
+
# <tt>result.payload[:state]</tt> — a status read from an unauthenticated
|
|
12
|
+
# source — which defeats the entire point of verify-by-fetch. The missing
|
|
13
|
+
# accessor IS the control; a test asserts it stays missing.
|
|
14
|
+
#
|
|
15
|
+
# Payload-derived information is limited to individually named fields: the
|
|
16
|
+
# masked +claimed_status+, the +disagreed?+ flag, and the masked
|
|
17
|
+
# +event_type+. None of them is ever the reported status. The raw body
|
|
18
|
+
# remains available for diagnosis where forensic detail belongs — in the
|
|
19
|
+
# masked audit record (FR-004b).
|
|
20
|
+
class StatusChangeResult
|
|
21
|
+
# Whitelisted view, in a stable order.
|
|
22
|
+
ATTRIBUTES = %i[
|
|
23
|
+
transaction_id status changed_at claimed_status event_type
|
|
24
|
+
disagreed rechecked advisory_http_status transaction
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# The transaction identifier read from the payload (FR-002) — the one field
|
|
28
|
+
# taken from the untrusted delivery and acted on.
|
|
29
|
+
attr_reader :transaction_id
|
|
30
|
+
|
|
31
|
+
# The authoritative record retrieved from BML (feature 003's model).
|
|
32
|
+
attr_reader :transaction
|
|
33
|
+
|
|
34
|
+
# What the payload claimed, masked. DIAGNOSTIC ONLY: never act on it.
|
|
35
|
+
attr_reader :claimed_status
|
|
36
|
+
|
|
37
|
+
# Which category of notification the payload says this is, masked — BML's
|
|
38
|
+
# portal registers a hook per category ("Transaction change",
|
|
39
|
+
# "Tokenisation status") and the delivery names one in +eventType+.
|
|
40
|
+
#
|
|
41
|
+
# ROUTING ONLY, and the distinction matters: a category selects WHICH
|
|
42
|
+
# handler runs, never WHAT it believes. A forged category can therefore
|
|
43
|
+
# only send a delivery to the wrong handler, where the authoritative
|
|
44
|
+
# record still has to bear it out. A handler that acted on this field as
|
|
45
|
+
# though it were a fact would reintroduce exactly the trust this class
|
|
46
|
+
# exists to refuse.
|
|
47
|
+
attr_reader :event_type
|
|
48
|
+
|
|
49
|
+
# What the host application SHOULD return to BML. Advice, not control
|
|
50
|
+
# (FR-016b).
|
|
51
|
+
attr_reader :advisory_http_status
|
|
52
|
+
|
|
53
|
+
def initialize(transaction_id:, transaction:, claimed_status: nil, event_type: nil,
|
|
54
|
+
disagreed: false, rechecked: false, advisory_http_status: 200)
|
|
55
|
+
@transaction_id = transaction_id
|
|
56
|
+
@transaction = transaction
|
|
57
|
+
@claimed_status = claimed_status.nil? ? nil : Masking.scrub(claimed_status.to_s)
|
|
58
|
+
@event_type = event_type.nil? ? nil : Masking.scrub(event_type.to_s)
|
|
59
|
+
@disagreed = disagreed ? true : false
|
|
60
|
+
@rechecked = rechecked ? true : false
|
|
61
|
+
@advisory_http_status = advisory_http_status
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The authoritative status, verbatim (FR-005). Treat it as
|
|
65
|
+
# current-as-of-retrieve, not as final.
|
|
66
|
+
def status
|
|
67
|
+
transaction&.state
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# When the authoritative record last changed (FR-004).
|
|
71
|
+
def changed_at
|
|
72
|
+
transaction&.updated
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# True when the payload's claim disagreed with the authoritative status.
|
|
76
|
+
# The signature of a forgery attempt or a stale replay — worth recording,
|
|
77
|
+
# never worth reporting as a status.
|
|
78
|
+
def disagreed?
|
|
79
|
+
@disagreed
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# True when the single delayed re-check ran (FR-010).
|
|
83
|
+
def rechecked?
|
|
84
|
+
@rechecked
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_h
|
|
88
|
+
{
|
|
89
|
+
transaction_id: transaction_id,
|
|
90
|
+
status: status,
|
|
91
|
+
changed_at: changed_at,
|
|
92
|
+
claimed_status: claimed_status,
|
|
93
|
+
event_type: event_type,
|
|
94
|
+
disagreed: disagreed?,
|
|
95
|
+
rechecked: rechecked?,
|
|
96
|
+
advisory_http_status: advisory_http_status,
|
|
97
|
+
transaction: transaction&.to_h
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def ==(other)
|
|
102
|
+
other.is_a?(StatusChangeResult) && to_h == other.to_h
|
|
103
|
+
end
|
|
104
|
+
alias eql? ==
|
|
105
|
+
|
|
106
|
+
def hash
|
|
107
|
+
to_h.hash
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Limited to the whitelist, so a result is always safe to log — there is no
|
|
111
|
+
# raw body on it to leak.
|
|
112
|
+
def inspect
|
|
113
|
+
summary = {
|
|
114
|
+
transaction_id: transaction_id,
|
|
115
|
+
status: status,
|
|
116
|
+
claimed_status: claimed_status,
|
|
117
|
+
event_type: event_type,
|
|
118
|
+
disagreed: disagreed?,
|
|
119
|
+
rechecked: rechecked?,
|
|
120
|
+
advisory_http_status: advisory_http_status
|
|
121
|
+
}
|
|
122
|
+
"#<BMLConnect::Models::StatusChangeResult #{summary.inspect}>"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BMLConnect
|
|
4
|
+
module Models
|
|
5
|
+
# A read-only value object for a BML stored-card token (BML's CustomerTokens
|
|
6
|
+
# schema).
|
|
7
|
+
#
|
|
8
|
+
# Attributes are whitelisted: only the fields below are pulled from a BML
|
|
9
|
+
# response, so an unexpected key — a full card number above all — cannot
|
|
10
|
+
# reach the object, a log line, or an audit record (Constitution I,
|
|
11
|
+
# FR-007/FR-009). The library mirrors BML's own field names verbatim and
|
|
12
|
+
# invents no `last_four`/`scheme` alias (research R7).
|
|
13
|
+
class Token
|
|
14
|
+
ATTRIBUTES = %i[
|
|
15
|
+
id brand provider token tokenType deleted tokenProvider
|
|
16
|
+
tokenAgreementId tokenAgreementType tokenExpiryMonth tokenExpiryYear
|
|
17
|
+
paddedCardNumber customerId companyId created updated
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
ATTRIBUTES.each { |attribute| attr_reader attribute }
|
|
21
|
+
|
|
22
|
+
def initialize(data = {})
|
|
23
|
+
source = data || {}
|
|
24
|
+
ATTRIBUTES.each do |attribute|
|
|
25
|
+
value = source.key?(attribute) ? source[attribute] : source[attribute.to_s]
|
|
26
|
+
instance_variable_set("@#{attribute}", value)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def deleted?
|
|
31
|
+
deleted == true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True for a token backed by a recurring/unscheduled agreement, which BML
|
|
35
|
+
# signals by populating `tokenAgreementId`.
|
|
36
|
+
def recurring?
|
|
37
|
+
!blank?(tokenAgreementId)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h
|
|
41
|
+
ATTRIBUTES.each_with_object({}) do |attribute, hash|
|
|
42
|
+
hash[attribute] = public_send(attribute)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ==(other)
|
|
47
|
+
other.is_a?(Token) && to_h == other.to_h
|
|
48
|
+
end
|
|
49
|
+
alias eql? ==
|
|
50
|
+
|
|
51
|
+
def hash
|
|
52
|
+
to_h.hash
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Limited to the whitelist, so a token is always safe to log.
|
|
56
|
+
def inspect
|
|
57
|
+
present = to_h.reject { |_, value| value.nil? }
|
|
58
|
+
"#<BMLConnect::Models::Token #{present.inspect}>"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def blank?(value)
|
|
64
|
+
value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BMLConnect
|
|
4
|
+
module Models
|
|
5
|
+
# A customer's stored-card tokens, exposed as an Enumerable over Token
|
|
6
|
+
# value objects.
|
|
7
|
+
#
|
|
8
|
+
# The list response shape is [UNVERIFIED] (research R4): the published
|
|
9
|
+
# contract attaches the token array to the operation's `requestBody` rather
|
|
10
|
+
# than to a response, so BML may send either a bare JSON array or a
|
|
11
|
+
# `{ count, items }` envelope (the shape /public-customers uses). This parser
|
|
12
|
+
# tolerates both and never raises on a surprising top-level type.
|
|
13
|
+
class TokenList
|
|
14
|
+
include Enumerable
|
|
15
|
+
|
|
16
|
+
attr_reader :count, :items
|
|
17
|
+
|
|
18
|
+
def initialize(data = {})
|
|
19
|
+
raw_items = extract_items(data)
|
|
20
|
+
@items = raw_items.map { |item| Token.new(item) }
|
|
21
|
+
@count = coerce_count(count_hint(data))
|
|
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
|
+
# The subset that has not been (soft-)deleted.
|
|
37
|
+
def active
|
|
38
|
+
items.reject(&:deleted?)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def extract_items(data)
|
|
44
|
+
return data if data.is_a?(Array)
|
|
45
|
+
return [] unless data.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
data[:items] || data["items"] || []
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def count_hint(data)
|
|
51
|
+
return data.size if data.is_a?(Array)
|
|
52
|
+
return nil unless data.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
data[:count] || data["count"]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def coerce_count(raw)
|
|
58
|
+
Integer(raw)
|
|
59
|
+
rescue ArgumentError, TypeError
|
|
60
|
+
items.size
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module BMLConnect
|
|
6
|
+
module Models
|
|
7
|
+
# Input-only value object for the `tokenizationDetails` sub-object on a v2
|
|
8
|
+
# create body. Never returned by BML; its presence is what instructs BML to
|
|
9
|
+
# store the card (FR-003). Constructing one validates it locally — every
|
|
10
|
+
# rule below runs before any remote call is made.
|
|
11
|
+
#
|
|
12
|
+
# The allowed `paymentType` and `recurringFrequency` values come from BML's
|
|
13
|
+
# prose descriptions, not a JSON enum (the document declares none), so an
|
|
14
|
+
# unrecognized value is rejected with a message quoting the documented set.
|
|
15
|
+
class TokenizationDetails
|
|
16
|
+
PAYMENT_TYPES = %w[RECURRING UNSCHEDULED].freeze
|
|
17
|
+
RECURRING_FREQUENCIES = %w[
|
|
18
|
+
DAILY WEEKLY BIWEEKLY FORTNIGHTLY MONTHLY QUARTERLY YEARLY AD_HOC
|
|
19
|
+
].freeze
|
|
20
|
+
EXPIRY_FORMAT = /\A\d{4}-\d{2}-\d{2}\z/.freeze
|
|
21
|
+
|
|
22
|
+
# The documented keys, in serialization order. Readers are defined and
|
|
23
|
+
# instance variables set by iterating this constant (as Token/Customer do),
|
|
24
|
+
# so rubocop never sees the camelCase names as literals.
|
|
25
|
+
FIELDS = %i[tokenize paymentType recurringFrequency expiryDate].freeze
|
|
26
|
+
|
|
27
|
+
FIELDS.each { |field| attr_reader field }
|
|
28
|
+
|
|
29
|
+
# +today+ is injectable so the future-date rule is testable without
|
|
30
|
+
# stubbing the clock.
|
|
31
|
+
def initialize(fields, today: nil)
|
|
32
|
+
@today = today
|
|
33
|
+
source = symbolize(fields)
|
|
34
|
+
FIELDS.each { |field| instance_variable_set("@#{field}", source[field]) }
|
|
35
|
+
validate!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Serializes to exactly the documented keys, omitting any not supplied.
|
|
39
|
+
def to_h
|
|
40
|
+
FIELDS.each_with_object({}) { |field, hash| hash[field] = public_send(field) }
|
|
41
|
+
.reject { |_, value| value.nil? }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def validate!
|
|
47
|
+
validate_tokenize!
|
|
48
|
+
validate_payment_type!
|
|
49
|
+
require_recurring_fields! if paymentType == "RECURRING"
|
|
50
|
+
|
|
51
|
+
# FR-004: whenever expiryDate is supplied (RECURRING or not), it must be a
|
|
52
|
+
# future date in yyyy-mm-dd form.
|
|
53
|
+
validate_expiry! unless blank?(expiryDate)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_tokenize!
|
|
57
|
+
raise ValidationError.new("tokenize is required in tokenizationDetails", field: :tokenize) if tokenize.nil?
|
|
58
|
+
return if [true, false].include?(tokenize)
|
|
59
|
+
|
|
60
|
+
raise ValidationError.new("tokenize must be true or false", field: :tokenize)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate_payment_type!
|
|
64
|
+
if blank?(paymentType)
|
|
65
|
+
raise ValidationError.new("paymentType is required in tokenizationDetails", field: :paymentType)
|
|
66
|
+
end
|
|
67
|
+
return if PAYMENT_TYPES.include?(paymentType)
|
|
68
|
+
|
|
69
|
+
raise ValidationError.new("paymentType must be one of #{PAYMENT_TYPES.join(", ")}", field: :paymentType)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def require_recurring_fields!
|
|
73
|
+
if blank?(recurringFrequency)
|
|
74
|
+
raise ValidationError.new("recurringFrequency is required when paymentType is RECURRING",
|
|
75
|
+
field: :recurringFrequency)
|
|
76
|
+
end
|
|
77
|
+
unless RECURRING_FREQUENCIES.include?(recurringFrequency)
|
|
78
|
+
raise ValidationError.new("recurringFrequency must be one of #{RECURRING_FREQUENCIES.join(", ")}",
|
|
79
|
+
field: :recurringFrequency)
|
|
80
|
+
end
|
|
81
|
+
return unless blank?(expiryDate)
|
|
82
|
+
|
|
83
|
+
raise ValidationError.new("expiryDate is required when paymentType is RECURRING", field: :expiryDate)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_expiry!
|
|
87
|
+
unless expiryDate.is_a?(String) && expiryDate =~ EXPIRY_FORMAT
|
|
88
|
+
raise ValidationError.new("expiryDate must be in yyyy-mm-dd form", field: :expiryDate)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
parsed =
|
|
92
|
+
begin
|
|
93
|
+
Date.iso8601(expiryDate)
|
|
94
|
+
rescue ArgumentError
|
|
95
|
+
raise ValidationError.new("expiryDate is not a valid date", field: :expiryDate)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
reference = @today || Date.today
|
|
99
|
+
raise ValidationError.new("expiryDate must be in the future", field: :expiryDate) unless parsed > reference
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def symbolize(fields)
|
|
103
|
+
unless fields.respond_to?(:each_pair)
|
|
104
|
+
raise ValidationError.new("tokenizationDetails must be an object", field: :tokenizationDetails)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
fields.transform_keys(&:to_sym)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def blank?(value)
|
|
111
|
+
value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -14,7 +14,12 @@ module BMLConnect
|
|
|
14
14
|
|
|
15
15
|
def initialize(fields)
|
|
16
16
|
# check for required fields
|
|
17
|
-
|
|
17
|
+
# `REQUIRED_FIELDS.to_a.join` — NOT `REQUIRED_FIELDS.join`: Set#join was
|
|
18
|
+
# added in Ruby 3.0 but the gemspec floor is 2.3, so on Ruby 2.7 (which
|
|
19
|
+
# msgowl/website runs) a bare Set#join raised NoMethodError instead of the
|
|
20
|
+
# intended ArgumentError. Bug fix, not a behavior change — it makes this
|
|
21
|
+
# path do what spec/transaction_spec.rb already asserts (research R11).
|
|
22
|
+
raise ArgumentError.new("Missing Required Fields: [#{REQUIRED_FIELDS.to_a.join(', ')}]") unless REQUIRED_FIELDS.subset?(fields.keys.to_set)
|
|
18
23
|
|
|
19
24
|
#initialize transaction props
|
|
20
25
|
fields.each { |k,v| public_send("#{k}=", v) }
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BMLConnect
|
|
4
|
+
module Models
|
|
5
|
+
# A read-only value object for a BML transaction (the v2 create/retrieve
|
|
6
|
+
# response). Named +TransactionRecord+, not +Transaction+, because the latter
|
|
7
|
+
# is already the v1 *request* builder that `transactions.create` depends on
|
|
8
|
+
# (research R9); renaming it would break a released method.
|
|
9
|
+
#
|
|
10
|
+
# Attributes are whitelisted: only the fields below are pulled from a BML
|
|
11
|
+
# response, so an unexpected key — a card number above all — cannot reach the
|
|
12
|
+
# object, a log line, or an audit record (Constitution I, FR-014).
|
|
13
|
+
class TransactionRecord
|
|
14
|
+
ATTRIBUTES = %i[
|
|
15
|
+
id created updated amount currency payCurrency merchantId
|
|
16
|
+
provider providerHistory state accountingState qr securityWord
|
|
17
|
+
canRefundIfConfirmed externalImport externalId localId paymentToken
|
|
18
|
+
history appVersion apiVersion redirectUrl costStructure
|
|
19
|
+
providerDisplayName paddedCardNumber customerId
|
|
20
|
+
customerReference localData pnr tokenizationDetails
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# Fields that may carry the hosted payment link, in resolution order. `url`
|
|
24
|
+
# is the v1 field, absent from the v2 schema ([UNVERIFIED] #1); `redirectUrl`
|
|
25
|
+
# and `qr.url` are the documented v2 fields. `url` is deliberately NOT in
|
|
26
|
+
# ATTRIBUTES: the hosted payment page is a completion secret (FR-015), so it
|
|
27
|
+
# is never whitelisted, never in #to_h, and never in #inspect. It is
|
|
28
|
+
# reachable only through the explicit #payment_url accessor.
|
|
29
|
+
PAYMENT_URL_KEYS = %i[url redirectUrl].freeze
|
|
30
|
+
|
|
31
|
+
# Not whitelisted, for the same reason `url` is not: it completes a
|
|
32
|
+
# payment. Reachable only through #next_action.
|
|
33
|
+
CHALLENGE_KEY = :nextAction
|
|
34
|
+
|
|
35
|
+
# Fields in which BML reports what it DID about tokenization, as opposed
|
|
36
|
+
# to +tokenizationDetails+, which is the request WE sent and which BML
|
|
37
|
+
# echoes back — an echo says only that we asked. `tokenize` and
|
|
38
|
+
# `tokenizationMethod` are the documented Transaction fields; the rest
|
|
39
|
+
# were observed on live UAT notifications (2026-10-21) and appear in no
|
|
40
|
+
# published schema.
|
|
41
|
+
#
|
|
42
|
+
# Whitelisted separately and reachable only through #tokenization_report:
|
|
43
|
+
# they exist to be logged while diagnosing a card that was not stored, so
|
|
44
|
+
# they do not belong in #to_h or #inspect, and keeping them a whitelist
|
|
45
|
+
# preserves the rule that no unexpected key can reach a log line.
|
|
46
|
+
TOKENIZATION_FIELDS = %i[
|
|
47
|
+
tokenize tokenizationMethod tokenisationStatus tokenizationStatus
|
|
48
|
+
token clientSideTokenId initiator
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
ATTRIBUTES.each { |attribute| attr_reader attribute }
|
|
52
|
+
|
|
53
|
+
def initialize(data = {})
|
|
54
|
+
@source = normalize(data || {})
|
|
55
|
+
ATTRIBUTES.each do |attribute|
|
|
56
|
+
instance_variable_set("@#{attribute}", @source[attribute])
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The hosted payment URL to send the cardholder to. Reads the first present
|
|
61
|
+
# of `url`, `redirectUrl`, `qr.url`. RAISES rather than returning nil when
|
|
62
|
+
# none is present: a nil here would surface as a blank redirect in checkout,
|
|
63
|
+
# a silent failure in a money path (research R7). Resolving which field is
|
|
64
|
+
# authoritative on v2 is [UNVERIFIED] #1 (T021).
|
|
65
|
+
def payment_url
|
|
66
|
+
candidate = PAYMENT_URL_KEYS.map { |key| @source[key] }.find { |value| !blank?(value) }
|
|
67
|
+
candidate = qr_url if blank?(candidate)
|
|
68
|
+
|
|
69
|
+
if blank?(candidate)
|
|
70
|
+
raise UnverifiedFieldError,
|
|
71
|
+
"no hosted payment URL present in the transaction response " \
|
|
72
|
+
"(see contracts/bml-remote.md [UNVERIFIED] #1)"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
candidate
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# BML's Transaction schema: "Returns a URL when 3DS authentication is
|
|
79
|
+
# required; this URL must be opened to complete the 3DS challenge."
|
|
80
|
+
def next_action
|
|
81
|
+
presence(@source[CHALLENGE_KEY])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Whether the gateway kept the card. It echoes `tokenizationDetails` back
|
|
85
|
+
# with the outcome, which is the only field that says so.
|
|
86
|
+
#
|
|
87
|
+
# NOT `paymentToken`: that is the acquirer's authorisation code — the
|
|
88
|
+
# trailing six digits of `externalId` — and is present on any authorised
|
|
89
|
+
# payment, tokenized or not (observed in UAT 2026-10-21).
|
|
90
|
+
def tokenized?
|
|
91
|
+
tokenization_detail("tokenize") == true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The token this transaction created, as listed under the customer.
|
|
95
|
+
# `tokenId` and `tokenObjectId` carry the same value; both are read so a
|
|
96
|
+
# response that drops one still resolves.
|
|
97
|
+
def stored_token_id
|
|
98
|
+
return nil unless tokenized?
|
|
99
|
+
|
|
100
|
+
id = tokenization_detail("tokenId")
|
|
101
|
+
blank?(id) ? presence(tokenization_detail("tokenObjectId")) : id
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# What the gateway said about tokenization, for a log line. Present keys
|
|
105
|
+
# only: an empty hash means BML reported nothing at all about the card,
|
|
106
|
+
# which is itself the answer when a tokenizing transaction stored none.
|
|
107
|
+
#
|
|
108
|
+
# Every string is scrubbed, because these fields are undocumented and a
|
|
109
|
+
# diagnostic must not be the one place a card number reaches a log.
|
|
110
|
+
def tokenization_report
|
|
111
|
+
report = TOKENIZATION_FIELDS.each_with_object({}) do |key, hash|
|
|
112
|
+
value = @source[key]
|
|
113
|
+
hash[key] = value.is_a?(String) ? Masking.scrub(value) : value unless blank?(value)
|
|
114
|
+
end
|
|
115
|
+
report[:tokenizationDetails] = scrub_details if tokenizationDetails.respond_to?(:each_pair)
|
|
116
|
+
report
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Whitelisted view. The hosted payment `url` is never included (FR-015).
|
|
120
|
+
def to_h
|
|
121
|
+
ATTRIBUTES.each_with_object({}) do |attribute, hash|
|
|
122
|
+
hash[attribute] = public_send(attribute)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def ==(other)
|
|
127
|
+
other.is_a?(TransactionRecord) && to_h == other.to_h
|
|
128
|
+
end
|
|
129
|
+
alias eql? ==
|
|
130
|
+
|
|
131
|
+
def hash
|
|
132
|
+
to_h.hash
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Limited to the whitelist, so a record is always safe to log — the hosted
|
|
136
|
+
# payment `url` never appears here.
|
|
137
|
+
def inspect
|
|
138
|
+
present = to_h.reject { |_, value| value.nil? }
|
|
139
|
+
"#<BMLConnect::Models::TransactionRecord #{present.inspect}>"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def tokenization_detail(key)
|
|
145
|
+
details = tokenizationDetails
|
|
146
|
+
return nil unless details.respond_to?(:[])
|
|
147
|
+
|
|
148
|
+
details[key].nil? ? details[key.to_sym] : details[key]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def scrub_details
|
|
152
|
+
tokenizationDetails.each_with_object({}) do |(key, value), hash|
|
|
153
|
+
hash[key.to_sym] = value.is_a?(String) ? Masking.scrub(value) : value
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def presence(value)
|
|
158
|
+
blank?(value) ? nil : value
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def qr_url
|
|
162
|
+
return nil unless qr.is_a?(Hash)
|
|
163
|
+
|
|
164
|
+
qr[:url] || qr["url"]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Symbol-first access with a string fallback, over every key we might read
|
|
168
|
+
# (whitelist, plus the payment-URL candidates and the tokenization
|
|
169
|
+
# diagnostics).
|
|
170
|
+
def normalize(source)
|
|
171
|
+
keys = ATTRIBUTES + PAYMENT_URL_KEYS + TOKENIZATION_FIELDS + [CHALLENGE_KEY]
|
|
172
|
+
keys.each_with_object({}) do |key, hash|
|
|
173
|
+
hash[key] = source.key?(key) ? source[key] : source[key.to_s]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def blank?(value)
|
|
178
|
+
value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
data/lib/bml_connect/models.rb
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'bml_connect/models/transaction'
|
|
4
|
+
require 'bml_connect/models/transaction_record'
|
|
5
|
+
require 'bml_connect/models/tokenization_details'
|
|
6
|
+
require 'bml_connect/models/customer'
|
|
7
|
+
require 'bml_connect/models/customer_list'
|
|
8
|
+
require 'bml_connect/models/token'
|
|
9
|
+
require 'bml_connect/models/token_list'
|
|
10
|
+
require "bml_connect/models/status_change_result"
|
|
4
11
|
|
|
5
12
|
module BMLConnect
|
|
6
13
|
module Models
|