Module: Bakong::Khqr

Defined in:
lib/bakong/khqr.rb,
lib/bakong/khqr/crc16.rb,
lib/bakong/khqr/error.rb,
lib/bakong/khqr/version.rb,
lib/bakong/khqr/khqr_tag.rb,
lib/bakong/khqr/constants.rb,
lib/bakong/khqr/error_codes.rb,
lib/bakong/khqr/khqr_subtag.rb,
lib/bakong/khqr/source_info.rb,
lib/bakong/khqr/helpers/http.rb,
lib/bakong/khqr/merchant_info.rb,
lib/bakong/khqr/individual_info.rb,
lib/bakong/khqr/helpers/deep_link.rb,
lib/bakong/khqr/merchant_code/crc.rb,
lib/bakong/khqr/tag_length_string.rb,
lib/bakong/khqr/controllers/decode.rb,
lib/bakong/khqr/helpers/cut_string.rb,
lib/bakong/khqr/controllers/generate.rb,
lib/bakong/khqr/helpers/check_account_id.rb,
lib/bakong/khqr/merchant_code/time_stamp.rb,
lib/bakong/khqr/merchant_code/country_code.rb,
lib/bakong/khqr/controllers/decode_non_khqr.rb,
lib/bakong/khqr/merchant_code/merchant_city.rb,
lib/bakong/khqr/merchant_code/merchant_name.rb,
lib/bakong/khqr/controllers/decode_validation.rb,
lib/bakong/khqr/merchant_code/additional_data.rb,
lib/bakong/khqr/merchant_code/transaction_amount.rb,
lib/bakong/khqr/merchant_code/transaction_currency.rb,
lib/bakong/khqr/merchant_code/merchant_category_code.rb,
lib/bakong/khqr/merchant_code/global_unique_identifier.rb,
lib/bakong/khqr/merchant_code/payload_format_indicator.rb,
lib/bakong/khqr/merchant_code/unionpay_merchant_account.rb,
lib/bakong/khqr/merchant_code/point_of_initiation_method.rb,
lib/bakong/khqr/merchant_code/merchant_information_language_template.rb

Overview

Top-level facade for the bakong-khqr SDK. Mirrors the public surface of the upstream JavaScript package (www.npmjs.com/package/bakong-khqr): generate, decode, verify, plus the Bakong Open API helpers.

Defined Under Namespace

Modules: CRC16, Controllers, Helpers, MerchantCode Classes: Error, IndividualInfo, MerchantInfo, SourceInfo, TagLengthString

Constant Summary collapse

CRC_REGEXP =
/6304[A-Fa-f0-9]{4}\z/
VERSION =
"0.1.1"
KHQR_TAG =

Authoritative ordered list of top-level EMVCo tags the KHQR spec accepts, paired with the TLV builder/validator class responsible for that tag.

