bml_connect 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91517f1cbbf91c13d81d93227136d7a53c5ed19d1668f035675c364c4c67f96f
4
- data.tar.gz: 460a93c1c771a53f56fd7fdbbfa6183031e2862b82cf75e08b273f0ccfe52aee
3
+ metadata.gz: 3188c8c01dd2f98bf493c8e9dfd17e7698b562c9f5e881f151aaa83ff71fdc23
4
+ data.tar.gz: aa04de2dbe02ff410bfa23e6be2ba6bf91fc8b130dada0a6b6fa1ca9ebd06bd5
5
5
  SHA512:
6
- metadata.gz: ef33690f58f716fc30f275ad3e0a3e0ddef3279eb13973a5f2c4fbe2273f6caac4b7fac56136fd320eed7ba497dcbd50349d2f0a64e40780a6ad1f7f23011d8b
7
- data.tar.gz: 8e5914d7c89ea92d8ed589d3b0cf8c3225fc2edacd68f152dbc763f42b3653f8f847a3aac4a212c7535b37843b4536e65f7ee00abe653fb05aec42e58a50ba76
6
+ metadata.gz: c8d7f7cea98ca9a3196fff43a87ef5befdb0f8ca1d009cef04f345ed408c9d48713be6849943007ab6eaf55a11097711ec7116a9d3c9ae5f811474447aea9e38
7
+ data.tar.gz: b295ca6fe627df089aef1f76cc9300d8440285b8c121082eb4825ba8e1e8c62f02db5610213c4f3d19ded7b5616d8c6195c78e322dfe4cafa499c734cf78cf75
data/CHANGELOG.md CHANGED
@@ -1,5 +1,194 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.1.0] - 2021-08-31
3
+ ## [0.3.0] - 2026-10-26
4
+
5
+ Release gates, as of 2026-10-26, all observed in BML UAT through `msgowl/website`:
6
+
7
+ - **Closed — `tokenId` identity.** `token_id` takes `Token#id`. Charges made that way returned
8
+ `CONFIRMED` (2026-10-21 onward; see `docs/website/research/bml-saved-card-adhoc-charge.md`).
9
+ - **Closed — webhook identifier field.** Every delivery carries a top-level `transactionId`, the first
10
+ built-in candidate, for both `NOTIFY_TRANSACTION_CHANGE` and `NOTIFY_TOKENISATION_STATUS`
11
+ (2026-10-21/22). No `extract_id:` override is needed.
12
+ - **Closed — live token and charge observation.** Customers, token listing, tokenizing transactions,
13
+ stored-card charges and webhook verification have all run end to end in UAT.
14
+ - **Works, key not pinned — v2 payment URL.** Hosted-page redirects from `create_v2` work in UAT
15
+ through the `url` / `redirectUrl` / `qr.url` fallback, but which of them BML populates has not been
16
+ recorded.
17
+ - **Still unobserved** — a declined charge, a charge returning `nextAction`, and a charge whose outcome
18
+ never arrives. These are handled against the published contract only.
19
+
20
+ ### Added
21
+ - **Webhook dispatch** (`BMLConnect::Webhooks::Router`, and a `router:` keyword on
22
+ `client.webhooks.handle`). A merchant registers one hook in BML's portal, so a single endpoint
23
+ receives notifications for every transaction the merchant creates. The router sends a **verified**
24
+ result to the handler the consumer's own resolver names — the gem stays ignorant of what those
25
+ names mean, because the payload cannot say which of a consumer's flows owns a transaction and
26
+ only the consumer's records can. Registering two handlers under one name, naming an unregistered
27
+ handler, or receiving a transaction nothing owns with no fallback all raise rather than passing
28
+ silently. `router:` chooses where a verified result goes; it cannot affect whether verification
29
+ happened, which `spec/unit/webhooks_surface_spec.rb` continues to guard.
30
+ - **Transaction-status webhook handling** (`client.webhooks.handle`), implementing feature
31
+ `005-webhook-handler`. Accepts a raw inbound notification — body, headers, and optionally a
32
+ caller-presented shared secret — and returns a `BMLConnect::Models::StatusChangeResult`. The
33
+ published contract documents the callback **nowhere** (no payload schema, no event list, no
34
+ signature or authentication), so the entire inbound surface is `[UNVERIFIED]` and the design is
35
+ built to need almost nothing from it. Highlights:
36
+ - **Verify-by-fetch, with no opt-out.** The payload is an untrusted hint that something changed;
37
+ the reported status always comes from a fresh `GET /public/transactions/{id}`. A forged "payment
38
+ confirmed" can at worst cost one wasted retrieve. No flag, option, or mode skips the retrieve —
39
+ test-enforced against the public surface, not merely defaulted.
40
+ - **No web-framework dependency and no new gem dependency at all.** Parsing uses stdlib `json` and
41
+ `uri`; the constant-time secret comparison uses stdlib `digest`. The library owns no route and
42
+ never writes a response.
43
+ - **At most two retrieves per notification, neither auto-retried.** The verify, plus at most one
44
+ delayed re-check when the payload's claimed status disagrees with the authoritative one
45
+ (default delay 3s, `0` disables it — the delay is spent inside the caller's request).
46
+ - **Advisory HTTP status on every result and error** (`advisory_http_status`), distinguishing
47
+ permanent outcomes from transient ones so a host application knows when redelivery helps.
48
+ `404` is never advised, even for a missing transaction: it reads as "no such endpoint" and risks
49
+ the hook being disabled.
50
+ - **Optional shared secret that gates spend, not trust.** When configured, a delivery presenting
51
+ no value or a wrong one is rejected with **zero** retrieves; a match never causes the payload to
52
+ be believed. The caller extracts the value (BML controls the callback's headers, so in practice
53
+ it rides in the registered URL) — the library never infers a location.
54
+ - **No raw payload on the result.** Payload-derived data is limited to a masked `claimed_status`
55
+ and a `disagreed?` flag; the raw body reaches the masked audit record only.
56
+ - **Audits every outcome, including rejections** — a refused delivery is the signature of a forgery
57
+ attempt, and an operator needs to see it.
58
+ - **No state enumeration, no deduplication, no body-size limit** — the published contract defines
59
+ no states, the gem owns no datastore, and it does not own the socket.
60
+ - **Identifier field observed**: UAT deliveries carry a top-level `transactionId`, the first
61
+ built-in candidate. A wrong guess still fails loudly rather than reporting a false status, and
62
+ `extract_id:` still overrides the list outright.
63
+ - `BMLConnect::WebhookRejectedError` — raised when an inbound notification fails the optional
64
+ shared-secret check. A distinct class so a security rejection stays observably separate from a
65
+ malformed body.
66
+
67
+ ### Changed
68
+ - `BMLConnect::Error` now carries an `advisory_http_status` accessor (nil unless set by the webhook
69
+ path). No existing error class or raise site changed behavior.
70
+ - `Transactions#retrieve` accepts an optional `retries:` keyword (default `true`, preserving released
71
+ behavior). The webhook handler passes `false`: the two-retrieve cap counts HTTP calls, so with
72
+ retry left on, one notification arriving while BML was flaky could have made six requests.
73
+ - BML's own error text is now masked before it becomes an exception message, so a card-like value in
74
+ an upstream error body cannot reach a log or an error message. Applies to every resource.
75
+
76
+ ### Added (earlier)
77
+ - **Stored-card charge** (`client.customers.charge`), implementing feature `004-token-charge` on
78
+ the documented `POST /public-customers/charge` endpoint. Takes `customer_id:`, `transaction_id:`
79
+ and `token_id:` (all required, validated locally by name before any remote call) plus an optional
80
+ `actor:`, and returns a `BMLConnect::Models::TransactionRecord`. Highlights:
81
+ - **Two-call flow, kept two calls.** No combined create-and-charge method exists (test-enforced);
82
+ the transaction is created first (`create_v2`) and supplies the amount — the charge carries no
83
+ `amount` of its own.
84
+ - **Never auto-retried** — the charge schema has no idempotency key, so exactly one HTTP attempt
85
+ is made; a timeout/transport failure raises `AvailabilityError` whose message names the
86
+ `transaction_id` for reconciliation by retrieval.
87
+ - **Returned vs. raised** are never conflated: a returned record (including a decline in a failed
88
+ `state`) is a business outcome; a raised error is a transport/validation outcome.
89
+ - **Audits failures too** (success, decline, validation failure, availability failure), masked and
90
+ carrying the transaction and token ids but never card data.
91
+ - **`token_id` is `Token#id`**, confirmed in UAT: charges against `Token#id` return `CONFIRMED`.
92
+ The opt-in UAT spec (`spec/integration/customers_charge_uat_spec.rb`) still exercises it.
93
+ - **Transactions v2 surface**, implementing feature `003-transactions-v2` on the shared `Resource`
94
+ transport — **additive**, no released method changed. New value-object methods `create_v2`,
95
+ `retrieve`, `update`, `capture` and `cancel` target the documented `/public/v2/transactions` and
96
+ `/public/transactions/{id}` endpoints, return whitelisted `BMLConnect::Models::TransactionRecord`
97
+ objects, and raise the shared typed error hierarchy. Highlights:
98
+ - `create_v2` supports variant 3 (existing `customerId`) and variant 4 (inline `customer`);
99
+ variants 1/2 (shop orders) and 5 (foreign exchange) are rejected locally by name.
100
+ - `tokenizationDetails` (`BMLConnect::Models::TokenizationDetails`) instructs BML to store a card;
101
+ `tokenize`/`paymentType` are required, and `recurringFrequency`/future `expiryDate` are required
102
+ when `paymentType` is `RECURRING` — all validated before any remote call.
103
+ - `amount` (on both `create_v2` and `capture`) must be a **positive Integer in minor units**; a
104
+ Float, String, zero, or negative is rejected by name and never coerced.
105
+ - **No auto-retry on create or capture** (no documented idempotency key); a timeout raises
106
+ `AvailabilityError` after exactly one attempt. Retrieve/list/update/cancel retry with backoff.
107
+ - `create_v2`/`update`/`capture`/`cancel` emit masked audit records; the hosted payment URL is
108
+ treated as a completion secret — omitted from `#to_h`/`#inspect` and never logged or audited.
109
+ `TransactionRecord#payment_url` raises `UnverifiedFieldError` (new) rather than returning `nil`
110
+ when no URL field is present, pending UAT confirmation of which v2 field carries it.
111
+ - `state` is passed through verbatim (never normalized); v2 requests send **no** `signature`
112
+ until UAT settles whether v2 accepts one.
113
+ - **Deprecated** the legacy v1 `transactions.create` (undocumented-but-live `POST
114
+ /public/transactions`). It still works unchanged and returns a `Faraday::Response`, but now logs a
115
+ single, once-per-process deprecation warning pointing at `create_v2`.
116
+ - Fixed a latent Ruby 2.7 bug in `BMLConnect::Models::Transaction`: a missing-fields `create`
117
+ raised `NoMethodError` (`Set#join`, Ruby 3.0+) instead of the intended `ArgumentError` on the 2.7
118
+ runtime `msgowl/website` uses. Bug fix only — makes the code do what its own test already asserts.
119
+ - **Stored card tokens resource** (`client.tokens`), implementing feature `002-stored-card-tokens`
120
+ against BML's `/public-customers/{customerId}/tokens` contract: `list`, `retrieve`, and `delete`
121
+ (soft delete, `204`). Read-and-delete **only** — BML publishes no token-creation endpoint, so the
122
+ resource deliberately exposes no `create`/`tokenize`/`detokenize` method and a test enforces the
123
+ absence (SC-006). Returns whitelisted `BMLConnect::Models::Token` / `TokenList` value objects
124
+ (mirroring BML's field names — no `last_four`/`scheme` alias) and raises the shared typed error
125
+ hierarchy. Only `delete` is audited, once, even across retries; the optional `actor:` is screened
126
+ for card data.
127
+ - Extended the shared transport to retry `429` responses (in addition to `408`/timeouts/`5xx`) with
128
+ bounded backoff on every resource (FR-013a); a surviving `429` still surfaces as `RateLimitError`
129
+ carrying `Retry-After`. Retry knobs are `max_retries` / `retry_backoff` on the client
130
+ (`max_retries: 0` disables).
131
+ - **Customers resource** (`client.customers`), implementing feature `001-customers-endpoints`
132
+ against BML's `/public-customers` contract: `create`, `retrieve`, `list`, partial `update`
133
+ (`PATCH`), and `archive` (soft delete). Returns whitelisted `BMLConnect::Models::Customer` /
134
+ `CustomerList` value objects and raises a typed error hierarchy
135
+ (`ValidationError`/`NotFoundError`/`AuthenticationError`/`ConflictError`/`RateLimitError`/`AvailabilityError`)
136
+ instead of the raw `Faraday::Response` that `transactions` returns.
137
+ - Shared infrastructure consumed by later features `002`/`004`: `BMLConnect::Errors`,
138
+ `Masking` (Luhn-gated PAN detection + log scrubbing), `Audit` (masked structured audit line via
139
+ the client's logger — no separate sink), and a `Resource` transport base (URL derived from the
140
+ client's base URL, status→error mapping, bounded retry with backoff, `Retry-After` on 429).
141
+ - Local safeguards: required-field and lightweight email-shape validation, and PAN/CVV screening
142
+ of **every** caller-supplied string (and the optional `actor`) before any remote call.
143
+ - Test tiers: unit, contract (WebMock, stub URLs derived from `client.base_url`), an
144
+ OpenAPI-conformance test that reads `reference/Connect-API.json` (Constitution II anti-stub
145
+ gate), and an opt-in, credential-gated UAT suite that loads a repo-local `.env` (git-ignored;
146
+ see `.env.example`). Added `webmock` and `dotenv` development dependencies and a `.gitleaks.toml`
147
+ secret-scanning config.
148
+ - Vendored BML's published OpenAPI contract at `reference/Connect-API.json` (Connect API v2.0),
149
+ with `reference/README.md` describing the endpoint inventory, what is out of scope, and the two
150
+ things this gem does that the document does not describe.
151
+ - Spec Kit machinery (`.specify/`, `speckit-feature`) and four feature specifications covering
152
+ the tokenization surface, each with plan, research, data model, tasks, and both an HTTP and a
153
+ Ruby contract:
154
+ - `specs/001-customers-endpoints` — `/public-customers` CRUD + archive
155
+ - `specs/002-stored-card-tokens` — `/public-customers/{customerId}/tokens` list, retrieve, delete
156
+ - `specs/003-transactions-v2` — `/public/v2/transactions`, `tokenizationDetails`, update, capture, cancel
157
+ - `specs/004-token-charge` — `/public-customers/charge`
158
+ - `MIGRATION.md` recording the move of tokenization work into this gem from `bml_tokenization`,
159
+ including the endpoint, authentication, data-model and flow corrections that prompted it.
160
+
161
+ ### Changed
162
+ - Project constitution amended to **2.0.0** (`.specify/memory/constitution.md`). Principle III
163
+ now requires contracts to derive from `reference/Connect-API.json` and be verified against a
164
+ live environment. Principle II gains an explicit gate that a stubbed test is not evidence an
165
+ endpoint exists. Principle V gains a rule against presenting one call where the platform
166
+ requires two.
167
+ - `README.md` gains a roadmap, an explanation of how BML tokenization actually works, and a
168
+ backward-compatibility statement.
169
+
170
+ ### Notes
171
+ - Features `001-customers-endpoints`, `002-stored-card-tokens` and `003-transactions-v2` are now
172
+ **implemented**; `004-token-charge` remains specified, not implemented. `BMLConnect::Client`
173
+ gains memoized `#customers`/`#tokens` plus `#logger`/`#timeout`/`#max_retries`/`#retry_backoff`
174
+ accessors with safe defaults. Feature `003` is additive to the released transactions surface:
175
+ `create`, `get` and `list` keep their 0.2.0 behavior, signature, and `Faraday::Response` return
176
+ type; the v2 value-object methods sit alongside them.
177
+ - Feature `003` is **not production-ready** until UAT confirms which v2 response field carries the
178
+ hosted payment URL (`create_v2` may be called but its `payment_url` will raise until then), and
179
+ until the `msgowl/website` suite is verified to pass unchanged against this gem (release gate).
180
+ - `specs/004-token-charge` is **release-blocked**: it is unconfirmed whether BML's `tokenId`
181
+ field expects a token's `id` or its `token` value. Charging the wrong identifier is a
182
+ money-movement defect.
183
+ - No feature has yet been observed against BML UAT. The available API key is rejected there with
184
+ `PP-C-004` even on the known-working legacy transactions path, so every feature's verification
185
+ table is open pending a newly issued UAT credential.
186
+
187
+ ## [0.2.0] - 2021-09-17
188
+
189
+ ### Changes
190
+ - Downgraded ruby to >=2.3
191
+
192
+ ## [0.1.0] - 2021-09-16
4
193
 
5
194
  - Initial release
data/README.md CHANGED
@@ -3,7 +3,13 @@
3
3
  Ruby gem for the Bank of Maldives Connect API
4
4
 
5
5
  Use this gem to interact with your Bank of Maldives Connect API:
6
- - 💳 __Transactions__
6
+ - 💳 __Transactions__ (v1, and v2 with tokenization)
7
+ - 👤 __Customers__
8
+ - 🔐 __Stored card tokens__
9
+ - 🔁 __Charging a stored card__
10
+ - 📬 __Transaction-status webhooks__
11
+
12
+ See [Roadmap](#roadmap) for what has been verified against BML UAT.
7
13
 
8
14
 
9
15
  ## Installation
@@ -24,6 +30,424 @@ Or install it yourself as:
24
30
 
25
31
  ## Usage
26
32
 
33
+ First get your API key and App ID from Merchant Portal for [`production`](https://dashboard.merchants.bankofmaldives.com.mv) or [`sandbox`](https://dashboard.uat.merchants.bankofmaldives.com.mv).
34
+
35
+ For production client:
36
+ ```ruby
37
+ require 'bml_connect`
38
+
39
+ client = BMLConnect::Client.new(api_key: '<your-api-key>', app_id: '<your-app-id>')
40
+ ```
41
+ For sandbox client:
42
+ ```ruby
43
+ require 'bml_connect`
44
+
45
+ client = BMLConnect::Client.new(api_key: '<your-api-key>', app_id: '<your-app-id>', mode: 'sandbox')
46
+ ```
47
+ In Ruby on Rails, to configure globally, add an intilializer `bml_connect.rb`:
48
+ ```ruby
49
+ module BMLConnect
50
+ class Client
51
+ BML_API_KEY = ENV['BML_MPG_KEY']
52
+ BML_APP_ID = ENV['BML_MPG_APP_ID']
53
+ BML_MODE = ENV['BML_MPG_MODE']
54
+ end
55
+ end
56
+ ````
57
+ ```ruby
58
+ client = BMLConnect::Client.new
59
+ ```
60
+ ### API Operations
61
+
62
+ > **Deprecation.** `transactions.create` posts to the legacy, undocumented-but-live
63
+ > `POST /public/transactions` (signed). It still works and its return type is unchanged, but it is
64
+ > **deprecated** in favour of `create_v2`, which targets BML's documented
65
+ > `POST /public/v2/transactions`. The first call in a process logs a one-time deprecation warning.
66
+
67
+ ```ruby
68
+ # DEPRECATED v1 create — still works, returns a Faraday::Response
69
+ resp = client.transactions.create({
70
+ amount: 10000,
71
+ currency: 'MVR',
72
+ redirectUrl: '<your-redirect-uri>',
73
+ localId: 'local-1',
74
+ customerReference: 'INV-0001'
75
+ })
76
+ resp = client.transactions.get(id) # => Faraday::Response
77
+ resp = client.transactions.list({ page: 2 }) # => Faraday::Response
78
+ ```
79
+
80
+ The v1 responses are [`Faraday::Response`](https://github.com/lostisland/faraday/blob/main/lib/faraday/response.rb) objects, `json`-encoded with symbolized names.
81
+
82
+ #### v2 surface (value objects)
83
+
84
+ The v2 methods target the documented endpoints, return whitelisted `TransactionRecord` value
85
+ objects, and **raise** on failure. `amount` MUST be a **positive Integer in minor units**
86
+ (`10_000` = MVR 100.00); a Float or String is rejected locally, never coerced.
87
+
88
+ ```ruby
89
+ txn = client.transactions.create_v2(
90
+ amount: 10_000, # required — positive Integer, minor units
91
+ currency: "MVR", # required
92
+ redirectUrl: "https://merchant.example.mv/return",
93
+ localId: "INV-0001",
94
+ customerId: "cus_123" # variant 3 (or supply an inline `customer:` — variant 4)
95
+ )
96
+ txn.id
97
+ txn.state # passed through verbatim (e.g. "CONFIRMED")
98
+ txn.payment_url # hosted page to send the cardholder to (never logged/audited)
99
+
100
+ client.transactions.retrieve("txn_…") # => TransactionRecord (`get` still returns raw)
101
+ client.transactions.update("txn_…", customerReference: "…") # partial merge: customerReference/localData/pnr
102
+ client.transactions.capture("txn_…", amount: 10_000) # amount obeys the same rule as create
103
+ client.transactions.cancel("txn_…")
104
+ ```
105
+
106
+ Store a card while taking payment by adding `tokenizationDetails`; the token appears under the
107
+ customer (via `client.tokens.list`) only **after** the cardholder completes the payment:
108
+
109
+ ```ruby
110
+ client.transactions.create_v2(
111
+ amount: 10_000, currency: "MVR", customerId: "cus_123",
112
+ redirectUrl: "https://merchant.example.mv/return",
113
+ tokenizationDetails: {
114
+ tokenize: true,
115
+ paymentType: "RECURRING", # or "UNSCHEDULED"
116
+ recurringFrequency: "MONTHLY", # required when RECURRING
117
+ expiryDate: "2027-01-01" # required when RECURRING; yyyy-mm-dd, future
118
+ }
119
+ )
120
+ ```
121
+
122
+ Create and capture are **never auto-retried** (no documented idempotency key); retrieve, list,
123
+ update and cancel retry with backoff. Which v2 response field carries the hosted payment URL is
124
+ still being verified against UAT — until then, `payment_url` raises `UnverifiedFieldError` rather
125
+ than returning `nil`, so `create_v2` should not be adopted in production yet.
126
+
127
+ ### Customers
128
+
129
+ The customers resource wraps BML's `/public-customers` endpoints. Unlike `transactions` (which
130
+ returns a raw `Faraday::Response`), it returns whitelisted value objects and **raises** on
131
+ failure. Reach it through the same configured client:
132
+
133
+ ```ruby
134
+ customer = client.customers.create(name: "Aisha Ali", email: "aisha@example.mv")
135
+ customer.id # => BML-assigned id, the handle for tokens and charges
136
+ customer.companyId # => platform-set
137
+
138
+ client.customers.retrieve(customer.id) # => Customer
139
+ client.customers.list.each { |c| puts c.email } # Enumerable {count, items} envelope
140
+ client.customers.update(customer.id, billingCity: "Male") # partial merge: only supplied keys sent
141
+ client.customers.archive(customer.id) # => true (soft delete; record keeps deleted: true)
142
+ ```
143
+
144
+ Notes:
145
+
146
+ - **Required on create:** `name` and `email`. Optional documented fields (`billingEmail`,
147
+ `billingAddress1`, `billingCity`, `billingCountry`, `billingPostCode`, `taxId`, …) pass through
148
+ unchanged. `email`/`billingEmail` get a lightweight shape check (must contain an `@` and a
149
+ domain); BML remains the authority beyond that.
150
+ - **Update is a partial merge** (`PATCH`): only the keys you pass are sent — an unset field is
151
+ never serialized as `null`, so it cannot erase stored data.
152
+ - **No card data.** Every caller-supplied string is screened for a card-number pattern and
153
+ rejected locally before any request; responses are whitelisted so a stray card field can never
154
+ reach an object, a log line, or the audit trail.
155
+ - **Auditing.** `create`, `update` and `archive` emit a masked, structured audit line through the
156
+ client's logger (who / what / when / outcome). Pass an optional `actor:` to attribute the call;
157
+ reads are not audited. Inject your own logger via `Client.new(options: { logger: my_logger })`.
158
+ - **Errors** are a typed hierarchy under `BMLConnect::Error`: `ValidationError` (carries `#field`),
159
+ `NotFoundError`, `AuthenticationError`, `ConflictError`, `RateLimitError` (carries
160
+ `#retry_after`), and `AvailabilityError` (timeouts / 5xx after bounded retries).
161
+
162
+ ### Stored cards (tokens)
163
+
164
+ The tokens resource wraps BML's `/public-customers/{customerId}/tokens` endpoints. It is
165
+ **read-and-delete only** — list, retrieve, delete — because BML publishes no endpoint that
166
+ creates a token. A stored card comes into existence only as a side effect of a tokenizing
167
+ transaction (feature `003`), completed by the cardholder on BML's hosted page; see
168
+ [How tokenization actually works](#how-tokenization-actually-works). Every path is nested under a
169
+ `customerId`.
170
+
171
+ ```ruby
172
+ # List a customer's saved cards
173
+ client.tokens.list("cus_123").each do |t|
174
+ puts "#{t.brand} #{t.paddedCardNumber} exp #{t.tokenExpiryMonth}/#{t.tokenExpiryYear}"
175
+ end
176
+
177
+ client.tokens.retrieve("cus_123", "tok_456") # => Token
178
+ client.tokens.delete("cus_123", "tok_456", actor: "ops:jane") # => true (soft delete)
179
+ ```
180
+
181
+ Notes:
182
+
183
+ - **No create, no detokenize.** There is deliberately no `client.tokens.create`/`tokenize`/
184
+ `detokenize` — the absence is enforced by a test. If you are looking for one, tokens are created
185
+ via feature `003`, not here.
186
+ - **No card data.** No operation accepts a PAN, CVV, expiry, or capture handle. A token is
187
+ represented only by BML's own fields (`token`, `brand`, `paddedCardNumber`, `tokenExpiryMonth`,
188
+ `tokenExpiryYear`, …); the gem invents no `last_four`/`scheme` alias and exposes no full card
189
+ number. Responses are whitelisted, so a stray card field can never reach an object, log, or audit
190
+ record.
191
+ - **Empty vs. broken.** An empty token list is an empty `TokenList`, never an error, and an
192
+ authentication failure is never swallowed into one.
193
+ - **Auditing.** Only `delete` is audited (reads are not); it emits a single masked audit line —
194
+ once, even if the delete is retried. Pass an optional `actor:` (screened for card data) to
195
+ attribute it.
196
+ - **Retries.** Transient failures (`429`, `408`, timeouts, `5xx`) are retried on all three
197
+ operations — delete included, since soft-delete is idempotent — with bounded backoff, then raised
198
+ as `RateLimitError`/`AvailabilityError`. Tune via `Client.new(options: { max_retries: 2,
199
+ retry_backoff: 0.5 })`; set `max_retries: 0` to disable. A `429`'s `Retry-After` is surfaced on
200
+ `RateLimitError#retry_after`.
201
+
202
+ ### Charging a stored card
203
+
204
+ Once a customer has a stored card (a token), you can take a payment from it with no cardholder
205
+ present — a subscription renewal, an account top-up, an ad-hoc charge. It is a **two-call flow**,
206
+ and the gem keeps it that way on purpose: BML has no single endpoint that creates a transaction
207
+ and charges it, so this gem offers none either (a hidden combined call would obscure a money
208
+ movement). The **amount lives on the transaction**, not on the charge.
209
+
210
+ ```ruby
211
+ # 1 ── create the transaction to be charged (the amount lives HERE)
212
+ renewal = client.transactions.create_v2(
213
+ amount: 10_000, currency: "MVR", # MVR 100.00, in minor units
214
+ customerId: "cus_123", localId: "SUB-2026-10"
215
+ )
216
+
217
+ # 2 ── charge the stored card against it
218
+ charged = client.customers.charge(
219
+ customer_id: "cus_123",
220
+ transaction_id: renewal.id,
221
+ token_id: "tok_789", # the stored card's Token#id
222
+ actor: "billing:cron" # optional, for the audit "who"
223
+ )
224
+
225
+ charged.state # resolved synchronously — no cardholder redirect
226
+ ```
227
+
228
+ Notes:
229
+
230
+ - **Returned vs. raised.** A **returned** `TransactionRecord` means BML answered — inspect
231
+ `charged.state` for the business outcome (a decline is a returned record, not an exception). A
232
+ **raised** error means BML did not answer. The gem never converts one into the other, so a
233
+ declined card and a network outage are always distinguishable.
234
+ - **Never auto-retried.** The charge schema carries no idempotency key, so a retry could take
235
+ payment twice. Exactly one HTTP attempt is made. On a timeout, an `AvailabilityError` is raised
236
+ whose message **names the `transaction_id`** — reconcile by retrieving that transaction
237
+ (`client.transactions.retrieve(id)`) rather than re-charging blindly.
238
+ - **No `amount` on the charge.** To bill a different amount, create a different transaction.
239
+ - **Audited, failures included.** Every charge emits a masked audit line — success, decline,
240
+ validation failure, and availability failure alike — carrying the transaction and token ids and
241
+ never any card data.
242
+ - **`token_id` is `Token#id`.** Confirmed in BML UAT: a charge against `Token#id` returns `CONFIRMED`.
243
+
244
+ ### Receiving transaction-status webhooks
245
+
246
+ BML can notify your application when a transaction's status changes. The published contract
247
+ documents how to *register* a hook URL and **nothing at all** about the notification it sends — no
248
+ payload schema, no event list, and no signature, secret, or authentication. So this handler never
249
+ trusts the delivery: it reads one field (which transaction) and takes every consequential fact from a
250
+ fresh retrieve.
251
+
252
+ Framework-agnostic by design — the gem is handed a body and returns a value object. It adds **no web
253
+ framework dependency**, owns no route, and never writes a response.
254
+
255
+ ```ruby
256
+ # config/routes.rb → post "/bml/notify" => "bml#notify"
257
+
258
+ def notify
259
+ result = BML.client.webhooks.handle(
260
+ body: request.body.read,
261
+ headers: request.env # Rails request.headers works too
262
+ )
263
+
264
+ Order.find_by!(transaction_id: result.transaction_id)
265
+ .apply_bml_status(result.status) # ← authoritative, from BML
266
+
267
+ head result.advisory_http_status # 200
268
+ rescue BMLConnect::Error => e
269
+ head e.advisory_http_status || 500 # 4xx = stop; 503 = please redeliver
270
+ end
271
+ ```
272
+
273
+ **The status never comes from the payload.** A forged "payment confirmed" posted to your public
274
+ endpoint cannot ship goods: the library retrieves the transaction and reports what BML says. There is
275
+ no flag that turns this off — `handle(verify: false)` raises `ArgumentError`, because the option does
276
+ not exist.
277
+
278
+ ```ruby
279
+ result.status # authoritative, retrieved fresh from BML — verbatim, never coerced
280
+ result.claimed_status # what the delivery claimed. Diagnostic only. Never act on it.
281
+ result.disagreed? # they differed → a replay, a read-path lag, or a forgery
282
+ result.transaction # BMLConnect::Models::TransactionRecord
283
+ ```
284
+
285
+ Expect states this gem has never heard of: the published contract enumerates none, so nothing is
286
+ coerced or rejected. Treat `result.status` as **current-as-of-retrieve**, not final.
287
+
288
+ #### What to return to BML
289
+
290
+ Every result and error carries `advisory_http_status`. It is advice — the library never writes a
291
+ response and does not require you to honor it.
292
+
293
+ | Outcome | Advisory | Meaning to the sender |
294
+ |---|---|---|
295
+ | Accepted | `200` | done |
296
+ | Malformed / empty / unparseable body | `400` | permanent — do not redeliver |
297
+ | Secret absent or wrong | `401` | permanent |
298
+ | No identifier extractable | `422` | permanent |
299
+ | Transaction does not exist | `422` | permanent |
300
+ | BML unreachable, timeout, 5xx, rate limited | `503` | **transient — please redeliver** |
301
+ | Our API key rejected | `503` | transient |
302
+
303
+ `404` is never advised. A missing transaction gets `422` instead: `404` from an HTTP endpoint reads as
304
+ "no such endpoint", and a sender that decides your hook URL is gone may stop delivering — turning one
305
+ forged identifier into an outage for every genuine notification after it.
306
+
307
+ #### Configuration
308
+
309
+ ```ruby
310
+ BMLConnect::Client.new(
311
+ api_key: ENV.fetch("BML_API_KEY"),
312
+ options: {
313
+ webhook_secret: ENV["BML_WEBHOOK_SECRET"], # optional
314
+ webhook_recheck_delay: 3 # seconds; 0 disables the re-check
315
+ }
316
+ )
317
+ ```
318
+
319
+ **`webhook_secret` gates spend, not trust.** With one configured, a delivery whose presented value is
320
+ absent or wrong is rejected before any retrieve is spent. A match never causes the payload to be
321
+ believed and never skips the retrieve — the same notification reports an identical status either way.
322
+
323
+ You extract the value; the library only compares it (in constant time). It never guesses where the
324
+ secret travels and never asks for the request URL. BML controls the callback's headers, so in practice
325
+ a secret can only ride in the URL you registered:
326
+
327
+ ```ruby
328
+ # registered hook URL: https://you.example/bml/notify?t=SECRET
329
+ BML.client.webhooks.handle(body: body, headers: headers, presented_secret: params[:t])
330
+ ```
331
+
332
+ > Enabling a secret on an existing hook? Re-register or update the URL **first**. Deliveries with
333
+ > nothing to present are rejected, and legitimate notifications drop silently until you do.
334
+
335
+ **`webhook_recheck_delay` is spent inside your request.** When the payload's claimed status disagrees
336
+ with the first retrieve, the library waits (default **3 seconds**) and retrieves once more — BML's read
337
+ path may lag the event that fired the callback. **If your endpoint's response deadline is at or below
338
+ 3 seconds, set this to `0`**; a sender that times out will redeliver, turning a latency problem into a
339
+ replay problem. The cost lands on every disagreeing delivery, including every replay.
340
+
341
+ At most **two** retrieves per notification, neither auto-retried. Nothing a payload claims produces a
342
+ third.
343
+
344
+ #### Limits you own, not the gem
345
+
346
+ - **Request size.** The library enforces **no** body size or nesting limit and parses whatever you
347
+ hand it — it does not own the socket, so a limit it invented would be the wrong one. Bound it at
348
+ your web server (`client_max_body_size`, a Rack middleware).
349
+ - **Every request costs a retrieve** when no secret is configured. Your endpoint is public;
350
+ protecting it is yours.
351
+ - **Deduplication and ordering.** The gem owns no datastore and deduplicates nothing. Deliveries are
352
+ at-least-once and possibly out of order; `result.transaction_id` is surfaced so you can key on it.
353
+ Handling the same delivery twice produces equal results against an unchanged record, with no side
354
+ effect beyond the retrieve.
355
+ - **Registration.** `POST/DELETE /public/webhooks` are not implemented. Register through the merchant
356
+ portal, or per-transaction via `webhook:` on `create_v2`.
357
+
358
+ If a notification never arrives, reconcile directly — no webhook needed:
359
+
360
+ ```ruby
361
+ BML.client.transactions.retrieve(transaction_id).state
362
+ ```
363
+
364
+ #### When the built-in field names are wrong
365
+
366
+ The identifier is looked for under `transactionId`, `transaction_id`, then `id` (top-level keys only).
367
+ BML UAT deliveries carry `transactionId`, so the defaults work. If your deliveries differ, say so and
368
+ the library will not second-guess you:
369
+
370
+ ```ruby
371
+ BML.client.webhooks.handle(
372
+ body: body, headers: headers,
373
+ extract_id: ->(payload, headers) { payload.dig(:transaction, :id) }
374
+ )
375
+ ```
376
+
377
+ The callable receives the parsed payload and the normalized headers — headers included so an
378
+ identifier arriving outside the body does not block you. Return `nil` for "not found" and you get the
379
+ same loud `ValidationError` as an exhausted list, with no remote call.
380
+
381
+ Bodies are accepted as JSON or form-encoded, selected by `Content-Type` (with a documented fallback
382
+ when it is absent). Header names are normalized for you, so `request.env`, `request.headers`, and a
383
+ plain hash all work.
384
+
385
+ > **Observed in UAT.** `NOTIFY_TRANSACTION_CHANGE` and `NOTIFY_TOKENISATION_STATUS` deliveries both
386
+ > carry a top-level `transactionId`. A wrong guess would still fail loudly rather than report a false
387
+ > status. See [`specs/005-webhook-handler/`](specs/005-webhook-handler/).
388
+
389
+ ## Roadmap
390
+
391
+ Tokenization support lives in this gem. It was previously being built as a separate
392
+ `bml_tokenization` gem, which turned out to have been specified against an assumed API contract
393
+ that does not match BML's published one.
394
+
395
+ Work is now spec-driven against BML's own OpenAPI document, vendored at
396
+ [`reference/Connect-API.json`](reference/Connect-API.json). Where this library and that document
397
+ disagree, the document wins.
398
+
399
+ | Feature | Endpoints | Status |
400
+ |---|---|---|
401
+ | [`001-customers-endpoints`](specs/001-customers-endpoints/) | `/public-customers` | Implemented, verified in UAT |
402
+ | [`002-stored-card-tokens`](specs/002-stored-card-tokens/) | `/public-customers/{id}/tokens` | Implemented, verified in UAT |
403
+ | [`003-transactions-v2`](specs/003-transactions-v2/) | `/public/v2/transactions` | Implemented, verified in UAT (payment-URL key not pinned) |
404
+ | [`004-token-charge`](specs/004-token-charge/) | `/public-customers/charge` | Implemented, verified in UAT (decline path unobserved) |
405
+ | [`005-webhook-handler`](specs/005-webhook-handler/) | inbound callback + `/public/transactions/{id}` | Implemented, verified in UAT |
406
+
407
+ Each feature directory carries a `spec.md`, `plan.md`, `research.md`, `data-model.md`, `tasks.md`
408
+ and two contracts — one for the BML HTTP surface, one for the Ruby surface this gem exposes.
409
+
410
+ ### How tokenization actually works
411
+
412
+ Worth stating up front, because it is not what most payment APIs do: **there is no endpoint that
413
+ creates a token.** A stored card comes into existence as a side effect of a transaction that
414
+ carries `tokenizationDetails`, completed by the cardholder on BML's hosted page. Later charges
415
+ are a two-step flow — create a transaction, then charge it against the stored token.
416
+
417
+ ```
418
+ create customer ─► create transaction with tokenizationDetails ─► cardholder pays
419
+ │
420
+ charge stored token ◄── token now listed ◄────────┘
421
+ ```
422
+
423
+ ### Backward compatibility
424
+
425
+ The transactions methods documented above are **not changing**. `create`, `get` and `list` keep
426
+ their current endpoints, signatures and `Faraday::Response` return type. New capabilities arrive
427
+ under new method names.
428
+
429
+ `transactions.create` will be marked **deprecated** in favour of a `create_v2` targeting BML's
430
+ documented `/public/v2/transactions`, but it will continue to work — it is live and carries
431
+ production traffic today.
432
+
433
+ ## Contributing to the specs
434
+
435
+ This repository uses [Spec Kit](https://github.com/github/spec-kit). The active feature is
436
+ tracked in `.specify/feature.json`:
437
+
438
+ ```bash
439
+ ./speckit-feature # show the active feature and the status of each
440
+ ./speckit-feature 002 # switch the active feature
441
+ ```
442
+
443
+ The project constitution is at [`.specify/memory/constitution.md`](.specify/memory/constitution.md).
444
+ Two rules matter most for anyone adding a remote call:
445
+
446
+ 1. **A stubbed test is not evidence that an endpoint exists.** Every path must be traceable to
447
+ `reference/Connect-API.json`, and stub URLs must derive from the client's base URL rather than
448
+ being hardcoded.
449
+ 2. **Anything not in the published document is marked `[UNVERIFIED]`** and must be confirmed
450
+ against UAT before it is relied on.
27
451
 
28
452
  ## Development
29
453