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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/LICENSE.txt +26 -0
  4. data/README.md +220 -0
  5. data/bakong-khqr.gemspec +35 -0
  6. data/lib/bakong/khqr/constants.rb +80 -0
  7. data/lib/bakong/khqr/controllers/decode.rb +94 -0
  8. data/lib/bakong/khqr/controllers/decode_non_khqr.rb +84 -0
  9. data/lib/bakong/khqr/controllers/decode_validation.rb +119 -0
  10. data/lib/bakong/khqr/controllers/generate.rb +155 -0
  11. data/lib/bakong/khqr/crc16.rb +56 -0
  12. data/lib/bakong/khqr/error.rb +18 -0
  13. data/lib/bakong/khqr/error_codes.rb +70 -0
  14. data/lib/bakong/khqr/helpers/check_account_id.rb +36 -0
  15. data/lib/bakong/khqr/helpers/cut_string.rb +22 -0
  16. data/lib/bakong/khqr/helpers/deep_link.rb +36 -0
  17. data/lib/bakong/khqr/helpers/http.rb +48 -0
  18. data/lib/bakong/khqr/individual_info.rb +58 -0
  19. data/lib/bakong/khqr/khqr_subtag.rb +43 -0
  20. data/lib/bakong/khqr/khqr_tag.rb +39 -0
  21. data/lib/bakong/khqr/merchant_code/additional_data.rb +120 -0
  22. data/lib/bakong/khqr/merchant_code/country_code.rb +21 -0
  23. data/lib/bakong/khqr/merchant_code/crc.rb +20 -0
  24. data/lib/bakong/khqr/merchant_code/global_unique_identifier.rb +112 -0
  25. data/lib/bakong/khqr/merchant_code/merchant_category_code.rb +29 -0
  26. data/lib/bakong/khqr/merchant_code/merchant_city.rb +21 -0
  27. data/lib/bakong/khqr/merchant_code/merchant_information_language_template.rb +80 -0
  28. data/lib/bakong/khqr/merchant_code/merchant_name.rb +21 -0
  29. data/lib/bakong/khqr/merchant_code/payload_format_indicator.rb +20 -0
  30. data/lib/bakong/khqr/merchant_code/point_of_initiation_method.rb +23 -0
  31. data/lib/bakong/khqr/merchant_code/time_stamp.rb +53 -0
  32. data/lib/bakong/khqr/merchant_code/transaction_amount.rb +26 -0
  33. data/lib/bakong/khqr/merchant_code/transaction_currency.rb +27 -0
  34. data/lib/bakong/khqr/merchant_code/unionpay_merchant_account.rb +22 -0
  35. data/lib/bakong/khqr/merchant_info.rb +30 -0
  36. data/lib/bakong/khqr/source_info.rb +22 -0
  37. data/lib/bakong/khqr/tag_length_string.rb +24 -0
  38. data/lib/bakong/khqr/version.rb +7 -0
  39. data/lib/bakong/khqr.rb +114 -0
  40. data/lib/bakong-khqr.rb +3 -0
  41. 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
+ [![Gem Version](https://badge.fury.io/rb/bakong-khqr.svg)](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.
@@ -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