[
  { tag: "00", type: :payload_format_indicator,                required: true,  instance: MerchantCode::PayloadFormatIndicator },
  { tag: "01", type: :point_of_initiation_method,              required: false, instance: MerchantCode::PointOfInitiationMethod },
  { tag: "15", type: :union_pay_merchant,                      required: false, instance: MerchantCode::UnionpayMerchantAccount },
  { tag: "29", type: :global_unique_identifier,                required: true,  sub: true, instance: MerchantCode::GlobalUniqueIdentifier },
  { tag: "52", type: :merchant_category_code,                  required: true,  instance: MerchantCode::MerchantCategoryCode },
  { tag: "53", type: :transaction_currency,                    required: true,  instance: MerchantCode::TransactionCurrency },
  { tag: "54", type: :transaction_amount,                      required: false, instance: MerchantCode::TransactionAmount },
  { tag: "58", type: :country_code,                            required: true,  instance: MerchantCode::CountryCode },
  { tag: "59", type: :merchant_name,                           required: true,  instance: MerchantCode::MerchantName },
  { tag: "60", type: :merchant_city,                           required: true,  instance: MerchantCode::MerchantCity },
  { tag: "62", type: :additional_data,                         required: false, sub: true, instance: MerchantCode::AdditionalData },
  { tag: "64", type: :merchant_information_language_template,  required: false, sub: true, instance: MerchantCode::MerchantInformationLanguageTemplate },
  { tag: "99", type: :timestamp,                               required: false, sub: true, instance: MerchantCode::TimeStamp },
  { tag: "63", type: :crc,                                     required: true,  instance: MerchantCode::CRC }
].freeze
CURRENCY =
{
  usd: 840,
  khr: 116
}.freeze
MERCHANT_TYPE =
{
  merchant: "merchant",
  individual: "individual"
}.freeze
EMV =
{
  PAYLOAD_FORMAT_INDICATOR: "00",
  DEFAULT_PAYLOAD_FORMAT_INDICATOR: "01",
  POINT_OF_INITIATION_METHOD: "01",
  STATIC_QR: "11",
  DYNAMIC_QR: "12",
  MERCHANT_ACCOUNT_INFORMATION_INDIVIDUAL: "29",
  MERCHANT_ACCOUNT_INFORMATION_MERCHANT: "30",
  BAKONG_ACCOUNT_IDENTIFIER: "00",
  MERCHANT_ACCOUNT_INFORMATION_MERCHANT_ID: "01",
  INDIVIDUAL_ACCOUNT_INFORMATION: "01",
  MERCHANT_ACCOUNT_INFORMATION_ACQUIRING_BANK: "02",
  MERCHANT_CATEGORY_CODE: "52",
  DEFAULT_MERCHANT_CATEGORY_CODE: "5999",
  TRANSACTION_CURRENCY: "53",
  TRANSACTION_AMOUNT: "54",
  DEFAULT_TRANSACTION_AMOUNT: "0",
  COUNTRY_CODE: "58",
  DEFAULT_COUNTRY_CODE: "KH",
  MERCHANT_NAME: "59",
  MERCHANT_CITY: "60",
  DEFAULT_MERCHANT_CITY: "Phnom Penh",
  CRC: "63",
  CRC_LENGTH: "04",
  ADDITIONAL_DATA_TAG: "62",
  BILLNUMBER_TAG: "01",
  ADDITIONAL_DATA_FIELD_MOBILE_NUMBER: "02",
  STORELABEL_TAG: "03",
  TERMINAL_TAG: "07",
  PURPOSE_OF_TRANSACTION: "08",
  TIMESTAMP_TAG: "99",
  CREATION_TIMESTAMP: "00",
  EXPIRATION_TIMESTAMP: "01",
  MERCHANT_INFORMATION_LANGUAGE_TEMPLATE: "64",
  LANGUAGE_PREFERENCE: "00",
  MERCHANT_NAME_ALTERNATE_LANGUAGE: "01",
  MERCHANT_CITY_ALTERNATE_LANGUAGE: "02",
  UNIONPAY_MERCHANT_ACCOUNT: "15",
  INVALID_LENGTH: {
    KHQR: 12,
    MERCHANT_NAME: 25,
    BAKONG_ACCOUNT: 32,
    AMOUNT: 13,
    COUNTRY_CODE: 3,
    MERCHANT_CATEGORY_CODE: 4,
    MERCHANT_CITY: 15,
    TIMESTAMP: 13,
    TRANSACTION_AMOUNT: 14,
    TRANSACTION_CURRENCY: 3,
    BILL_NUMBER: 25,
    STORE_LABEL: 25,
    TERMINAL_LABEL: 25,
    PURPOSE_OF_TRANSACTION: 25,
    MERCHANT_ID: 32,
    ACQUIRING_BANK: 32,
    MOBILE_NUMBER: 25,
    ACCOUNT_INFORMATION: 32,
    MERCHANT_INFORMATION_LANGUAGE_TEMPLATE: 99,
    UPI_MERCHANT: 99,
    LANGUAGE_PREFERENCE: 2,
    MERCHANT_NAME_ALTERNATE_LANGUAGE: 25,
    MERCHANT_CITY_ALTERNATE_LANGUAGE: 15
  }.freeze
}.freeze
ERROR_CODES =
{
  BAKONG_ACCOUNT_ID_REQUIRED: { code: 1, message: "Bakong Account ID cannot be null or empty" },
  MERCHANT_NAME_REQUIRED: { code: 2, message: "Merchant name cannot be null or empty" },
  BAKONG_ACCOUNT_ID_INVALID: { code: 3, message: "Bakong Account ID is invalid" },
  TRANSACTION_AMOUNT_INVALID: { code: 4, message: "Amount is invalid" },
  MERCHANT_TYPE_REQUIRED: { code: 5, message: "Merchant type cannot be null or empty" },
  BAKONG_ACCOUNT_ID_LENGTH_INVALID: { code: 6, message: "Bakong Account ID Length is Invalid" },
  MERCHANT_NAME_LENGTH_INVALID: { code: 7, message: "Merchant Name Length is invalid" },
  KHQR_INVALID: { code: 8, message: "KHQR provided is invalid" },
  CURRENCY_TYPE_REQUIRED: { code: 9, message: "Currency type cannot be null or empty" },
  BILL_NUMBER_LENGTH_INVALID: { code: 10, message: "Bill Name Length is invalid" },
  STORE_LABEL_LENGTH_INVALID: { code: 11, message: "Store Label Length is invalid" },
  TERMINAL_LABEL_LENGTH_INVALID: { code: 12, message: "Terminal Label Length is invalid" },
  CONNECTION_TIMEOUT: {
    code: 13,
    message: "Cannot reach Bakong Open API service. Please check internet connection"
  },
  INVALID_DEEP_LINK_SOURCE_INFO: { code: 14, message: "Source Info for Deep Link is invalid" },
  INTERNAL_SERVER_ERROR: { code: 15, message: "Internal server error" },
  PAYLOAD_FORMAT_INDICATOR_LENGTH_INVALID: { code: 16, message: "Payload Format indicator Length is invalid" },
  POINT_INITIATION_LENGTH_INVALID: { code: 17, message: "Point of initiation Length is invalid" },
  MERCHANT_CODE_LENGTH_INVALID: { code: 18, message: "Merchant code Length is invalid" },
  TRANSACTION_CURRENCY_LENGTH_INVALID: { code: 19, message: "Transaction currency Length is invalid" },
  COUNTRY_CODE_LENGTH_INVALID: { code: 20, message: "Country code Length is invalid" },
  MERCHANT_CITY_LENGTH_INVALID: { code: 21, message: "Merchant city Length is invalid" },
  CRC_LENGTH_INVALID: { code: 22, message: "CRC Length is invalid" },
  PAYLOAD_FORMAT_INDICATOR_TAG_REQUIRED: { code: 23, message: "Payload format indicator tag required" },
  CRC_TAG_REQUIRED: { code: 24, message: "CRC tag required" },
  MERCHANT_CATEGORY_TAG_REQUIRED: { code: 25, message: "Merchant category tag required" },
  COUNTRY_CODE_TAG_REQUIRED: { code: 26, message: "Country Code cannot be null or empty" },
  MERCHANT_CITY_TAG_REQUIRED: { code: 27, message: "Merchant City cannot be null or empty" },
  UNSUPPORTED_CURRENCY: { code: 28, message: "Unsupported currency" },
  INVALID_DEEP_LINK_URL: { code: 29, message: "Deep Link URL is not valid" },
  MERCHANT_ID_REQUIRED: { code: 30, message: "Merchant ID cannot be null or empty" },
  ACQUIRING_BANK_REQUIRED: { code: 31, message: "Acquiring Bank cannot be null or empty" },
  MERCHANT_ID_LENGTH_INVALID: { code: 32, message: "Merchant ID Length is invalid" },
  ACQUIRING_BANK_LENGTH_INVALID: { code: 33, message: "Acquiring Bank Length is invalid" },
  MOBILE_NUMBER_LENGTH_INVALID: { code: 34, message: "Mobile Number Length is invalid" },
  ACCOUNT_INFORMATION_LENGTH_INVALID: { code: 35, message: "Account Information Length is invalid" },
  TAG_NOT_IN_ORDER: { code: 36, message: "Tag is not in order" },
  LANGUAGE_PREFERENCE_REQUIRED: { code: 37, message: "Language Preference cannot be null or empty" },
  LANGUAGE_PREFERENCE_LENGTH_INVALID: { code: 38, message: "Language Preference Length is invalid" },
  MERCHANT_NAME_ALTERNATE_LANGUAGE_REQUIRED: {
    code: 39, message: "Merchant Name Alternate Language cannot be null or empty"
  },
  MERCHANT_NAME_ALTERNATE_LANGUAGE_LENGTH_INVALID: {
    code: 40, message: "Merchant Name Alternate Language Length is invalid"
  },
  MERCHANT_CITY_ALTERNATE_LANGUAGE_LENGTH_INVALID: {
    code: 41, message: "Merchant City Alternate Language Length is invalid"
  },
  PURPOSE_OF_TRANSACTION_LENGTH_INVALID: { code: 42, message: "Purpose of Transaction Length is invalid" },
  UPI_ACCOUNT_INFORMATION_LENGTH_INVALID: { code: 43, message: "Upi Account Information Length is invalid" },
  UPI_ACCOUNT_INFORMATION_INVALID_CURRENCY: {
    code: 44, message: "Upi Account Information Length does not accept USD"
  },
  EXPIRATION_TIMESTAMP_REQUIRED: { code: 45, message: "Expiration timestamp is required for dynamic KHQR" },
  KHQR_EXPIRED: { code: 46, message: "This dynamic KHQR has expired" },
  INVALID_DYNAMIC_KHQR: { code: 47, message: "This dynamic KHQR has invalid field transaction amount" },
  POINT_OF_INITIATION_METHOD_INVALID: { code: 48, message: "Point of Initiation Method is invalid" },
  EXPIRATION_TIMESTAMP_LENGTH_INVALID: { code: 49, message: "Expiration timestamp length is invalid" },
  EXPIRATION_TIMESTAMP_IN_THE_PAST: { code: 50, message: "Expiration timestamp is in the past" },
  INVALID_MERCHANT_CATEGORY_CODE: { code: 51, message: "Invalid merchant category code" }
}.freeze
KHQR_SUBTAG =

