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 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
+ [![Gem Version](https://badge.fury.io/rb/bakong-open-api.svg?icon=si%3Arubygems)](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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bakong
4
+ module OpenApi
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bakong/open_api"
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: []