bml_connect 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
- raise ArgumentError.new("Missing Required Fields: [#{REQUIRED_FIELDS.join(', ')}]") unless REQUIRED_FIELDS.subset?(fields.keys.to_set)
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
@@ -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