Subtag schema for the composite top-level tags (29, 30, 62, 64, 99). ‘input` defines the keys we surface in a decoded result; `compare` is the lookup table mapping (tag, sub_tag) → field name.

{
  input: [
    { tag: "29", data: { bakong_account_id: nil, account_information: nil } },
    { tag: "30", data: { bakong_account_id: nil, merchant_id: nil, acquiring_bank: nil } },
    { tag: "62", data: {
      bill_number: nil, mobile_number: nil, store_label: nil,
      terminal_label: nil, purpose_of_transaction: nil
    } },
    { tag: "64", data: {
      language_preference: nil,
      merchant_name_alternate_language: nil,
      merchant_city_alternate_language: nil
    } },
    { tag: "99", data: { creation_timestamp: nil, expiration_timestamp: nil } }
  ].freeze,

  compare: [
    { tag: "29", sub_tag: EMV[:BAKONG_ACCOUNT_IDENTIFIER],                       name: :bakong_account_id },
    { tag: "29", sub_tag: EMV[:MERCHANT_ACCOUNT_INFORMATION_MERCHANT_ID],        name: :account_information },
    { tag: "29", sub_tag: EMV[:MERCHANT_ACCOUNT_INFORMATION_ACQUIRING_BANK],     name: :acquiring_bank },
    { tag: "62", sub_tag: EMV[:BILLNUMBER_TAG],                                  name: :bill_number },
    { tag: "62", sub_tag: EMV[:ADDITIONAL_DATA_FIELD_MOBILE_NUMBER],             name: :mobile_number },
    { tag: "62", sub_tag: EMV[:STORELABEL_TAG],                                  name: :store_label },
    { tag: "62", sub_tag: EMV[:PURPOSE_OF_TRANSACTION],                          name: :purpose_of_transaction },
    { tag: "62", sub_tag: EMV[:TERMINAL_TAG],                                    name: :terminal_label },
    { tag: "64", sub_tag: EMV[:LANGUAGE_PREFERENCE],                             name: :language_preference },
    { tag: "64", sub_tag: EMV[:MERCHANT_NAME_ALTERNATE_LANGUAGE],                name: :merchant_name_alternate_language },
    { tag: "64", sub_tag: EMV[:MERCHANT_CITY_ALTERNATE_LANGUAGE],                name: :merchant_city_alternate_language },
    { tag: "99", sub_tag: EMV[:CREATION_TIMESTAMP],                              name: :creation_timestamp },
    { tag: "99", sub_tag: EMV[:EXPIRATION_TIMESTAMP],                            name: :expiration_timestamp }
  ].freeze
}.freeze

Class Method Summary collapse

Class Method Details

.check_bakong_account(url, account_id) ⇒ Hash

Check whether a Bakong account ID exists via the Bakong Open API.

Parameters:

  • url (String)
  • account_id (String)

    e.g. “vandy@aclb”

Returns:

  • (Hash)

    { bakong_account_existed: Boolean }

Raises:



88
89
90
# File 'lib/bakong/khqr.rb', line 88

def (url, )
  Helpers::CheckAccountID.call(url, )
end

.decode(khqr_string) ⇒ Hash

Parse a KHQR string into a snake_case symbol-keyed Hash.

Parameters:

  • khqr_string (String)

Returns:

  • (Hash)


53
54
55
# File 'lib/bakong/khqr.rb', line 53

def decode(khqr_string)
  Controllers::Decode.call(khqr_string)
end

.decode_non_khqr(qr) ⇒ Hash

Parse an arbitrary EMVCo TLV QR string (not necessarily KHQR-compliant).

Parameters:

  • qr (String)

Returns:

  • (Hash)


60
61
62
# File 'lib/bakong/khqr.rb', line 60

def decode_non_khqr(qr)
  Controllers::DecodeNonKHQR.call(qr)
end

Request a shortened deep link for a KHQR string via the Bakong Open API.

Parameters:

  • url (String)

    the full deep-link endpoint URL

  • qr (String)
  • source_info (Bakong::Khqr::SourceInfo, nil) (defaults to: nil)

    optional

Returns:

  • (Hash)

    { short_link: String }

Raises:



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/bakong/khqr.rb', line 98

def generate_deep_link(url, qr, source_info: nil)
  raise Error.from(ERROR_CODES[:INVALID_DEEP_LINK_URL]) unless Helpers::DeepLink.valid_link?(url)
  raise Error.from(ERROR_CODES[:KHQR_INVALID]) unless verify(qr)

  if source_info && !source_info.complete?
    raise Error.from(ERROR_CODES[:INVALID_DEEP_LINK_SOURCE_INFO])
  end

  payload = { qr: qr }
  payload[:sourceInfo] = source_info.to_h if source_info

  response = Helpers::DeepLink.call(url, payload)
  short_link = response.dig(:data, :shortLink)
  { short_link: short_link }
end

.generate_individual(info) ⇒ Hash

Generate a KHQR string for an individual recipient.

Parameters:

Returns:

  • (Hash)

    { qr: String, md5: String }



37
38
39
40
# File 'lib/bakong/khqr.rb', line 37

def generate_individual(info)
  qr = Controllers::Generate.call(info, :individual)
  { qr: qr, md5: Digest::MD5.hexdigest(qr) }
end

.generate_merchant(info) ⇒ Hash

Generate a KHQR string for a merchant.

Parameters:

Returns:

  • (Hash)

    { qr: String, md5: String }



45
46
47
48
# File 'lib/bakong/khqr.rb', line 45

def generate_merchant(info)
  qr = Controllers::Generate.call(info, :merchant)
  { qr: qr, md5: Digest::MD5.hexdigest(qr) }
end

.verify(khqr_string) ⇒ Boolean

Verify the CRC-16/CCITT-FALSE checksum embedded at the tail of a KHQR string and re-validate every TLV against the spec.

Parameters:

  • khqr_string (String)

Returns:

  • (Boolean)


68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/bakong/khqr.rb', line 68

def verify(khqr_string)
  return false unless khqr_string.is_a?(String)
  return false unless khqr_string.match?(CRC_REGEXP)

  crc = khqr_string[-4..]
  body = khqr_string[0...-4]
  return false unless CRC16.compute(body) == crc.upcase
  return false if khqr_string.length < EMV[:INVALID_LENGTH][:KHQR]

  Controllers::DecodeValidation.call(khqr_string)
  true
rescue Error, StandardError
  false
end