bakong-khqr 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 +17 -0
- data/LICENSE.txt +26 -0
- data/README.md +220 -0
- data/bakong-khqr.gemspec +35 -0
- data/lib/bakong/khqr/constants.rb +80 -0
- data/lib/bakong/khqr/controllers/decode.rb +94 -0
- data/lib/bakong/khqr/controllers/decode_non_khqr.rb +84 -0
- data/lib/bakong/khqr/controllers/decode_validation.rb +119 -0
- data/lib/bakong/khqr/controllers/generate.rb +155 -0
- data/lib/bakong/khqr/crc16.rb +56 -0
- data/lib/bakong/khqr/error.rb +18 -0
- data/lib/bakong/khqr/error_codes.rb +70 -0
- data/lib/bakong/khqr/helpers/check_account_id.rb +36 -0
- data/lib/bakong/khqr/helpers/cut_string.rb +22 -0
- data/lib/bakong/khqr/helpers/deep_link.rb +36 -0
- data/lib/bakong/khqr/helpers/http.rb +48 -0
- data/lib/bakong/khqr/individual_info.rb +58 -0
- data/lib/bakong/khqr/khqr_subtag.rb +43 -0
- data/lib/bakong/khqr/khqr_tag.rb +39 -0
- data/lib/bakong/khqr/merchant_code/additional_data.rb +120 -0
- data/lib/bakong/khqr/merchant_code/country_code.rb +21 -0
- data/lib/bakong/khqr/merchant_code/crc.rb +20 -0
- data/lib/bakong/khqr/merchant_code/global_unique_identifier.rb +112 -0
- data/lib/bakong/khqr/merchant_code/merchant_category_code.rb +29 -0
- data/lib/bakong/khqr/merchant_code/merchant_city.rb +21 -0
- data/lib/bakong/khqr/merchant_code/merchant_information_language_template.rb +80 -0
- data/lib/bakong/khqr/merchant_code/merchant_name.rb +21 -0
- data/lib/bakong/khqr/merchant_code/payload_format_indicator.rb +20 -0
- data/lib/bakong/khqr/merchant_code/point_of_initiation_method.rb +23 -0
- data/lib/bakong/khqr/merchant_code/time_stamp.rb +53 -0
- data/lib/bakong/khqr/merchant_code/transaction_amount.rb +26 -0
- data/lib/bakong/khqr/merchant_code/transaction_currency.rb +27 -0
- data/lib/bakong/khqr/merchant_code/unionpay_merchant_account.rb +22 -0
- data/lib/bakong/khqr/merchant_info.rb +30 -0
- data/lib/bakong/khqr/source_info.rb +22 -0
- data/lib/bakong/khqr/tag_length_string.rb +24 -0
- data/lib/bakong/khqr/version.rb +7 -0
- data/lib/bakong/khqr.rb +114 -0
- data/lib/bakong-khqr.rb +3 -0
- metadata +90 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 58040c211ace06a112a68a19d0f74a3ab8e5ec932c86941b6b3e5c44b3b3aa0c
|
|
4
|
+
data.tar.gz: a5fb9615b8525c7b9ddc7c86ddb39b0e3eb014e0ea8a63f6127682282a3d0205
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fcaf93cd86a3fe0b65defd14dbc4f0dce62b8216bcab6264a43875985b7b2494bd92bc8724773f494f6135c5d2543c3fde517fbdd608095f37c76020682c7f2b
|
|
7
|
+
data.tar.gz: 957b4cdf11f5206751feb7d6143fb492e55b50ac6da831e907ea09553590e0310cf64d78fd610f075aa4e6cdb2616c94fb6e71eb5fd8f7b2da9c6f3382ba973c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **bakong-khqr** 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
|
+
## [0.1.0] - 2026-05-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Initial Ruby port of the official [bakong-khqr](https://www.npmjs.com/package/bakong-khqr) JavaScript SDK v1.0.20.
|
|
12
|
+
- `Bakong::Khqr.generate_individual` and `.generate_merchant` for KHQR string + MD5 generation.
|
|
13
|
+
- `Bakong::Khqr.decode` and `.decode_non_khqr` for parsing KHQR (and arbitrary EMVCo TLV) payloads.
|
|
14
|
+
- `Bakong::Khqr.verify` for CRC-16/CCITT-FALSE checksum validation.
|
|
15
|
+
- `Bakong::Khqr.check_bakong_account` and `.generate_deep_link` Bakong Open API clients (`Net::HTTP`, zero gem deps).
|
|
16
|
+
- `Bakong::Khqr::IndividualInfo`, `MerchantInfo`, `SourceInfo`, and `CURRENCY` constants.
|
|
17
|
+
- RSpec parity test suite ported from the upstream npm package's Jest fixtures.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vandy Sodanheang
|
|
4
|
+
|
|
5
|
+
This Ruby gem is a port of the bakong-khqr npm package by Devit Huotkeo
|
|
6
|
+
(https://www.npmjs.com/package/bakong-khqr), originally licensed under ISC.
|
|
7
|
+
All original copyright notices apply to the algorithms and data structures
|
|
8
|
+
derived from that work.
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# bakong-khqr
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/bakong-khqr)
|
|
4
|
+
|
|
5
|
+
A Ruby SDK for **KHQR** — the centralized QR Code used by every mobile banking
|
|
6
|
+
app in Cambodia. Generate, decode, and verify KHQR payloads, and talk to the
|
|
7
|
+
Bakong Open API.
|
|
8
|
+
|
|
9
|
+
This is a Ruby port of the official
|
|
10
|
+
[`bakong-khqr`](https://www.npmjs.com/package/bakong-khqr) JavaScript SDK by
|
|
11
|
+
Devit Huotkeo. The public API mirrors the npm package one-to-one; only the
|
|
12
|
+
naming has been Ruby-fied (snake_case methods and hash keys). Zero runtime gem
|
|
13
|
+
dependencies — uses only the Ruby standard library.
|
|
14
|
+
|
|
15
|
+
> **KHQR ~ Scan. Pay. Done.**
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Ruby `>= 3.4.1`
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Add to your Gemfile:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
gem "bakong-khqr"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or install directly:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
gem install bakong-khqr
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then require:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
require "bakong/khqr"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
### Generate an Individual KHQR
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
require "bakong/khqr"
|
|
47
|
+
|
|
48
|
+
info = Bakong::Khqr::IndividualInfo.new(
|
|
49
|
+
bakong_account_id: "vandy@aclb",
|
|
50
|
+
merchant_name: "Vandy Sodanheang",
|
|
51
|
+
merchant_city: "Phnom Penh",
|
|
52
|
+
currency: Bakong::Khqr::CURRENCY[:khr],
|
|
53
|
+
amount: 50_000,
|
|
54
|
+
bill_number: "INV-2026-0001",
|
|
55
|
+
mobile_number: "85512345678",
|
|
56
|
+
store_label: "BKK-1",
|
|
57
|
+
terminal_label: "Counter-1",
|
|
58
|
+
expiration_timestamp: (Time.now.to_f * 1000).to_i + 5 * 60 * 1000 # 5 min
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
result = Bakong::Khqr.generate_individual(info)
|
|
62
|
+
result[:qr] # → "00020101021229180014vandy@aclb52045999..."
|
|
63
|
+
result[:md5] # → MD5 digest of the QR string
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
For static QRs (no amount, no expiration), simply omit `:amount`.
|
|
67
|
+
|
|
68
|
+
### Generate a Merchant KHQR
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
info = Bakong::Khqr::MerchantInfo.new(
|
|
72
|
+
bakong_account_id: "vandy@aclb",
|
|
73
|
+
merchant_name: "Sodanheang Coffee",
|
|
74
|
+
merchant_city: "Phnom Penh",
|
|
75
|
+
merchant_id: "1234567890",
|
|
76
|
+
acquiring_bank: "ACLEDA Bank",
|
|
77
|
+
currency: Bakong::Khqr::CURRENCY[:usd],
|
|
78
|
+
amount: 1.50,
|
|
79
|
+
expiration_timestamp: (Time.now.to_f * 1000).to_i + 5 * 60 * 1000
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
Bakong::Khqr.generate_merchant(info)
|
|
83
|
+
# → { qr: "...", md5: "..." }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Verify a KHQR
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
Bakong::Khqr.verify(qr_string) # → true | false
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Returns `true` only when both the trailing CRC-16/CCITT-FALSE checksum and the
|
|
93
|
+
per-tag validation pass.
|
|
94
|
+
|
|
95
|
+
### Decode a KHQR
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
decoded = Bakong::Khqr.decode(qr_string)
|
|
99
|
+
# → {
|
|
100
|
+
# merchant_type: "29",
|
|
101
|
+
# bakong_account_id: "vandy@aclb",
|
|
102
|
+
# account_information: nil,
|
|
103
|
+
# payload_format_indicator: "01",
|
|
104
|
+
# point_of_initiation_method: "12",
|
|
105
|
+
# merchant_category_code: "5999",
|
|
106
|
+
# transaction_currency: "116",
|
|
107
|
+
# transaction_amount: "50000",
|
|
108
|
+
# country_code: "KH",
|
|
109
|
+
# merchant_name: "Vandy Sodanheang",
|
|
110
|
+
# merchant_city: "Phnom Penh",
|
|
111
|
+
# bill_number: "INV-2026-0001",
|
|
112
|
+
# mobile_number: "85512345678",
|
|
113
|
+
# store_label: "BKK-1",
|
|
114
|
+
# terminal_label: "Counter-1",
|
|
115
|
+
# purpose_of_transaction: nil,
|
|
116
|
+
# language_preference: nil,
|
|
117
|
+
# merchant_name_alternate_language: nil,
|
|
118
|
+
# merchant_city_alternate_language: nil,
|
|
119
|
+
# creation_timestamp: "1747...",
|
|
120
|
+
# expiration_timestamp: "1747...",
|
|
121
|
+
# crc: "A586"
|
|
122
|
+
# }
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Use `Bakong::Khqr.decode_non_khqr(qr_string)` to decode arbitrary EMVCo TLV QRs
|
|
126
|
+
(returns a string-keyed hash with up to three levels of nesting).
|
|
127
|
+
|
|
128
|
+
### Check whether a Bakong account exists
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
result = Bakong::Khqr.check_bakong_account(
|
|
132
|
+
"https://api-bakong.nbc.gov.kh/v1/check_bakong_account",
|
|
133
|
+
"vandy@aclb"
|
|
134
|
+
)
|
|
135
|
+
# → { bakong_account_existed: true }
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Generate a deep link
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
source = Bakong::Khqr::SourceInfo.new(
|
|
142
|
+
app_icon_url: "https://yourapp.example/icon.png",
|
|
143
|
+
app_name: "Your App",
|
|
144
|
+
app_deep_link_callback: "yourapp://payment-result"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
Bakong::Khqr.generate_deep_link(
|
|
148
|
+
"https://api-bakong.nbc.gov.kh/v1/generate_deeplink_by_qr",
|
|
149
|
+
qr_string,
|
|
150
|
+
source_info: source
|
|
151
|
+
)
|
|
152
|
+
# → { short_link: "https://bakong.link/abc123" }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`source_info` is optional; pass `nil` to skip it. When provided, all three
|
|
156
|
+
fields are required.
|
|
157
|
+
|
|
158
|
+
## Error handling
|
|
159
|
+
|
|
160
|
+
All validation and transport errors raise `Bakong::Khqr::Error`, which carries
|
|
161
|
+
the upstream numeric error code in `#code` and a human-readable `#message`:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
begin
|
|
165
|
+
Bakong::Khqr.generate_individual(info)
|
|
166
|
+
rescue Bakong::Khqr::Error => e
|
|
167
|
+
puts "code=#{e.code} message=#{e.message}"
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Error codes (1–51) are kept identical to the upstream JavaScript SDK so that
|
|
172
|
+
existing dashboards and i18n strings keyed off them continue to work. See
|
|
173
|
+
[lib/bakong/khqr/error_codes.rb](lib/bakong/khqr/error_codes.rb) for the full
|
|
174
|
+
list.
|
|
175
|
+
|
|
176
|
+
## API mapping (npm → gem)
|
|
177
|
+
|
|
178
|
+
| JavaScript | Ruby |
|
|
179
|
+
| ------------------------------------------------ | ------------------------------------------------------- |
|
|
180
|
+
| `BakongKHQR.prototype.generateIndividual(info)` | `Bakong::Khqr.generate_individual(info)` |
|
|
181
|
+
| `BakongKHQR.prototype.generateMerchant(info)` | `Bakong::Khqr.generate_merchant(info)` |
|
|
182
|
+
| `BakongKHQR.decode(qr)` | `Bakong::Khqr.decode(qr)` |
|
|
183
|
+
| `BakongKHQR.decodeNonKhqr(qr)` | `Bakong::Khqr.decode_non_khqr(qr)` |
|
|
184
|
+
| `BakongKHQR.verify(qr).isValid` | `Bakong::Khqr.verify(qr)` |
|
|
185
|
+
| `BakongKHQR.checkBakongAccount(url, id)` | `Bakong::Khqr.check_bakong_account(url, id)` |
|
|
186
|
+
| `BakongKHQR.generateDeepLink(url, qr, source)` | `Bakong::Khqr.generate_deep_link(url, qr, source_info:)`|
|
|
187
|
+
| `new IndividualInfo(id, name, city, optional)` | `Bakong::Khqr::IndividualInfo.new(...)` (keyword args) |
|
|
188
|
+
| `new MerchantInfo(id, name, city, mid, ab, opt)` | `Bakong::Khqr::MerchantInfo.new(...)` (keyword args) |
|
|
189
|
+
| `new SourceInfo(icon, name, cb)` | `Bakong::Khqr::SourceInfo.new(...)` (keyword args) |
|
|
190
|
+
| `khqrData.currency.{khr,usd}` | `Bakong::Khqr::CURRENCY[:khr]`, `Bakong::Khqr::CURRENCY[:usd]` |
|
|
191
|
+
|
|
192
|
+
## Development
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
bin/setup # bundle install
|
|
196
|
+
bundle exec rspec
|
|
197
|
+
bundle exec rake # default task = spec
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
To open a console with the gem loaded:
|
|
201
|
+
|
|
202
|
+
```sh
|
|
203
|
+
bin/console
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Contributing
|
|
207
|
+
|
|
208
|
+
Issues and pull requests are welcome at
|
|
209
|
+
<https://github.com/VandyTheCoder/bakong-khqr-ruby>.
|
|
210
|
+
|
|
211
|
+
## Credits
|
|
212
|
+
|
|
213
|
+
- Upstream JavaScript SDK: [bakong-khqr](https://www.npmjs.com/package/bakong-khqr) by [Devit Huotkeo](https://github.com/davidhuotkeo).
|
|
214
|
+
- KHQR specification: [National Bank of Cambodia](https://bakong.nbc.gov.kh/).
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT — see [LICENSE.txt](LICENSE.txt). All algorithm and data-structure
|
|
219
|
+
copyright from the upstream `bakong-khqr` npm package (ISC license) is
|
|
220
|
+
preserved per its terms.
|
data/bakong-khqr.gemspec
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/bakong/khqr/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "bakong-khqr"
|
|
7
|
+
spec.version = Bakong::Khqr::VERSION
|
|
8
|
+
spec.authors = ["Vandy Sodanheang"]
|
|
9
|
+
spec.email = ["vandysodanheang@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Ruby SDK for KHQR (Khmer QR Code) — generate, decode, and verify Bakong KHQR strings."
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
A Ruby port of the official bakong-khqr JavaScript SDK
|
|
14
|
+
(https://www.npmjs.com/package/bakong-khqr) by Devit Huotkeo.
|
|
15
|
+
|
|
16
|
+
Generate Individual and Merchant KHQR payloads, decode existing KHQR strings,
|
|
17
|
+
verify the embedded CRC-16/CCITT-FALSE checksum, and call the Bakong Open API
|
|
18
|
+
to check accounts and produce deep links. Zero runtime gem dependencies — uses
|
|
19
|
+
only the Ruby standard library.
|
|
20
|
+
DESC
|
|
21
|
+
spec.homepage = "https://github.com/VandyTheCoder/bakong-khqr-ruby"
|
|
22
|
+
spec.license = "MIT"
|
|
23
|
+
spec.required_ruby_version = ">= 3.4.1"
|
|
24
|
+
|
|
25
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
26
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
27
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
28
|
+
|
|
29
|
+
spec.files = Dir.chdir(__dir__) do
|
|
30
|
+
Dir["lib/**/*.rb", "*.md", "LICENSE.txt", "bakong-khqr.gemspec"]
|
|
31
|
+
end
|
|
32
|
+
spec.require_paths = ["lib"]
|
|
33
|
+
spec.bindir = "exe"
|
|
34
|
+
spec.executables = []
|
|
35
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bakong
|
|
4
|
+
module Khqr
|
|
5
|
+
CURRENCY = {
|
|
6
|
+
usd: 840,
|
|
7
|
+
khr: 116
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
MERCHANT_TYPE = {
|
|
11
|
+
merchant: "merchant",
|
|
12
|
+
individual: "individual"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
EMV = {
|
|
16
|
+
PAYLOAD_FORMAT_INDICATOR: "00",
|
|
17
|
+
DEFAULT_PAYLOAD_FORMAT_INDICATOR: "01",
|
|
18
|
+
POINT_OF_INITIATION_METHOD: "01",
|
|
19
|
+
STATIC_QR: "11",
|
|
20
|
+
DYNAMIC_QR: "12",
|
|
21
|
+
MERCHANT_ACCOUNT_INFORMATION_INDIVIDUAL: "29",
|
|
22
|
+
MERCHANT_ACCOUNT_INFORMATION_MERCHANT: "30",
|
|
23
|
+
BAKONG_ACCOUNT_IDENTIFIER: "00",
|
|
24
|
+
MERCHANT_ACCOUNT_INFORMATION_MERCHANT_ID: "01",
|
|
25
|
+
INDIVIDUAL_ACCOUNT_INFORMATION: "01",
|
|
26
|
+
MERCHANT_ACCOUNT_INFORMATION_ACQUIRING_BANK: "02",
|
|
27
|
+
MERCHANT_CATEGORY_CODE: "52",
|
|
28
|
+
DEFAULT_MERCHANT_CATEGORY_CODE: "5999",
|
|
29
|
+
TRANSACTION_CURRENCY: "53",
|
|
30
|
+
TRANSACTION_AMOUNT: "54",
|
|
31
|
+
DEFAULT_TRANSACTION_AMOUNT: "0",
|
|
32
|
+
COUNTRY_CODE: "58",
|
|
33
|
+
DEFAULT_COUNTRY_CODE: "KH",
|
|
34
|
+
MERCHANT_NAME: "59",
|
|
35
|
+
MERCHANT_CITY: "60",
|
|
36
|
+
DEFAULT_MERCHANT_CITY: "Phnom Penh",
|
|
37
|
+
CRC: "63",
|
|
38
|
+
CRC_LENGTH: "04",
|
|
39
|
+
ADDITIONAL_DATA_TAG: "62",
|
|
40
|
+
BILLNUMBER_TAG: "01",
|
|
41
|
+
ADDITIONAL_DATA_FIELD_MOBILE_NUMBER: "02",
|
|
42
|
+
STORELABEL_TAG: "03",
|
|
43
|
+
TERMINAL_TAG: "07",
|
|
44
|
+
PURPOSE_OF_TRANSACTION: "08",
|
|
45
|
+
TIMESTAMP_TAG: "99",
|
|
46
|
+
CREATION_TIMESTAMP: "00",
|
|
47
|
+
EXPIRATION_TIMESTAMP: "01",
|
|
48
|
+
MERCHANT_INFORMATION_LANGUAGE_TEMPLATE: "64",
|
|
49
|
+
LANGUAGE_PREFERENCE: "00",
|
|
50
|
+
MERCHANT_NAME_ALTERNATE_LANGUAGE: "01",
|
|
51
|
+
MERCHANT_CITY_ALTERNATE_LANGUAGE: "02",
|
|
52
|
+
UNIONPAY_MERCHANT_ACCOUNT: "15",
|
|
53
|
+
INVALID_LENGTH: {
|
|
54
|
+
KHQR: 12,
|
|
55
|
+
MERCHANT_NAME: 25,
|
|
56
|
+
BAKONG_ACCOUNT: 32,
|
|
57
|
+
AMOUNT: 13,
|
|
58
|
+
COUNTRY_CODE: 3,
|
|
59
|
+
MERCHANT_CATEGORY_CODE: 4,
|
|
60
|
+
MERCHANT_CITY: 15,
|
|
61
|
+
TIMESTAMP: 13,
|
|
62
|
+
TRANSACTION_AMOUNT: 14,
|
|
63
|
+
TRANSACTION_CURRENCY: 3,
|
|
64
|
+
BILL_NUMBER: 25,
|
|
65
|
+
STORE_LABEL: 25,
|
|
66
|
+
TERMINAL_LABEL: 25,
|
|
67
|
+
PURPOSE_OF_TRANSACTION: 25,
|
|
68
|
+
MERCHANT_ID: 32,
|
|
69
|
+
ACQUIRING_BANK: 32,
|
|
70
|
+
MOBILE_NUMBER: 25,
|
|
71
|
+
ACCOUNT_INFORMATION: 32,
|
|
72
|
+
MERCHANT_INFORMATION_LANGUAGE_TEMPLATE: 99,
|
|
73
|
+
UPI_MERCHANT: 99,
|
|
74
|
+
LANGUAGE_PREFERENCE: 2,
|
|
75
|
+
MERCHANT_NAME_ALTERNATE_LANGUAGE: 25,
|
|
76
|
+
MERCHANT_CITY_ALTERNATE_LANGUAGE: 15
|
|
77
|
+
}.freeze
|
|
78
|
+
}.freeze
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../constants"
|
|
4
|
+
require_relative "../khqr_tag"
|
|
5
|
+
require_relative "../khqr_subtag"
|
|
6
|
+
require_relative "../helpers/cut_string"
|
|
7
|
+
|
|
8
|
+
module Bakong
|
|
9
|
+
module Khqr
|
|
10
|
+
module Controllers
|
|
11
|
+
# Parses a KHQR string into a snake_case symbol-keyed Hash. Tag 30
|
|
12
|
+
# (merchant) is normalized to tag 29 in the output so downstream
|
|
13
|
+
# consumers only need to handle one merchant_type discriminator.
|
|
14
|
+
module Decode
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def call(khqr_string)
|
|
18
|
+
all_fields = KHQR_TAG.map { |el| el[:tag] }
|
|
19
|
+
subtag_set = KHQR_TAG.select { |el| el[:sub] }.map { |el| el[:tag] }
|
|
20
|
+
sub_tag_input = KHQR_SUBTAG[:input]
|
|
21
|
+
sub_tag_compare = KHQR_SUBTAG[:compare]
|
|
22
|
+
|
|
23
|
+
tags = {}
|
|
24
|
+
merchant_type = nil
|
|
25
|
+
last_tag = ""
|
|
26
|
+
is_merchant_tag = false
|
|
27
|
+
remainder = khqr_string
|
|
28
|
+
|
|
29
|
+
until remainder.nil? || remainder.empty?
|
|
30
|
+
slice = Helpers::CutString.call(remainder)
|
|
31
|
+
tag = slice[:tag]
|
|
32
|
+
value = slice[:value]
|
|
33
|
+
remainder = slice[:remainder]
|
|
34
|
+
|
|
35
|
+
break if tag == last_tag
|
|
36
|
+
|
|
37
|
+
if tag == "30"
|
|
38
|
+
merchant_type = "30"
|
|
39
|
+
tag = "29"
|
|
40
|
+
is_merchant_tag = true
|
|
41
|
+
elsif tag == "29"
|
|
42
|
+
merchant_type = "29"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
tags[tag] = value if all_fields.include?(tag)
|
|
46
|
+
last_tag = tag
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
decode_value = { merchant_type: merchant_type }
|
|
50
|
+
sub_tag_input.each { |el| decode_value.merge!(el[:data]) }
|
|
51
|
+
|
|
52
|
+
KHQR_TAG.each do |khqr_tag|
|
|
53
|
+
tag = khqr_tag[:tag]
|
|
54
|
+
value = tags[tag]
|
|
55
|
+
input_value = value
|
|
56
|
+
|
|
57
|
+
if subtag_set.include?(tag)
|
|
58
|
+
schema = sub_tag_input.find { |el| el[:tag] == tag }[:data]
|
|
59
|
+
input_data = deep_dup(schema)
|
|
60
|
+
|
|
61
|
+
while value && !value.empty?
|
|
62
|
+
cut = Helpers::CutString.call(value)
|
|
63
|
+
sub_tag = cut[:tag]
|
|
64
|
+
sub_value = cut[:value]
|
|
65
|
+
value = cut[:remainder]
|
|
66
|
+
|
|
67
|
+
name_subtag = sub_tag_compare
|
|
68
|
+
.select { |el| el[:tag] == tag }
|
|
69
|
+
.find { |el| el[:sub_tag] == sub_tag }
|
|
70
|
+
|
|
71
|
+
next unless name_subtag
|
|
72
|
+
|
|
73
|
+
name = name_subtag[:name]
|
|
74
|
+
name = :merchant_id if is_merchant_tag && name == :account_information
|
|
75
|
+
input_data[name] = sub_value
|
|
76
|
+
input_value = input_data
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
decode_value.merge!(input_value) if input_value.is_a?(Hash)
|
|
80
|
+
else
|
|
81
|
+
decode_value[khqr_tag[:type]] = value
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
decode_value
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def deep_dup(hash)
|
|
89
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k] = v.is_a?(Hash) ? deep_dup(v) : v }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bakong
|
|
4
|
+
module Khqr
|
|
5
|
+
module Controllers
|
|
6
|
+
# Generic EMVCo TLV decoder for QR strings that may or may not be KHQR.
|
|
7
|
+
# Walks up to three levels of nested TLV for the same composite-tag
|
|
8
|
+
# ranges the upstream JS package handles (26–51, 80–99, 62, 64).
|
|
9
|
+
module DecodeNonKHQR
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def call(qr)
|
|
13
|
+
first_level = {}
|
|
14
|
+
final_data = {}
|
|
15
|
+
remainder = qr
|
|
16
|
+
|
|
17
|
+
loop do
|
|
18
|
+
parsed = extract_tlv(remainder)
|
|
19
|
+
break unless valid?(parsed[:tag], parsed[:length], parsed[:value])
|
|
20
|
+
|
|
21
|
+
first_level[parsed[:tag]] = parsed[:value]
|
|
22
|
+
remainder = parsed[:remain]
|
|
23
|
+
break if remainder.nil? || remainder.empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
first_level.each do |tag, value|
|
|
27
|
+
final_data[tag] = value
|
|
28
|
+
next unless composite_tag?(tag)
|
|
29
|
+
next if value.length < 6
|
|
30
|
+
|
|
31
|
+
second_level = {}
|
|
32
|
+
remaining_value = value
|
|
33
|
+
|
|
34
|
+
loop do
|
|
35
|
+
third_level = {}
|
|
36
|
+
parsed = extract_tlv(remaining_value)
|
|
37
|
+
break unless valid?(parsed[:tag], parsed[:length], parsed[:value])
|
|
38
|
+
|
|
39
|
+
sub_tag = parsed[:tag]
|
|
40
|
+
sub_value = parsed[:value]
|
|
41
|
+
remaining_value = parsed[:remain]
|
|
42
|
+
|
|
43
|
+
if tag == "62" && (50..99).cover?(sub_tag.to_i)
|
|
44
|
+
inner = sub_value
|
|
45
|
+
loop do
|
|
46
|
+
inner_parsed = extract_tlv(inner)
|
|
47
|
+
break unless valid?(inner_parsed[:tag], inner_parsed[:length], inner_parsed[:value])
|
|
48
|
+
|
|
49
|
+
third_level[inner_parsed[:tag]] = inner_parsed[:value]
|
|
50
|
+
inner = inner_parsed[:remain]
|
|
51
|
+
break if inner.nil? || inner.empty?
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
second_level[sub_tag] = third_level.empty? ? sub_value : third_level
|
|
56
|
+
break if remaining_value.nil? || remaining_value.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
final_data[tag] = second_level unless second_level.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
final_data
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def extract_tlv(string)
|
|
66
|
+
tag = string[0, 2].to_s
|
|
67
|
+
length = string[2, 2].to_i
|
|
68
|
+
value = string[4, length].to_s
|
|
69
|
+
remain = string[(4 + length)..] || ""
|
|
70
|
+
{ tag: tag, length: length, value: value, remain: remain }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def composite_tag?(tag)
|
|
74
|
+
int = tag.to_i
|
|
75
|
+
(26..51).cover?(int) || (80..99).cover?(int) || tag == "64" || tag == "62"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def valid?(tag, length, value)
|
|
79
|
+
tag.match?(/\A\d+\z/) && length == value.length
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../constants"
|
|
4
|
+
require_relative "../error"
|
|
5
|
+
require_relative "../error_codes"
|
|
6
|
+
require_relative "../khqr_tag"
|
|
7
|
+
require_relative "../khqr_subtag"
|
|
8
|
+
require_relative "../helpers/cut_string"
|
|
9
|
+
|
|
10
|
+
module Bakong
|
|
11
|
+
module Khqr
|
|
12
|
+
module Controllers
|
|
13
|
+
# Walks a candidate KHQR string TLV-by-TLV, instantiating each tag's
|
|
14
|
+
# validator class. Used by `verify` after the CRC check passes.
|
|
15
|
+
module DecodeValidation
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def call(khqr_string)
|
|
19
|
+
all_fields = KHQR_TAG.map { |el| el[:tag] }
|
|
20
|
+
subtag_set = KHQR_TAG.select { |el| el[:sub] }.map { |el| el[:tag] }
|
|
21
|
+
required_fields = KHQR_TAG.select { |el| el[:required] }.map { |el| el[:tag] }
|
|
22
|
+
sub_tag_input = KHQR_SUBTAG[:input]
|
|
23
|
+
sub_tag_compare = KHQR_SUBTAG[:compare]
|
|
24
|
+
|
|
25
|
+
tags = []
|
|
26
|
+
merchant_type = "individual"
|
|
27
|
+
last_tag = ""
|
|
28
|
+
remainder = khqr_string
|
|
29
|
+
|
|
30
|
+
until remainder.nil? || remainder.empty?
|
|
31
|
+
slice = Helpers::CutString.call(remainder)
|
|
32
|
+
tag = slice[:tag]
|
|
33
|
+
value = slice[:value]
|
|
34
|
+
remainder = slice[:remainder]
|
|
35
|
+
|
|
36
|
+
break if tag == last_tag
|
|
37
|
+
|
|
38
|
+
if tag == "30"
|
|
39
|
+
merchant_type = "merchant"
|
|
40
|
+
tag = "29"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if all_fields.include?(tag)
|
|
44
|
+
tags << { tag: tag, value: value }
|
|
45
|
+
required_fields.delete(tag)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
last_tag = tag
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if tags.any? { |t| t[:tag] == "01" && t[:value] == "12" }
|
|
52
|
+
raise Error.from(ERROR_CODES[:INVALID_DYNAMIC_KHQR]) unless tags.any? { |t| t[:tag] == "54" }
|
|
53
|
+
raise Error.from(ERROR_CODES[:EXPIRATION_TIMESTAMP_REQUIRED]) unless tags.any? { |t| t[:tag] == "99" }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if tags.any? { |t| t[:tag] == "54" } && tags.none? { |t| t[:tag] == "99" }
|
|
57
|
+
raise Error.from(ERROR_CODES[:EXPIRATION_TIMESTAMP_REQUIRED])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
unless required_fields.empty?
|
|
61
|
+
required_tag = required_fields.first
|
|
62
|
+
missing_instance = KHQR_TAG.find { |el| el[:tag] == required_tag }[:instance]
|
|
63
|
+
missing_instance.new(required_tag, nil) # raises with the proper error
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
decode_value = { merchant_type: merchant_type }
|
|
67
|
+
sub_tag_input.each { |el| decode_value.merge!(el[:data]) }
|
|
68
|
+
|
|
69
|
+
poi = nil
|
|
70
|
+
tags.each do |khqr_tag|
|
|
71
|
+
tag = khqr_tag[:tag]
|
|
72
|
+
schema = KHQR_TAG.find { |el| el[:tag] == tag }
|
|
73
|
+
value = khqr_tag[:value]
|
|
74
|
+
input_value = value
|
|
75
|
+
poi = value if tag == EMV[:POINT_OF_INITIATION_METHOD]
|
|
76
|
+
|
|
77
|
+
if subtag_set.include?(tag)
|
|
78
|
+
input_data_template = sub_tag_input.find { |el| el[:tag] == tag }[:data]
|
|
79
|
+
input_data = deep_dup(input_data_template)
|
|
80
|
+
|
|
81
|
+
while value && !value.empty?
|
|
82
|
+
cut = Helpers::CutString.call(value)
|
|
83
|
+
sub_tag = cut[:tag]
|
|
84
|
+
sub_value = cut[:value]
|
|
85
|
+
value = cut[:remainder]
|
|
86
|
+
|
|
87
|
+
name_subtag = sub_tag_compare
|
|
88
|
+
.select { |el| el[:tag] == tag }
|
|
89
|
+
.find { |el| el[:sub_tag] == sub_tag }
|
|
90
|
+
|
|
91
|
+
next unless name_subtag
|
|
92
|
+
|
|
93
|
+
input_data[name_subtag[:name]] = sub_value
|
|
94
|
+
input_value = input_data
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
decode_value.merge!(input_value) if input_value.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
if tag == EMV[:TIMESTAMP_TAG]
|
|
100
|
+
schema[:instance].new(tag, input_value, poi)
|
|
101
|
+
else
|
|
102
|
+
schema[:instance].new(tag, input_value)
|
|
103
|
+
end
|
|
104
|
+
else
|
|
105
|
+
instance = schema[:instance].new(tag, input_value)
|
|
106
|
+
decode_value[schema[:type]] = instance.value
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
decode_value
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def deep_dup(hash)
|
|
114
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k] = v.is_a?(Hash) ? deep_dup(v) : v }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|