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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3188c8c01dd2f98bf493c8e9dfd17e7698b562c9f5e881f151aaa83ff71fdc23
|
|
4
|
+
data.tar.gz: aa04de2dbe02ff410bfa23e6be2ba6bf91fc8b130dada0a6b6fa1ca9ebd06bd5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c8d7f7cea98ca9a3196fff43a87ef5befdb0f8ca1d009cef04f345ed408c9d48713be6849943007ab6eaf55a11097711ec7116a9d3c9ae5f811474447aea9e38
|
|
7
|
+
data.tar.gz: b295ca6fe627df089aef1f76cc9300d8440285b8c121082eb4825ba8e1e8c62f02db5610213c4f3d19ded7b5616d8c6195c78e322dfe4cafa499c734cf78cf75
|
data/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,194 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
-
## [0.
|
|
3
|
+
## [0.3.0] - 2026-10-26
|
|
4
4
|
|
|
5
|
-
-
|
|
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.
|
|
6
186
|
|
|
7
187
|
## [0.2.0] - 2021-09-17
|
|
8
188
|
|
|
9
189
|
### Changes
|
|
10
190
|
- Downgraded ruby to >=2.3
|
|
191
|
+
|
|
192
|
+
## [0.1.0] - 2021-09-16
|
|
193
|
+
|
|
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
|
|
@@ -52,8 +58,14 @@ end
|
|
|
52
58
|
client = BMLConnect::Client.new
|
|
53
59
|
```
|
|
54
60
|
### API Operations
|
|
55
|
-
|
|
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
|
+
|
|
56
67
|
```ruby
|
|
68
|
+
# DEPRECATED v1 create — still works, returns a Faraday::Response
|
|
57
69
|
resp = client.transactions.create({
|
|
58
70
|
amount: 10000,
|
|
59
71
|
currency: 'MVR',
|
|
@@ -61,16 +73,381 @@ resp = client.transactions.create({
|
|
|
61
73
|
localId: 'local-1',
|
|
62
74
|
customerReference: 'INV-0001'
|
|
63
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
|
+
)
|
|
64
120
|
```
|
|
65
|
-
|
|
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
|
+
|
|
66
133
|
```ruby
|
|
67
|
-
|
|
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)
|
|
68
142
|
```
|
|
69
|
-
|
|
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
|
+
|
|
70
278
|
```ruby
|
|
71
|
-
|
|
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
|
|
72
283
|
```
|
|
73
|
-
|
|
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.
|
|
74
451
|
|
|
75
452
|
## Development
|
|
76
453
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module BMLConnect
|
|
7
|
+
# Emits an audit record for every state-changing customer operation.
|
|
8
|
+
#
|
|
9
|
+
# Per the 2026-09-08 clarification (research R12), the record is written as a
|
|
10
|
+
# single structured, masked log line through the client's existing logger.
|
|
11
|
+
# There is no separate audit sink: auditing rides the same logging path the
|
|
12
|
+
# rest of the library uses, and is observable in tests by inspecting the line.
|
|
13
|
+
module Audit
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Build the who/what/when/outcome record and log it. Returns the record
|
|
17
|
+
# (as a hash) so callers/tests can assert on it without parsing the line.
|
|
18
|
+
def emit_event(client, action:, actor:, outcome:, subject:)
|
|
19
|
+
record = {
|
|
20
|
+
action: action,
|
|
21
|
+
who: { app_id: client.app_id, actor: actor },
|
|
22
|
+
subject: subject,
|
|
23
|
+
outcome: outcome,
|
|
24
|
+
at: Time.now.utc.iso8601
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
logger = client.respond_to?(:logger) ? client.logger : nil
|
|
28
|
+
logger&.info(Masking.scrub(JSON.generate(record)))
|
|
29
|
+
|
|
30
|
+
record
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|