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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +337 -0
- data/docs/bank-auto-confirm-integration-guide.md +307 -0
- data/docs/tingee-api-reference.md +317 -0
- data/docs/tingee-vcb-personal-link.md +130 -0
- data/lib/tingee/client.rb +205 -0
- data/lib/tingee/configuration.rb +21 -0
- data/lib/tingee/error.rb +14 -0
- data/lib/tingee/signature.rb +44 -0
- data/lib/tingee/version.rb +3 -0
- data/lib/tingee/viet_qr.rb +125 -0
- data/lib/tingee.rb +30 -0
- data/lib/tingee_ruby_sdk.rb +2 -0
- metadata +59 -0
data/lib/tingee/error.rb
ADDED
|
@@ -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,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
|
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: []
|