bakong-open-api 0.1.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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +261 -0
- data/bakong-open-api.gemspec +38 -0
- data/lib/bakong/open_api/client.rb +50 -0
- data/lib/bakong/open_api/configuration.rb +22 -0
- data/lib/bakong/open_api/connection.rb +105 -0
- data/lib/bakong/open_api/error.rb +63 -0
- data/lib/bakong/open_api/helpers/case_helper.rb +50 -0
- data/lib/bakong/open_api/resources/accounts.rb +37 -0
- data/lib/bakong/open_api/resources/base.rb +61 -0
- data/lib/bakong/open_api/resources/deeplinks.rb +36 -0
- data/lib/bakong/open_api/resources/tokens.rb +21 -0
- data/lib/bakong/open_api/resources/transactions.rb +83 -0
- data/lib/bakong/open_api/source_info.rb +21 -0
- data/lib/bakong/open_api/version.rb +7 -0
- data/lib/bakong/open_api.rb +22 -0
- data/lib/bakong-open-api.rb +3 -0
- metadata +71 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7ab8db3ca6526ebf0abf3c6541dd573fb4b673db087d9c0292cf067b2fff2a04
|
|
4
|
+
data.tar.gz: 8f33be1f488d42ff0a6482d575a58b631da19236c0f69e08c03c3e556b339e93
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3d84f6150fbd46b48b87f9ba7c44d2a9d50df6246699b3a09919a9fd240d40decc24ab2701e970bb149641bbc97ae5287cb90f9d8ceac1505ad5995c9d015653
|
|
7
|
+
data.tar.gz: e6df0b93e85cc4f0c861a40a92c08e2f88dcb6c8906ae093db2fcdd20a6d70c3fbced2a4ea90abfd8c0314e984c5f9025c1d61f0ccb5befc23d3273a60c5ab19
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **bakong-open-api** will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Initial scaffolding for the Bakong Open API Ruby client.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vandy Sodanheang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# bakong-open-api
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/bakong-open-api)
|
|
4
|
+
|
|
5
|
+
A Ruby client for the **Bakong Open API** — the National Bank of Cambodia's
|
|
6
|
+
HTTP API for verifying KHQR transactions, checking Bakong account existence,
|
|
7
|
+
generating wallet deep links, and managing access tokens.
|
|
8
|
+
|
|
9
|
+
> ⚠️ **Unofficial client.** This gem is **not** an official SDK from the
|
|
10
|
+
> National Bank of Cambodia or any Bakong-affiliated entity. It is an
|
|
11
|
+
> independent, community-maintained Ruby wrapper implemented against the
|
|
12
|
+
> publicly available API documentation at
|
|
13
|
+
> <https://api-bakong.nbc.gov.kh/document>. The NBC has not reviewed,
|
|
14
|
+
> endorsed, or sponsored this project. Always verify behavior against the
|
|
15
|
+
> upstream documentation before relying on it in production.
|
|
16
|
+
|
|
17
|
+
Zero runtime gem dependencies — uses only the Ruby standard library
|
|
18
|
+
(`Net::HTTP`). Pairs naturally with [bakong-khqr](https://rubygems.org/gems/bakong-khqr)
|
|
19
|
+
for generating the KHQR payloads you then verify.
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- Ruby `>= 3.4.1`
|
|
24
|
+
- A registered Bakong developer email (request one via the NBC's developer
|
|
25
|
+
portal). The email is used to mint short-lived JWT access tokens via
|
|
26
|
+
`POST /v1/renew_token`.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
gem "bakong-open-api"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
gem install bakong-open-api
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
require "bakong/open_api"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
client = Bakong::OpenApi.client
|
|
48
|
+
|
|
49
|
+
# 1. Mint a token using your registered email
|
|
50
|
+
client.token = client.tokens.renew(email: "you@example.com").fetch(:token)
|
|
51
|
+
|
|
52
|
+
# 2. Check whether a Bakong account exists
|
|
53
|
+
client.accounts.exists?(account_id: "vandy@aclb")
|
|
54
|
+
# => true | false
|
|
55
|
+
|
|
56
|
+
# 3. Verify a transaction by the MD5 of its KHQR string
|
|
57
|
+
client.transactions.check_by_md5(md5: "d60f3db96913029a2af979a1662c1e72")
|
|
58
|
+
# => { hash: "...", from_account_id: "...", to_account_id: "...",
|
|
59
|
+
# currency: "USD", amount: 1.0, description: "",
|
|
60
|
+
# created_date_ms: 1605774370608.0, acknowledged_date_ms: 1605774422421.0 }
|
|
61
|
+
# nil if the API returns errorCode 1 (not found)
|
|
62
|
+
|
|
63
|
+
# 4. Generate a wallet deep link for a KHQR
|
|
64
|
+
client.deeplinks.generate(qr: "00020101...")
|
|
65
|
+
# => { short_link: "https://bakong.link/abc" }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Authentication
|
|
69
|
+
|
|
70
|
+
Every endpoint except `tokens.renew` and `deeplinks.generate` requires a
|
|
71
|
+
Bearer token. Tokens are short-lived JWTs (~90 days at the time of writing).
|
|
72
|
+
Two ways to use them:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# Construct with token
|
|
76
|
+
client = Bakong::OpenApi.client(token: "eyJhbGciOiJ...")
|
|
77
|
+
|
|
78
|
+
# Or set/replace after construction (e.g. after refresh)
|
|
79
|
+
client.token = "eyJhbGciOiJ..."
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
To mint a fresh token:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
response = client.tokens.renew(email: "vandysodanheang@gmail.com")
|
|
86
|
+
client.token = response[:token]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Resources
|
|
90
|
+
|
|
91
|
+
### `client.tokens`
|
|
92
|
+
|
|
93
|
+
| Method | Bakong endpoint | Returns |
|
|
94
|
+
| --- | --- | --- |
|
|
95
|
+
| `.renew(email:)` | `POST /v1/renew_token` | `{ token: String }` |
|
|
96
|
+
|
|
97
|
+
### `client.accounts`
|
|
98
|
+
|
|
99
|
+
| Method | Bakong endpoint | Returns |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `.exists?(account_id:)` | `POST /v1/check_bakong_account` | `true` / `false` |
|
|
102
|
+
|
|
103
|
+
### `client.deeplinks`
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
source = Bakong::OpenApi::SourceInfo.new(
|
|
107
|
+
app_icon_url: "https://yourapp.example/icon.png",
|
|
108
|
+
app_name: "Your App",
|
|
109
|
+
app_deep_link_callback: "yourapp://payment-result"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
client.deeplinks.generate(qr: "00020101...", source_info: source)
|
|
113
|
+
# => { short_link: "..." }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`source_info` is optional; when provided, **all three fields are required**
|
|
117
|
+
and the gem raises `Bakong::OpenApi::MissingFieldsError` before the HTTP
|
|
118
|
+
call if any are blank.
|
|
119
|
+
|
|
120
|
+
### `client.transactions`
|
|
121
|
+
|
|
122
|
+
| Method | Bakong endpoint | Returns |
|
|
123
|
+
| --- | --- | --- |
|
|
124
|
+
| `.check_by_md5(md5:)` | `POST /v1/check_transaction_by_md5` | transaction `Hash` or `nil` |
|
|
125
|
+
| `.check_by_hash(hash:)` | `POST /v1/check_transaction_by_hash` | transaction `Hash` or `nil` |
|
|
126
|
+
| `.check_by_short_hash(hash:, amount:, currency:)` | `POST /v1/check_transaction_by_short_hash` | transaction `Hash` or `nil` |
|
|
127
|
+
| `.check_by_instruction_ref(instruction_ref:)` | `POST /v1/check_transaction_by_instruction_ref` | transaction `Hash` or `nil` |
|
|
128
|
+
| `.check_by_external_ref(external_ref:)` | `POST /v1/check_transaction_by_external_ref` | transaction `Hash` or `nil` |
|
|
129
|
+
| `.check_by_md5_list(md5s:)` | `POST /v1/check_transaction_by_md5_list` | full envelope `Hash` (data is per-row Array) |
|
|
130
|
+
| `.check_by_hash_list(hashes:)` | `POST /v1/check_transaction_by_hash_list` | full envelope `Hash` (data is per-row Array) |
|
|
131
|
+
|
|
132
|
+
**Single lookups** return the transaction `Hash` with snake_cased keys on
|
|
133
|
+
success, `nil` when the API reports `errorCode 1` (transaction not found), and
|
|
134
|
+
raise on every other failure. **Batch lookups** are capped at 50 items and
|
|
135
|
+
return the full envelope so callers can iterate per-row statuses (`SUCCESS`,
|
|
136
|
+
`NOT_FOUND`, `FAILED`, `STATIC_QR`).
|
|
137
|
+
|
|
138
|
+
## Error handling
|
|
139
|
+
|
|
140
|
+
All errors inherit from `Bakong::OpenApi::Error` and carry the HTTP status,
|
|
141
|
+
the Bakong `responseCode` / `errorCode` / `responseMessage`, and the raw
|
|
142
|
+
response body:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
begin
|
|
146
|
+
client.tokens.renew(email: "wrong@example.com")
|
|
147
|
+
rescue Bakong::OpenApi::NotRegisteredError => e
|
|
148
|
+
e.error_code # => 10
|
|
149
|
+
e.response_message # => "Not registered yet"
|
|
150
|
+
e.body # => { responseCode: 1, errorCode: 10, ... }
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Specialized error classes for each Bakong errorCode:
|
|
155
|
+
|
|
156
|
+
| errorCode | Class | Meaning |
|
|
157
|
+
| --- | --- | --- |
|
|
158
|
+
| 1 | `TransactionNotFoundError` | Transaction not found (also returned as `nil` from single lookups) |
|
|
159
|
+
| 2 | `StaticQrNotSupportedError` | Static QR codes aren't supported by this endpoint |
|
|
160
|
+
| 3 | `TransactionFailedError` | The transaction failed |
|
|
161
|
+
| 4 | `DeeplinkProviderError` | Upstream deep link provider error |
|
|
162
|
+
| 5 | `MissingFieldsError` | Required request fields missing |
|
|
163
|
+
| 6 | `UnauthorizedError` | Token missing or rejected |
|
|
164
|
+
| 7 | `EmailServerDownError` | Bakong couldn't send the email |
|
|
165
|
+
| 8 | `EmailAlreadyRegisteredError` | Email already registered |
|
|
166
|
+
| 9 | `BakongUnreachableError` | Bakong reports it can't reach its backend |
|
|
167
|
+
| 10 | `NotRegisteredError` | This email isn't registered |
|
|
168
|
+
| 11 | `AccountNotFoundError` | Bakong account doesn't exist (also returned as `false` from `accounts.exists?`) |
|
|
169
|
+
| 12 | `AccountInvalidError` | Bakong account ID malformed |
|
|
170
|
+
|
|
171
|
+
Transport-level errors map to:
|
|
172
|
+
|
|
173
|
+
| HTTP status | Class |
|
|
174
|
+
| --- | --- |
|
|
175
|
+
| 401 | `AuthenticationError` |
|
|
176
|
+
| 403 | `TokenExpiredError` |
|
|
177
|
+
| 404 | `NotFoundError` |
|
|
178
|
+
| 429 | `RateLimitError` |
|
|
179
|
+
| 4xx | `InvalidRequestError` |
|
|
180
|
+
| 5xx | `ServerError` |
|
|
181
|
+
| socket / DNS failure | `ConnectionError` |
|
|
182
|
+
|
|
183
|
+
## Configuration
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
client = Bakong::OpenApi.client(
|
|
187
|
+
token: "eyJhbGciOiJ...",
|
|
188
|
+
base_url: "https://api-bakong.nbc.gov.kh", # default
|
|
189
|
+
open_timeout: 45, # seconds
|
|
190
|
+
read_timeout: 45,
|
|
191
|
+
user_agent: "myapp/1.2.3"
|
|
192
|
+
)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Pairing with bakong-khqr
|
|
196
|
+
|
|
197
|
+
The typical flow when accepting Bakong payments is:
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
require "bakong/khqr"
|
|
201
|
+
require "bakong/open_api"
|
|
202
|
+
|
|
203
|
+
# Generate a KHQR string for the buyer to scan
|
|
204
|
+
info = Bakong::Khqr::IndividualInfo.new(
|
|
205
|
+
bakong_account_id: "vandy@aclb",
|
|
206
|
+
merchant_name: "Sodanheang Coffee",
|
|
207
|
+
merchant_city: "Phnom Penh",
|
|
208
|
+
amount: 1.50,
|
|
209
|
+
currency: Bakong::Khqr::CURRENCY[:usd],
|
|
210
|
+
expiration_timestamp: (Time.now.to_f * 1000).to_i + 5 * 60 * 1000
|
|
211
|
+
)
|
|
212
|
+
qr_data = Bakong::Khqr.generate_merchant(info)
|
|
213
|
+
qr_data[:qr] # → display this as a QR image
|
|
214
|
+
qr_data[:md5] # → store this; poll it later
|
|
215
|
+
|
|
216
|
+
# Later (e.g. via a background job), check whether the buyer paid
|
|
217
|
+
client = Bakong::OpenApi.client(token: ENV.fetch("BAKONG_TOKEN"))
|
|
218
|
+
transaction = client.transactions.check_by_md5(md5: qr_data[:md5])
|
|
219
|
+
if transaction
|
|
220
|
+
# paid — fulfill the order
|
|
221
|
+
else
|
|
222
|
+
# not yet paid (or expired)
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Development
|
|
227
|
+
|
|
228
|
+
```sh
|
|
229
|
+
bin/setup # bundle install
|
|
230
|
+
bundle exec rspec
|
|
231
|
+
bundle exec rake # default task = spec
|
|
232
|
+
bin/console # IRB with bakong/open_api loaded
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Releasing
|
|
236
|
+
|
|
237
|
+
```sh
|
|
238
|
+
bin/release v0.1.1
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The script bumps `VERSION`, runs RSpec, builds the gem, commits + tags,
|
|
242
|
+
prompts for your RubyGems MFA OTP, pushes the gem to RubyGems, pushes the
|
|
243
|
+
git tag, and creates a GitHub release. Requires `$RUBY_GEM_KEY` in your
|
|
244
|
+
shell and an authenticated `gh` CLI.
|
|
245
|
+
|
|
246
|
+
## Contributing
|
|
247
|
+
|
|
248
|
+
Issues and pull requests are welcome at
|
|
249
|
+
<https://github.com/VandyTheCoder/bakong-open-api-ruby>.
|
|
250
|
+
|
|
251
|
+
## Credits & disclaimer
|
|
252
|
+
|
|
253
|
+
This is an **independent, unofficial Ruby client** built against the public
|
|
254
|
+
[Bakong Open API documentation](https://api-bakong.nbc.gov.kh/document)
|
|
255
|
+
published by the National Bank of Cambodia. The NBC has not reviewed,
|
|
256
|
+
endorsed, or sponsored this project. All trademarks and API specifications
|
|
257
|
+
remain the property of the National Bank of Cambodia.
|
|
258
|
+
|
|
259
|
+
## License
|
|
260
|
+
|
|
261
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/bakong/open_api/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "bakong-open-api"
|
|
7
|
+
spec.version = Bakong::OpenApi::VERSION
|
|
8
|
+
spec.authors = ["Vandy Sodanheang"]
|
|
9
|
+
spec.email = ["vandysodanheang@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Unofficial Ruby client for the Bakong Open API (National Bank of Cambodia)."
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
An unofficial, community-maintained Ruby client for the Bakong Open API
|
|
14
|
+
published by the National Bank of Cambodia. This gem is NOT an official
|
|
15
|
+
SDK from the NBC or any Bakong-affiliated entity — it is implemented
|
|
16
|
+
independently against the public API documentation available at
|
|
17
|
+
https://api-bakong.nbc.gov.kh/document.
|
|
18
|
+
|
|
19
|
+
Authenticate, manage tokens, look up Bakong accounts, verify transactions
|
|
20
|
+
by md5/hash/short-hash/external ref/instruction ref, and generate KHQR
|
|
21
|
+
deep links. Zero runtime gem dependencies — uses only the Ruby standard
|
|
22
|
+
library (Net::HTTP).
|
|
23
|
+
DESC
|
|
24
|
+
spec.homepage = "https://github.com/VandyTheCoder/bakong-open-api-ruby"
|
|
25
|
+
spec.license = "MIT"
|
|
26
|
+
spec.required_ruby_version = ">= 3.4.1"
|
|
27
|
+
|
|
28
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
29
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
30
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
31
|
+
|
|
32
|
+
spec.files = Dir.chdir(__dir__) do
|
|
33
|
+
Dir["lib/**/*.rb", "*.md", "LICENSE.txt", "bakong-open-api.gemspec"]
|
|
34
|
+
end
|
|
35
|
+
spec.require_paths = ["lib"]
|
|
36
|
+
spec.bindir = "exe"
|
|
37
|
+
spec.executables = []
|
|
38
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "configuration"
|
|
4
|
+
require_relative "connection"
|
|
5
|
+
require_relative "resources/tokens"
|
|
6
|
+
require_relative "resources/accounts"
|
|
7
|
+
require_relative "resources/deeplinks"
|
|
8
|
+
require_relative "resources/transactions"
|
|
9
|
+
|
|
10
|
+
module Bakong
|
|
11
|
+
module OpenApi
|
|
12
|
+
# Primary entry-point. Holds configuration and a Connection. Resource
|
|
13
|
+
# modules (tokens, accounts, deeplinks, transactions) are lazily
|
|
14
|
+
# instantiated on first access.
|
|
15
|
+
class Client
|
|
16
|
+
attr_reader :config, :connection
|
|
17
|
+
|
|
18
|
+
def initialize(token: nil, base_url: nil, open_timeout: nil, read_timeout: nil, user_agent: nil)
|
|
19
|
+
@config = Configuration.new
|
|
20
|
+
@config.token = token if token
|
|
21
|
+
@config.base_url = base_url if base_url
|
|
22
|
+
@config.open_timeout = open_timeout if open_timeout
|
|
23
|
+
@config.read_timeout = read_timeout if read_timeout
|
|
24
|
+
@config.user_agent = user_agent if user_agent
|
|
25
|
+
@connection = Connection.new(@config)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Mutate the active token after construction (e.g. after renew_token).
|
|
29
|
+
def token=(new_token)
|
|
30
|
+
@config.token = new_token
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def tokens
|
|
34
|
+
@tokens ||= Resources::Tokens.new(self)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def accounts
|
|
38
|
+
@accounts ||= Resources::Accounts.new(self)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def deeplinks
|
|
42
|
+
@deeplinks ||= Resources::Deeplinks.new(self)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def transactions
|
|
46
|
+
@transactions ||= Resources::Transactions.new(self)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bakong
|
|
4
|
+
module OpenApi
|
|
5
|
+
# Client-wide configuration. Either passed to Client.new explicitly or
|
|
6
|
+
# mutated via Bakong::OpenApi.configure { |c| ... } for a thread-local
|
|
7
|
+
# default singleton.
|
|
8
|
+
class Configuration
|
|
9
|
+
DEFAULT_BASE_URL = "https://api-bakong.nbc.gov.kh"
|
|
10
|
+
DEFAULT_TIMEOUT = 45
|
|
11
|
+
|
|
12
|
+
attr_accessor :base_url, :token, :open_timeout, :read_timeout, :user_agent
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@base_url = DEFAULT_BASE_URL
|
|
16
|
+
@open_timeout = DEFAULT_TIMEOUT
|
|
17
|
+
@read_timeout = DEFAULT_TIMEOUT
|
|
18
|
+
@user_agent = "bakong-open-api-ruby/#{Bakong::OpenApi::VERSION}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
require_relative "error"
|
|
8
|
+
|
|
9
|
+
module Bakong
|
|
10
|
+
module OpenApi
|
|
11
|
+
# Net::HTTP-based HTTP transport. Knows nothing about Bakong domain
|
|
12
|
+
# semantics — just turns (method, path, body) into a parsed Hash or raises
|
|
13
|
+
# a Bakong::OpenApi::Error.
|
|
14
|
+
class Connection
|
|
15
|
+
def initialize(config)
|
|
16
|
+
@config = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def get(path, params: nil, headers: {})
|
|
20
|
+
request(:get, path, params: params, headers: headers)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def post(path, body: nil, headers: {})
|
|
24
|
+
request(:post, path, body: body, headers: headers)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def request(method, path, body: nil, params: nil, headers: {})
|
|
30
|
+
uri = build_uri(path, params)
|
|
31
|
+
http = build_http(uri)
|
|
32
|
+
req = build_request(method, uri, body, headers)
|
|
33
|
+
|
|
34
|
+
response = http.request(req)
|
|
35
|
+
handle(response)
|
|
36
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
|
|
37
|
+
raise ConnectionError.new("Bakong Open API unreachable: #{e.class}: #{e.message}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_uri(path, params)
|
|
41
|
+
uri = URI.parse(@config.base_url)
|
|
42
|
+
uri.path = path.start_with?("/") ? path : "/#{path}"
|
|
43
|
+
uri.query = URI.encode_www_form(params) if params && !params.empty?
|
|
44
|
+
uri
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_http(uri)
|
|
48
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
49
|
+
http.use_ssl = (uri.scheme == "https")
|
|
50
|
+
http.open_timeout = @config.open_timeout
|
|
51
|
+
http.read_timeout = @config.read_timeout
|
|
52
|
+
http
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_request(method, uri, body, headers)
|
|
56
|
+
klass = method == :post ? Net::HTTP::Post : Net::HTTP::Get
|
|
57
|
+
req = klass.new(uri.request_uri)
|
|
58
|
+
req["Content-Type"] = "application/json"
|
|
59
|
+
req["Accept"] = "application/json"
|
|
60
|
+
req["User-Agent"] = @config.user_agent
|
|
61
|
+
req["Authorization"] = "Bearer #{@config.token}" if @config.token
|
|
62
|
+
headers.each { |k, v| req[k] = v }
|
|
63
|
+
req.body = JSON.generate(body) if body
|
|
64
|
+
req
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def handle(response)
|
|
68
|
+
body = parse(response.body)
|
|
69
|
+
|
|
70
|
+
case response.code.to_i
|
|
71
|
+
when 200..299
|
|
72
|
+
body
|
|
73
|
+
when 401
|
|
74
|
+
raise AuthenticationError.new("authentication required", status: 401, body: body)
|
|
75
|
+
when 403
|
|
76
|
+
raise TokenExpiredError.new("token expired or insufficient scope", status: 403, body: body)
|
|
77
|
+
when 404
|
|
78
|
+
raise NotFoundError.new("resource not found", status: 404, body: body)
|
|
79
|
+
when 429
|
|
80
|
+
raise RateLimitError.new("rate limit exceeded", status: 429, body: body)
|
|
81
|
+
when 400..499
|
|
82
|
+
raise InvalidRequestError.new(error_message(body, "invalid request"), status: response.code.to_i, body: body)
|
|
83
|
+
when 500..599
|
|
84
|
+
raise ServerError.new(error_message(body, "Bakong server error"), status: response.code.to_i, body: body)
|
|
85
|
+
else
|
|
86
|
+
raise Error.new("unexpected HTTP #{response.code}", status: response.code.to_i, body: body)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse(body)
|
|
91
|
+
return {} if body.nil? || body.empty?
|
|
92
|
+
|
|
93
|
+
JSON.parse(body, symbolize_names: true)
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
{ raw: body }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def error_message(body, fallback)
|
|
99
|
+
return fallback unless body.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
body[:message] || body[:errorMessage] || body[:error] || fallback
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bakong
|
|
4
|
+
module OpenApi
|
|
5
|
+
# Base class for every error this gem raises. Carries the upstream HTTP
|
|
6
|
+
# status, the Bakong responseCode/errorCode if the API supplied one, and
|
|
7
|
+
# the raw response body for debugging.
|
|
8
|
+
class Error < StandardError
|
|
9
|
+
attr_reader :status, :response_code, :error_code, :response_message, :body
|
|
10
|
+
|
|
11
|
+
def initialize(message = nil, status: nil, response_code: nil, error_code: nil, response_message: nil, body: nil)
|
|
12
|
+
@status = status
|
|
13
|
+
@response_code = response_code
|
|
14
|
+
@error_code = error_code
|
|
15
|
+
@response_message = response_message
|
|
16
|
+
@body = body
|
|
17
|
+
super(message || response_message || "Bakong Open API error")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# HTTP-level failures
|
|
22
|
+
class AuthenticationError < Error; end # 401
|
|
23
|
+
class TokenExpiredError < Error; end # 403
|
|
24
|
+
class InvalidRequestError < Error; end # 4xx
|
|
25
|
+
class NotFoundError < Error; end # 404
|
|
26
|
+
class RateLimitError < Error; end # 429
|
|
27
|
+
class ServerError < Error; end # 5xx
|
|
28
|
+
class ConnectionError < Error; end # socket timeout / DNS
|
|
29
|
+
|
|
30
|
+
# Bakong domain errors — when responseCode != 0 and the failure mode is
|
|
31
|
+
# specific enough to warrant its own class.
|
|
32
|
+
class TransactionNotFoundError < Error; end # errorCode: 1
|
|
33
|
+
class StaticQrNotSupportedError < Error; end # errorCode: 2
|
|
34
|
+
class TransactionFailedError < Error; end # errorCode: 3
|
|
35
|
+
class DeeplinkProviderError < Error; end # errorCode: 4
|
|
36
|
+
class MissingFieldsError < Error; end # errorCode: 5
|
|
37
|
+
class UnauthorizedError < Error; end # errorCode: 6
|
|
38
|
+
class EmailServerDownError < Error; end # errorCode: 7
|
|
39
|
+
class EmailAlreadyRegisteredError < Error; end # errorCode: 8
|
|
40
|
+
class BakongUnreachableError < Error; end # errorCode: 9
|
|
41
|
+
class NotRegisteredError < Error; end # errorCode: 10
|
|
42
|
+
class AccountNotFoundError < Error; end # errorCode: 11
|
|
43
|
+
class AccountInvalidError < Error; end # errorCode: 12
|
|
44
|
+
|
|
45
|
+
# Mapping from Bakong's numeric errorCode (in the response body) to a
|
|
46
|
+
# specific error class. Used by resources that want to translate domain
|
|
47
|
+
# failures into Ruby exceptions instead of returning the raw envelope.
|
|
48
|
+
DOMAIN_ERROR_CLASSES = {
|
|
49
|
+
1 => TransactionNotFoundError,
|
|
50
|
+
2 => StaticQrNotSupportedError,
|
|
51
|
+
3 => TransactionFailedError,
|
|
52
|
+
4 => DeeplinkProviderError,
|
|
53
|
+
5 => MissingFieldsError,
|
|
54
|
+
6 => UnauthorizedError,
|
|
55
|
+
7 => EmailServerDownError,
|
|
56
|
+
8 => EmailAlreadyRegisteredError,
|
|
57
|
+
9 => BakongUnreachableError,
|
|
58
|
+
10 => NotRegisteredError,
|
|
59
|
+
11 => AccountNotFoundError,
|
|
60
|
+
12 => AccountInvalidError
|
|
61
|
+
}.freeze
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bakong
|
|
4
|
+
module OpenApi
|
|
5
|
+
module Helpers
|
|
6
|
+
# Convert between Ruby's snake_case (used everywhere in this gem's
|
|
7
|
+
# public API) and the Bakong API's camelCase wire format. Recursive on
|
|
8
|
+
# hashes and arrays.
|
|
9
|
+
module CaseHelper
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# snake_case symbol/string keys → camelCase symbol keys for requests.
|
|
13
|
+
def to_camel(value)
|
|
14
|
+
case value
|
|
15
|
+
when Hash
|
|
16
|
+
value.each_with_object({}) { |(k, v), out| out[snake_to_camel(k.to_s).to_sym] = to_camel(v) }
|
|
17
|
+
when Array
|
|
18
|
+
value.map { |v| to_camel(v) }
|
|
19
|
+
else
|
|
20
|
+
value
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# camelCase keys → snake_case symbol keys for responses.
|
|
25
|
+
def to_snake(value)
|
|
26
|
+
case value
|
|
27
|
+
when Hash
|
|
28
|
+
value.each_with_object({}) { |(k, v), out| out[camel_to_snake(k.to_s).to_sym] = to_snake(v) }
|
|
29
|
+
when Array
|
|
30
|
+
value.map { |v| to_snake(v) }
|
|
31
|
+
else
|
|
32
|
+
value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def snake_to_camel(string)
|
|
37
|
+
parts = string.split("_")
|
|
38
|
+
([parts.first] + parts.drop(1).map(&:capitalize)).join
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def camel_to_snake(string)
|
|
42
|
+
string
|
|
43
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
44
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
45
|
+
.downcase
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Bakong
|
|
6
|
+
module OpenApi
|
|
7
|
+
module Resources
|
|
8
|
+
# `POST /v1/check_bakong_account` — check whether a Bakong account ID
|
|
9
|
+
# (e.g. "user@bank") exists. The API returns responseCode 0 when the
|
|
10
|
+
# account exists, 1 + errorCode 11 when it does not. This wrapper
|
|
11
|
+
# collapses that into a Ruby boolean.
|
|
12
|
+
class Accounts < Base
|
|
13
|
+
# @param account_id [String] Bakong account ID, e.g. "vandy@aclb"
|
|
14
|
+
# @return [Boolean] true if the account exists, false if not
|
|
15
|
+
# @raise [Bakong::OpenApi::AccountInvalidError] when errorCode 12
|
|
16
|
+
# @raise [Bakong::OpenApi::Error] on other failures
|
|
17
|
+
def exists?(account_id:)
|
|
18
|
+
envelope = connection.post(
|
|
19
|
+
"/v1/check_bakong_account",
|
|
20
|
+
body: { accountId: account_id }
|
|
21
|
+
)
|
|
22
|
+
response_code = envelope[:responseCode] || envelope[:response_code]
|
|
23
|
+
error_code = envelope[:errorCode] || envelope[:error_code]
|
|
24
|
+
message = envelope[:responseMessage] || envelope[:response_message]
|
|
25
|
+
|
|
26
|
+
return true if response_code.to_i.zero?
|
|
27
|
+
return false if error_code.to_i == 11 # explicitly NOT_FOUND
|
|
28
|
+
|
|
29
|
+
klass = Bakong::OpenApi::DOMAIN_ERROR_CLASSES[error_code.to_i] || Bakong::OpenApi::Error
|
|
30
|
+
raise klass.new(message,
|
|
31
|
+
response_code: response_code, error_code: error_code,
|
|
32
|
+
response_message: message, body: envelope)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../error"
|
|
4
|
+
require_relative "../helpers/case_helper"
|
|
5
|
+
|
|
6
|
+
module Bakong
|
|
7
|
+
module OpenApi
|
|
8
|
+
module Resources
|
|
9
|
+
# Shared behavior for resource modules. Each resource gets a `client`
|
|
10
|
+
# accessor (so `connection` and `config` are reachable) and helpers for
|
|
11
|
+
# turning a Bakong envelope `{responseCode, errorCode, responseMessage, data}`
|
|
12
|
+
# into either snake_cased data or a raised domain-specific Error.
|
|
13
|
+
class Base
|
|
14
|
+
def initialize(client)
|
|
15
|
+
@client = client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :client
|
|
19
|
+
|
|
20
|
+
protected
|
|
21
|
+
|
|
22
|
+
def connection
|
|
23
|
+
@client.connection
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Submit a POST and translate the Bakong envelope into either:
|
|
27
|
+
# - the snake_cased `data` payload on responseCode 0
|
|
28
|
+
# - a raised domain Error on responseCode 1
|
|
29
|
+
#
|
|
30
|
+
# @param treat_response_code_1_as [nil, :not_found] when :not_found,
|
|
31
|
+
# responseCode 1 with no specific errorCode mapping returns nil
|
|
32
|
+
# instead of raising (used by transaction lookups).
|
|
33
|
+
def submit(path, body, treat_response_code_1_as: nil)
|
|
34
|
+
envelope = connection.post(path, body: Helpers::CaseHelper.to_camel(body))
|
|
35
|
+
unwrap(envelope, treat_response_code_1_as: treat_response_code_1_as)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Submit and return the full snake_cased envelope without unwrapping.
|
|
39
|
+
# Used by list endpoints whose `data` is an array of mixed-status rows.
|
|
40
|
+
def submit_envelope(path, body)
|
|
41
|
+
envelope = connection.post(path, body: Helpers::CaseHelper.to_camel(body))
|
|
42
|
+
Helpers::CaseHelper.to_snake(envelope)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def unwrap(envelope, treat_response_code_1_as: nil)
|
|
46
|
+
response_code = envelope[:responseCode] || envelope[:response_code]
|
|
47
|
+
return Helpers::CaseHelper.to_snake(envelope[:data]) if response_code.to_i.zero?
|
|
48
|
+
|
|
49
|
+
error_code = envelope[:errorCode] || envelope[:error_code]
|
|
50
|
+
message = envelope[:responseMessage] || envelope[:response_message]
|
|
51
|
+
return nil if treat_response_code_1_as == :not_found && error_code.to_i == 1
|
|
52
|
+
|
|
53
|
+
klass = Bakong::OpenApi::DOMAIN_ERROR_CLASSES[error_code.to_i] || Bakong::OpenApi::Error
|
|
54
|
+
raise klass.new(message,
|
|
55
|
+
response_code: response_code, error_code: error_code,
|
|
56
|
+
response_message: message, body: envelope)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../source_info"
|
|
5
|
+
|
|
6
|
+
module Bakong
|
|
7
|
+
module OpenApi
|
|
8
|
+
module Resources
|
|
9
|
+
# `POST /v1/generate_deeplink_by_qr` — turn a KHQR string into a short
|
|
10
|
+
# link the user's wallet app can open directly.
|
|
11
|
+
class Deeplinks < Base
|
|
12
|
+
# @param qr [String] KHQR payload (typically generated by the
|
|
13
|
+
# bakong-khqr gem)
|
|
14
|
+
# @param source_info [Bakong::OpenApi::SourceInfo, nil] optional; if
|
|
15
|
+
# provided, all three fields must be set
|
|
16
|
+
# @return [Hash] { short_link: "https://..." }
|
|
17
|
+
# @raise [Bakong::OpenApi::MissingFieldsError] if source_info is
|
|
18
|
+
# present but incomplete
|
|
19
|
+
# @raise [Bakong::OpenApi::DeeplinkProviderError] on upstream failure
|
|
20
|
+
def generate(qr:, source_info: nil)
|
|
21
|
+
if source_info && !source_info.complete?
|
|
22
|
+
raise MissingFieldsError.new(
|
|
23
|
+
"source_info requires app_icon_url, app_name, and app_deep_link_callback",
|
|
24
|
+
error_code: 5
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
body = { qr: qr }
|
|
29
|
+
body[:source_info] = source_info.to_payload if source_info
|
|
30
|
+
|
|
31
|
+
submit("/v1/generate_deeplink_by_qr", body)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Bakong
|
|
6
|
+
module OpenApi
|
|
7
|
+
module Resources
|
|
8
|
+
# `POST /v1/renew_token` — exchange a registered email address for a
|
|
9
|
+
# short-lived JWT access token. The token is also delivered to the
|
|
10
|
+
# registered inbox; the API returns it inline on success.
|
|
11
|
+
class Tokens < Base
|
|
12
|
+
# @param email [String] registered Bakong developer email (max 30 chars)
|
|
13
|
+
# @return [Hash] { token: "..." }
|
|
14
|
+
# @raise [Bakong::OpenApi::Error] on any non-success responseCode
|
|
15
|
+
def renew(email:)
|
|
16
|
+
submit("/v1/renew_token", { email: email })
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Bakong
|
|
6
|
+
module OpenApi
|
|
7
|
+
module Resources
|
|
8
|
+
# Bakong transaction lookups. Five single-record endpoints + two batch
|
|
9
|
+
# endpoints. Single-record lookups return the transaction Hash on
|
|
10
|
+
# success, `nil` when the API reports errorCode 1 (not found), and
|
|
11
|
+
# raise on any other failure. Batch endpoints return the full array of
|
|
12
|
+
# per-item results so the caller can introspect statuses.
|
|
13
|
+
class Transactions < Base
|
|
14
|
+
# Single transaction by MD5 of the KHQR string.
|
|
15
|
+
def check_by_md5(md5:)
|
|
16
|
+
submit("/v1/check_transaction_by_md5", { md5: md5 }, treat_response_code_1_as: :not_found)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Single transaction by 64-char full hash.
|
|
20
|
+
def check_by_hash(hash:)
|
|
21
|
+
submit("/v1/check_transaction_by_hash", { hash: hash }, treat_response_code_1_as: :not_found)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Single transaction by 8-char short hash. Requires amount + currency
|
|
25
|
+
# to disambiguate.
|
|
26
|
+
# @param currency [String] "USD" or "KHR"
|
|
27
|
+
def check_by_short_hash(hash:, amount:, currency:)
|
|
28
|
+
submit(
|
|
29
|
+
"/v1/check_transaction_by_short_hash",
|
|
30
|
+
{ hash: hash, amount: amount, currency: currency },
|
|
31
|
+
treat_response_code_1_as: :not_found
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Single transaction by instruction reference (1-35 chars).
|
|
36
|
+
def check_by_instruction_ref(instruction_ref:)
|
|
37
|
+
submit(
|
|
38
|
+
"/v1/check_transaction_by_instruction_ref",
|
|
39
|
+
{ instruction_ref: instruction_ref },
|
|
40
|
+
treat_response_code_1_as: :not_found
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Single transaction by external reference / merchant ID (1-35 chars).
|
|
45
|
+
def check_by_external_ref(external_ref:)
|
|
46
|
+
submit(
|
|
47
|
+
"/v1/check_transaction_by_external_ref",
|
|
48
|
+
{ external_ref: external_ref },
|
|
49
|
+
treat_response_code_1_as: :not_found
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Batch lookup by up to 50 MD5 hashes. Returns the full envelope so
|
|
54
|
+
# callers can iterate per-row statuses (SUCCESS / NOT_FOUND / STATIC_QR).
|
|
55
|
+
# @param md5s [Array<String>]
|
|
56
|
+
# @return [Hash] full snake_cased envelope; the [:data] key is an Array
|
|
57
|
+
def check_by_md5_list(md5s:)
|
|
58
|
+
validate_batch_size!(md5s, "md5s")
|
|
59
|
+
envelope = connection.post("/v1/check_transaction_by_md5_list", body: md5s)
|
|
60
|
+
Helpers::CaseHelper.to_snake(envelope)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Batch lookup by up to 50 full hashes. Returns the full envelope so
|
|
64
|
+
# callers can iterate per-row statuses (SUCCESS / NOT_FOUND / FAILED).
|
|
65
|
+
# @param hashes [Array<String>]
|
|
66
|
+
# @return [Hash] full snake_cased envelope; the [:data] key is an Array
|
|
67
|
+
def check_by_hash_list(hashes:)
|
|
68
|
+
validate_batch_size!(hashes, "hashes")
|
|
69
|
+
envelope = connection.post("/v1/check_transaction_by_hash_list", body: hashes)
|
|
70
|
+
Helpers::CaseHelper.to_snake(envelope)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def validate_batch_size!(array, field_name)
|
|
76
|
+
raise ArgumentError, "#{field_name} must be an Array" unless array.is_a?(Array)
|
|
77
|
+
raise ArgumentError, "#{field_name} cannot be empty" if array.empty?
|
|
78
|
+
raise ArgumentError, "#{field_name} cannot exceed 50 items (got #{array.size})" if array.size > 50
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bakong
|
|
4
|
+
module OpenApi
|
|
5
|
+
# Optional metadata sent alongside a deep-link generation request so the
|
|
6
|
+
# receiving wallet can render the originating app's icon and name.
|
|
7
|
+
SourceInfo = Struct.new(:app_icon_url, :app_name, :app_deep_link_callback, keyword_init: true) do
|
|
8
|
+
def complete?
|
|
9
|
+
[app_icon_url, app_name, app_deep_link_callback].all? { |v| v.is_a?(String) && !v.empty? }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_payload
|
|
13
|
+
{
|
|
14
|
+
app_icon_url: app_icon_url,
|
|
15
|
+
app_name: app_name,
|
|
16
|
+
app_deep_link_callback: app_deep_link_callback
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "open_api/version"
|
|
4
|
+
require_relative "open_api/error"
|
|
5
|
+
require_relative "open_api/configuration"
|
|
6
|
+
require_relative "open_api/connection"
|
|
7
|
+
require_relative "open_api/source_info"
|
|
8
|
+
require_relative "open_api/client"
|
|
9
|
+
|
|
10
|
+
module Bakong
|
|
11
|
+
# Unofficial Ruby client for the Bakong Open API (National Bank of Cambodia).
|
|
12
|
+
# This gem is not an official SDK from the NBC — it is implemented against
|
|
13
|
+
# the public API documentation at https://api-bakong.nbc.gov.kh/document.
|
|
14
|
+
module OpenApi
|
|
15
|
+
class << self
|
|
16
|
+
# Convenience constructor: `Bakong::OpenApi.client(token: "...")`.
|
|
17
|
+
def client(**kwargs)
|
|
18
|
+
Client.new(**kwargs)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bakong-open-api
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Vandy Sodanheang
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-05-16 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
An unofficial, community-maintained Ruby client for the Bakong Open API
|
|
14
|
+
published by the National Bank of Cambodia. This gem is NOT an official
|
|
15
|
+
SDK from the NBC or any Bakong-affiliated entity — it is implemented
|
|
16
|
+
independently against the public API documentation available at
|
|
17
|
+
https://api-bakong.nbc.gov.kh/document.
|
|
18
|
+
|
|
19
|
+
Authenticate, manage tokens, look up Bakong accounts, verify transactions
|
|
20
|
+
by md5/hash/short-hash/external ref/instruction ref, and generate KHQR
|
|
21
|
+
deep links. Zero runtime gem dependencies — uses only the Ruby standard
|
|
22
|
+
library (Net::HTTP).
|
|
23
|
+
email:
|
|
24
|
+
- vandysodanheang@gmail.com
|
|
25
|
+
executables: []
|
|
26
|
+
extensions: []
|
|
27
|
+
extra_rdoc_files: []
|
|
28
|
+
files:
|
|
29
|
+
- CHANGELOG.md
|
|
30
|
+
- LICENSE.txt
|
|
31
|
+
- README.md
|
|
32
|
+
- bakong-open-api.gemspec
|
|
33
|
+
- lib/bakong-open-api.rb
|
|
34
|
+
- lib/bakong/open_api.rb
|
|
35
|
+
- lib/bakong/open_api/client.rb
|
|
36
|
+
- lib/bakong/open_api/configuration.rb
|
|
37
|
+
- lib/bakong/open_api/connection.rb
|
|
38
|
+
- lib/bakong/open_api/error.rb
|
|
39
|
+
- lib/bakong/open_api/helpers/case_helper.rb
|
|
40
|
+
- lib/bakong/open_api/resources/accounts.rb
|
|
41
|
+
- lib/bakong/open_api/resources/base.rb
|
|
42
|
+
- lib/bakong/open_api/resources/deeplinks.rb
|
|
43
|
+
- lib/bakong/open_api/resources/tokens.rb
|
|
44
|
+
- lib/bakong/open_api/resources/transactions.rb
|
|
45
|
+
- lib/bakong/open_api/source_info.rb
|
|
46
|
+
- lib/bakong/open_api/version.rb
|
|
47
|
+
homepage: https://github.com/VandyTheCoder/bakong-open-api-ruby
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata:
|
|
51
|
+
homepage_uri: https://github.com/VandyTheCoder/bakong-open-api-ruby
|
|
52
|
+
changelog_uri: https://github.com/VandyTheCoder/bakong-open-api-ruby/blob/main/CHANGELOG.md
|
|
53
|
+
bug_tracker_uri: https://github.com/VandyTheCoder/bakong-open-api-ruby/issues
|
|
54
|
+
rdoc_options: []
|
|
55
|
+
require_paths:
|
|
56
|
+
- lib
|
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: 3.4.1
|
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0'
|
|
67
|
+
requirements: []
|
|
68
|
+
rubygems_version: 3.6.2
|
|
69
|
+
specification_version: 4
|
|
70
|
+
summary: Unofficial Ruby client for the Bakong Open API (National Bank of Cambodia).
|
|
71
|
+
test_files: []
|