tingee_ruby_sdk 0.3.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.
@@ -0,0 +1,14 @@
1
+ module Tingee
2
+ # A non-"00" Tingee response, or a transport/config failure. `code` keeps Tingee's
3
+ # raw code for programmatic handling. Two undocumented-but-observed series exist and
4
+ # are NOT reconciled by Tingee: business codes 1001–1076, and signature codes
5
+ # 90 (bad timestamp) / 91 (timeout) / 97 (bad signature). We keep the raw code as-is.
6
+ class Error < StandardError
7
+ attr_reader :code
8
+
9
+ def initialize(code, detail)
10
+ @code = code
11
+ super("Tingee error #{code}: #{detail}")
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,44 @@
1
+ require "openssl"
2
+ require "json"
3
+
4
+ module Tingee
5
+ # HMAC-SHA512 signing, per the official tingee-node SDK and verified live
6
+ # (docs/tingee-api-reference.md §Auth). Outbound requests sign
7
+ # `timestamp + ":" + minified_body`, where a bodyless request signs "{}" (not "")
8
+ # — signing "" returns code 97. Inbound webhooks are verified over the RAW body
9
+ # bytes, not a re-serialization (see #verify).
10
+ module Signature
11
+ module_function
12
+
13
+ # yyyyMMddHHmmssSSS in UTC+7. Server rejects >10 min clock drift (error 90).
14
+ def timestamp(now = Time.now)
15
+ now.getlocal("+07:00").strftime("%Y%m%d%H%M%S%L")
16
+ end
17
+
18
+ def generate(secret:, timestamp:, body:)
19
+ # `.b`: hash raw bytes (silences the json 3.0 UTF-8/BINARY warning); the
20
+ # digest of ASCII/UTF-8 bytes is unchanged either way.
21
+ OpenSSL::HMAC.hexdigest("SHA512", secret, "#{timestamp}:#{body}".b)
22
+ end
23
+
24
+ # Verify an inbound webhook signature. Resolved against a real captured payment
25
+ # webhook (2026-07-16): Tingee signs the RAW body bytes exactly as sent.
26
+ # We hash `raw_body` verbatim — do NOT re-parse/re-serialize. Raw is both correct
27
+ # and immune to Ruby-vs-JS number formatting (a re-serialized whole-number float
28
+ # would render "250000.0" in Ruby vs "250000" in JS and break a valid signature).
29
+ # The caller must pass the body exactly as received (Rails: `request.raw_post`),
30
+ # never a re-encoded body.
31
+ def verify(secret:, timestamp:, raw_body:, signature:)
32
+ secure_compare(generate(secret:, timestamp:, body: raw_body), signature.to_s)
33
+ end
34
+
35
+ # Constant-time comparison, hand-rolled so the gem needs no ActiveSupport.
36
+ def secure_compare(left, right)
37
+ return false unless left.bytesize == right.bytesize
38
+
39
+ diff = 0
40
+ left.each_byte.with_index { |byte, i| diff |= byte ^ right.getbyte(i) }
41
+ diff.zero?
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,3 @@
1
+ module Tingee
2
+ VERSION = "0.3.0"
3
+ end
@@ -0,0 +1,125 @@
1
+ module Tingee
2
+ # VietQR payment payloads, built locally — no img.vietqr.io round-trip and no Tingee
3
+ # endpoint (theirs 500s, see docs/tingee-api-reference.md §10). A plain transfer into
4
+ # the linked real account fires the payment webhook whatever minted the QR, so the
5
+ # payer-facing code is purely a client-side string.
6
+ #
7
+ # Ported from https://github.com/openhoangnc/vietqr (MIT); test/tingee/viet_qr_test.rb
8
+ # reproduces that project's fixtures byte-exact.
9
+ #
10
+ # Returns the EMVCo payload STRING, not an image. QR pixel encoding is Reed-Solomon +
11
+ # masking, which would mean a runtime dependency and this gem has none — render the
12
+ # string app-side with rqrcode (Ruby) or any JS QR library.
13
+ module VietQR
14
+ # EMVCo tag 62-08 (purpose of transaction) caps at 25 characters.
15
+ MAX_DESCRIPTION = 25
16
+
17
+ NAPAS_AID = "A000000727".freeze # merchant account information: Napas
18
+ SERVICE_CODE = "QRIBFTTA".freeze # interbank funds transfer TO AN ACCOUNT
19
+ CURRENCY_VND = "704".freeze # ISO 4217
20
+ COUNTRY_VN = "VN".freeze
21
+
22
+ module_function
23
+
24
+ # bank_bin: the Napas BIN (`client.get_banks` → "bin", same value create_va takes).
25
+ # account_number: the REAL account from confirm_va — where the money lands.
26
+ # amount: integer VND; nil or 0 mints an open-amount QR the payer fills in.
27
+ # description: the transfer memo; normalized (see #normalize_description).
28
+ def payload(bank_bin:, account_number:, amount: nil, description: nil)
29
+ beneficiary = tlv("00", validate_id!(bank_bin, "bank_bin")) +
30
+ tlv("01", validate_id!(account_number, "account_number"))
31
+ merchant = tlv("00", NAPAS_AID) + tlv("01", beneficiary) + tlv("02", SERVICE_CODE)
32
+ memo = normalize_description(description)
33
+ dong = validate_amount!(amount)
34
+
35
+ s = "000201" # payload format indicator
36
+ s += "010211" # point of initiation: 11 = static/reusable (12 = single-use)
37
+ s += tlv("38", merchant)
38
+ s += tlv("53", CURRENCY_VND)
39
+ # `if amount` alone would be a porting bug: 0 is falsy in the JS original but
40
+ # truthy in Ruby, which would emit a bogus zero-amount field 54.
41
+ s += tlv("54", dong.to_s) if dong.positive?
42
+ s += tlv("58", COUNTRY_VN)
43
+ s += tlv("62", tlv("08", memo)) unless memo.empty?
44
+ s += "6304" # CRC tag + length, both covered by their own checksum
45
+ s + crc16(s)
46
+ end
47
+
48
+ # ASCII-folds Vietnamese diacritics and truncates to MAX_DESCRIPTION, because many
49
+ # bank scanners mangle or reject a non-ASCII memo.
50
+ #
51
+ # PERSIST WHAT THIS RETURNS. "Thanh toán" becomes "Thanh toan", so a webhook matcher
52
+ # grepping your original un-normalized string silently misses every payment.
53
+ def normalize_description(str)
54
+ normalized =
55
+ str.to_s
56
+ # A Latin-1 paste or a byte-truncated memo would otherwise raise a raw
57
+ # ArgumentError out of unicode_normalize, past any rescue Tingee::Error.
58
+ .encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
59
+ .unicode_normalize(:nfkd) # NFKD, not NFD: also folds fullwidth HD1 to HD1
60
+ .gsub(/\p{Mn}/, "") # drop combining diacritics: "toán" -> "toan"
61
+ .tr("đĐ", "dD") # not decomposable, needs its own mapping
62
+ # Anything still non-ASCII becomes a space rather than vanishing, so an
63
+ # en dash or NBSP cannot silently fuse two words into one.
64
+ .gsub(/[^\x20-\x7E]/, " ")
65
+ .squeeze(" ")
66
+ .strip[0, MAX_DESCRIPTION]
67
+ .strip # truncation can leave a trailing space mid-word
68
+
69
+ # An unreferenced QR is unmatchable by construction (the auto-confirm matcher
70
+ # keys on this memo), so refuse to turn a memo the caller meant into nothing.
71
+ if normalized.empty? && !str.to_s.strip.empty?
72
+ raise Error.new("QR_INPUT", "description #{str.inspect} normalized to empty; a QR with no memo cannot be matched")
73
+ end
74
+
75
+ normalized
76
+ end
77
+
78
+ # VND has no minor unit, so an amount must be a whole non-negative number of dong.
79
+ # Deliberately NOT String#to_i, which coerces instead of parsing: "1.234.567" is
80
+ # the ordinary Vietnamese money format and to_i turns it into a 1-dong QR that the
81
+ # payer scans, pays, and nobody notices. nil (or 0) means an open-amount QR.
82
+ def validate_amount!(amount)
83
+ return 0 if amount.nil?
84
+
85
+ dong = case amount
86
+ when Numeric then (amount % 1).zero? ? amount.to_i : nil # Float/BigDecimal/Rational
87
+ when String then amount.match?(/\A\d+\z/) ? amount.to_i : nil
88
+ end
89
+ raise Error.new("QR_INPUT", "amount must be a whole non-negative number of dong, got #{amount.inspect}") if dong.nil? || dong.negative?
90
+
91
+ dong
92
+ end
93
+
94
+ # EMVCo TLV: 2-char id + 2-char length + value. The length counts BYTES — the JS
95
+ # original used String#length (UTF-16 units), which diverges on any non-ASCII value.
96
+ def tlv(id, value)
97
+ size = value.bytesize
98
+ raise Error.new("QR_INPUT", "#{id} value is #{size} bytes; EMVCo length field holds 2 digits") if size > 99
99
+
100
+ "#{id}#{format('%02d', size)}#{value}"
101
+ end
102
+
103
+ # CRC16-CCITT-FALSE: init 0xFFFF, poly 0x1021, no reflection, no final XOR.
104
+ # Masked once at the end rather than each round — Ruby Integers are unbounded, so
105
+ # the intermediate width differs from JS's 32-bit bitwise ops, the low 16 bits do not.
106
+ def crc16(str)
107
+ crc = 0xFFFF
108
+ str.each_byte do |byte|
109
+ crc ^= byte << 8
110
+ 8.times { crc = (crc & 0x8000).zero? ? crc << 1 : (crc << 1) ^ 0x1021 }
111
+ end
112
+ format("%04X", crc & 0xFFFF)
113
+ end
114
+
115
+ # Bank BIN and account number must survive a QR scan into a bank app's transfer
116
+ # form, so reject anything but ASCII alphanumerics. No BIN whitelist here —
117
+ # client.get_banks is the source of truth for what Tingee actually supports.
118
+ def validate_id!(value, name)
119
+ value = value.to_s
120
+ raise Error.new("QR_INPUT", "#{name} must be non-empty ASCII alphanumerics, got #{value.inspect}") unless value.match?(/\A[A-Za-z0-9]+\z/)
121
+
122
+ value
123
+ end
124
+ end
125
+ end
data/lib/tingee.rb ADDED
@@ -0,0 +1,30 @@
1
+ # Ruby client for the Tingee BaaS API (https://open-api.tingee.vn) — bank account
2
+ # linking, virtual accounts, and payment webhooks. Pure Ruby, zero runtime
3
+ # dependencies, no Rails required (guarded by test/tingee/rails_free_boundary_test.rb).
4
+ #
5
+ # The observed API contract lives in docs/tingee-api-reference.md (signing recipe,
6
+ # envelope shapes, error codes — all verified against the live API 2026-07-16).
7
+ # Credentials are INJECTED via Tingee.configure; this library never reads any
8
+ # credential store itself.
9
+ require_relative "tingee/version"
10
+ require_relative "tingee/error"
11
+ require_relative "tingee/configuration"
12
+ require_relative "tingee/signature"
13
+ require_relative "tingee/viet_qr"
14
+ require_relative "tingee/client"
15
+
16
+ module Tingee
17
+ class << self
18
+ def configure
19
+ yield config
20
+ config
21
+ end
22
+
23
+ def config
24
+ @config ||= Configuration.new
25
+ end
26
+
27
+ # test seam — swap in a fake config, reset between examples
28
+ attr_writer :config
29
+ end
30
+ end
@@ -0,0 +1,2 @@
1
+ # Bundler autorequires by gem name; the library lives under lib/tingee.
2
+ require_relative "tingee"
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tingee_ruby_sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - lpwanw
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Pure-Ruby, zero-dependency client for Tingee (open-api.tingee.vn): HMAC-SHA512
13
+ request signing, bank-link sessions, the manual create-va/confirm-va OTP chain,
14
+ unlink, and raw-body webhook signature verification. Built from a live-verified
15
+ API contract.'
16
+ email:
17
+ - lp.wanw@gmail.com
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE.txt
23
+ - README.md
24
+ - docs/bank-auto-confirm-integration-guide.md
25
+ - docs/tingee-api-reference.md
26
+ - docs/tingee-vcb-personal-link.md
27
+ - lib/tingee.rb
28
+ - lib/tingee/client.rb
29
+ - lib/tingee/configuration.rb
30
+ - lib/tingee/error.rb
31
+ - lib/tingee/signature.rb
32
+ - lib/tingee/version.rb
33
+ - lib/tingee/viet_qr.rb
34
+ - lib/tingee_ruby_sdk.rb
35
+ homepage: https://github.com/lpwanw/tingee_ruby_sdk
36
+ licenses:
37
+ - MIT
38
+ metadata:
39
+ source_code_uri: https://github.com/lpwanw/tingee_ruby_sdk
40
+ rubygems_mfa_required: 'true'
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '3.2'
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 4.0.11
56
+ specification_version: 4
57
+ summary: Ruby client for the Tingee BaaS API — bank linking, virtual accounts, payment
58
+ webhooks
59
+ test_files: []