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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b8c34e49cceee99dc1a94785dde20511c8e405d9fd3dce14289d8e70aec951f4
|
|
4
|
+
data.tar.gz: 6dfa9725570c35cf7d9cc43f701bfb857840d671e682de02d38e9f3dc035cbae
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5c9d96ed98a6ccf7531754b91bfaecfe67e13a58e5436a19ba51930cc93c6e2f3241d916b71b4e42d9ef35af146eaecfea59c859b2e87d66ee7b53a64dc2eda9
|
|
7
|
+
data.tar.gz: bf2ebfb597a85e3162fa01f7491d86953504a50af5f95a91553cffe489622b73bd11456e65f9b490632c8842229bfcf64ede7d516318217db3e9f38c2069a72f
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lpwanw
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# tingee_ruby_sdk
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/tingee_ruby_sdk)
|
|
4
|
+
[](LICENSE.txt)
|
|
5
|
+
|
|
6
|
+
Ruby client for the [Tingee](https://tingee.vn) BaaS API (`open-api.tingee.vn`) —
|
|
7
|
+
bank account linking, virtual accounts, and payment webhooks for Vietnamese banks.
|
|
8
|
+
Official API docs: [developers.tingee.vn/docs/banking](https://developers.tingee.vn/docs/banking/).
|
|
9
|
+
|
|
10
|
+
- **Pure Ruby, zero runtime dependencies.** `net/http`, `openssl`, `json` only. No
|
|
11
|
+
Rails required (enforced by a boundary test).
|
|
12
|
+
- **Built from a live-verified API contract.** Every wrapped endpoint and every
|
|
13
|
+
signing rule in this gem was observed against the production API (2026-07-16),
|
|
14
|
+
not copied from marketing docs. The full observed contract, including Tingee's
|
|
15
|
+
quirks and known bugs, lives in [`docs/tingee-api-reference.md`](docs/tingee-api-reference.md).
|
|
16
|
+
- **Only verified endpoints are wrapped** — no speculative "complete SDK".
|
|
17
|
+
- **VietQR payment codes are minted locally** — no `img.vietqr.io` round-trip, no API
|
|
18
|
+
call. See [VietQR payment codes](#vietqr-payment-codes).
|
|
19
|
+
|
|
20
|
+
> **Building the full auto-confirm feature?** Follow
|
|
21
|
+
> [`docs/bank-auto-confirm-integration-guide.md`](docs/bank-auto-confirm-integration-guide.md) —
|
|
22
|
+
> a complete app-side playbook (data model, link state machine, webhook endpoint,
|
|
23
|
+
> payment matching, go-live checklist) distilled from a production integration.
|
|
24
|
+
> Written so you can hand the link to an AI coding agent and it knows what to build.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# Gemfile
|
|
30
|
+
gem "tingee_ruby_sdk", "~> 0.3"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
gem install tingee_ruby_sdk
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Requires Ruby >= 3.2. Or track unreleased work:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
gem "tingee_ruby_sdk", github: "lpwanw/tingee_ruby_sdk" # main
|
|
41
|
+
gem "tingee_ruby_sdk", path: "../tingee_ruby_sdk" # local checkout
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
Credentials are injected — the gem never reads any credential store itself:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# e.g. config/initializers/tingee.rb in a Rails app
|
|
50
|
+
Tingee.configure do |c|
|
|
51
|
+
c.client_id = ENV["TINGEE_CLIENT_ID"] # or Rails credentials, etc.
|
|
52
|
+
c.secret_token = ENV["TINGEE_SECRET_TOKEN"]
|
|
53
|
+
# c.base_url = "https://open-api.tingee.vn" # default (production)
|
|
54
|
+
# c.shop_id = 252011 # optional: group every VA under ONE Tingee shop (one shop
|
|
55
|
+
# per project); unset, Tingee auto-creates a shop per link
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Validation happens when a client is built, not at load time — a credential-less
|
|
60
|
+
environment still boots. A missing credential raises `Tingee::Error` with code
|
|
61
|
+
`"CONFIG"`.
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
### Client basics
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
client = Tingee::Client.new # uses Tingee.config, 90s read timeout
|
|
69
|
+
slow = Tingee::Client.new(read_timeout: 300) # for background jobs (OTP confirm can take minutes)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
All methods return the unwrapped `data` payload on success (`code "00"`), or raise
|
|
73
|
+
`Tingee::Error` (see [Error handling](#error-handling)).
|
|
74
|
+
|
|
75
|
+
### Supported banks
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
client.get_banks
|
|
79
|
+
# => bare array (NOT the usual envelope): [{"code"=>"VCB", "name"=>..., "bin"=>"970436", ...}, ...]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
14 real banks are supported; notably **Techcombank (TCB) is NOT supported**. Full
|
|
83
|
+
verified bank/BIN table in the [API reference](docs/tingee-api-reference.md#1-get-banks--supported-banks).
|
|
84
|
+
|
|
85
|
+
### Bank linking — hosted SDK flow
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
url = client.create_bank_link_session(redirect_url: "https://yourapp.com/settings/bank")
|
|
89
|
+
# => "https://bank-link.tingee.vn?token=…" — redirect the user there
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
An empty payload works for the default merchant; pass `merchant_id:` only for a
|
|
93
|
+
sub-merchant. Note: Tingee's hosted JS SDK had a live crash bug at verification
|
|
94
|
+
time (`confirmId.startsWith`) — the manual chain below is the verified fallback.
|
|
95
|
+
|
|
96
|
+
### Bank linking — manual API chain (verified end-to-end)
|
|
97
|
+
|
|
98
|
+
One `create_va` serves every bank; the bank decides which shape you get back. OTP banks
|
|
99
|
+
are below, redirect-authorize banks (VCB/TPB) in the next section.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# 1. Start the link — the bank sends/pushes an OTP to `mobile`
|
|
103
|
+
data = client.create_va(
|
|
104
|
+
bank_bin: "970403", # Napas BIN (STB/Sacombank here)
|
|
105
|
+
account_number: "0400…",
|
|
106
|
+
account_name: "NGUYEN VAN A",
|
|
107
|
+
identity: "0123456789…", # CCCD — do not persist
|
|
108
|
+
mobile: "09xxxxxxxx", # MUST be domestic 0-prefixed; "84…" is rejected
|
|
109
|
+
webhook_url: "https://yourapp.com/webhooks/tingee"
|
|
110
|
+
)
|
|
111
|
+
data # => {"confirmId"=>"…", "otpMethod"=>"SmartOTP"}
|
|
112
|
+
|
|
113
|
+
# 2. Finish with the bank's OTP (can take minutes bank-side — use a background job)
|
|
114
|
+
link = client.confirm_va(bank_bin: "970403", confirm_id: data["confirmId"], otp_number: "123456")
|
|
115
|
+
link["accountNumber"] # the REAL bank account — money lands here, show this on QRs
|
|
116
|
+
link["vaAccountNumber"] # "TNG…" — Tingee-internal ROUTING KEY, store it to route webhooks
|
|
117
|
+
|
|
118
|
+
# 3. ACB only: one extra OTP round
|
|
119
|
+
r = client.register_notify(bank_bin: "970416", va_account_number: link["vaAccountNumber"])
|
|
120
|
+
client.confirm_register_notify(bank_bin: "970416", confirm_id: r["confirmId"], otp_number: "654321")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Bank linking — redirect-authorize banks (VCB, TPB)
|
|
124
|
+
|
|
125
|
+
Same `create_va`, different bank behavior: pass `app_type: "baas"` + `redirect_url` and
|
|
126
|
+
the bank answers with an **authorize link** instead of sending an OTP. There is **no
|
|
127
|
+
`confirm_va` step** — the owner approves in the bank's app/web and the result arrives
|
|
128
|
+
**asynchronously on your webhook** as `status: "confirm-va-success" | "confirm-va-failed"`
|
|
129
|
+
(see [§Webhooks](docs/tingee-api-reference.md#7-webhooks)).
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
# Pass and STORE your own request_id — it's echoed back on the webhook to correlate.
|
|
133
|
+
# TPB returns NO confirmId at all, so your request_id is the ONLY correlation key.
|
|
134
|
+
data = client.create_va(
|
|
135
|
+
bank_bin: "970436", # VCB
|
|
136
|
+
request_id: my_link_id,
|
|
137
|
+
account_number: "0912323232",
|
|
138
|
+
mobile: "0987665555",
|
|
139
|
+
app_type: "baas",
|
|
140
|
+
redirect_url: "https://yourapp.com/settings/bank", # where the bank sends them back
|
|
141
|
+
webhook_url: "https://yourapp.com/webhooks/tingee"
|
|
142
|
+
)
|
|
143
|
+
data.first["deepLink"] # "vcbpartner://linkPaymentEvent?token=…" — open in VCB Digibank
|
|
144
|
+
# (TPB answers with "authorizeLink" — an https page)
|
|
145
|
+
|
|
146
|
+
# Later, on your webhook (verify the signature first — see below):
|
|
147
|
+
# payload["status"] == "confirm-va-success" → payload["vaAccountNumber"] is now linked
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Banks whose contract collects nothing from you (TPB — the owner picks the account on the
|
|
151
|
+
bank's own web) simply omit `account_number`/`account_name`/`identity`/`mobile`; unset
|
|
152
|
+
params are never sent.
|
|
153
|
+
|
|
154
|
+
### Listing linked accounts
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
client.get_va_paging # => {"totalCount"=>1, "items"=>[{"vaAccountNumber"=>"TNG…", "status"=>"active", …}]}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Unlinking
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
# Note Tingee's inconsistency: delete-va takes QUERY params, confirm-delete-va takes a
|
|
164
|
+
# JSON BODY. Both identify the bank by its BIN ("970403"). The gem handles the transport.
|
|
165
|
+
r = client.delete_va(bank_bin: "970403", va_account_number: "TNG…")
|
|
166
|
+
client.confirm_delete_va(bank_bin: "970403", confirm_id: r["confirmId"], otp_number: "111111")
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
> **Do not pass the bank's short CODE (`bankName: "STB"`) to delete-va.** It looks like
|
|
170
|
+
> it works — Tingee returns a `confirmId` and the bank really does send an OTP — but the
|
|
171
|
+
> session it opens cannot be confirmed: `confirm-delete-va` then fails with
|
|
172
|
+
> `"Lỗi hệ thống phương thức xác thực"` (live, 2026-07-17). Since unlinking is the only
|
|
173
|
+
> way to stop per-webhook billing, an unlink that silently cannot complete keeps costing
|
|
174
|
+
> money.
|
|
175
|
+
|
|
176
|
+
Bank-shape variations on `delete_va`'s response are the caller's to branch on: OTP banks
|
|
177
|
+
return `{confirmId}`, TPB returns an `authorizeLink`, and VCB returns `{}` because it
|
|
178
|
+
detaches immediately — nothing left to confirm.
|
|
179
|
+
|
|
180
|
+
### Transaction history
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
# start_time/end_time are required ("yyyyMMddHHmmss", UTC+7); max 10-day window.
|
|
184
|
+
client.get_transactions(start_time: "20260701000000", end_time: "20260710235959")
|
|
185
|
+
# => {"totalCount"=>100, "items"=>[{"transactionId"=>…, "amount"=>100000, "type"=>"CREDIT", …}]}
|
|
186
|
+
|
|
187
|
+
# Optional filters: filter (keyword), skip_count, max_result_count, merchant_id,
|
|
188
|
+
# shop_ids, va_account_numbers, bank_bin.
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Unlinking is also how you stop Tingee's per-webhook billing for an account.
|
|
192
|
+
|
|
193
|
+
### VietQR payment codes
|
|
194
|
+
|
|
195
|
+
Mint the payer-facing transfer QR locally — no `img.vietqr.io` round-trip, no API call:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
memo = Tingee::VietQR.normalize_description("Thanh toán đơn HD#{invoice.id}")
|
|
199
|
+
payload = Tingee::VietQR.payload(
|
|
200
|
+
bank_bin: link.tingee_bank_bin,
|
|
201
|
+
account_number: link.bank_account_number, # the REAL account from confirm_va
|
|
202
|
+
amount: invoice.total, # whole VND; omit or 0 for an open-amount QR
|
|
203
|
+
description: memo
|
|
204
|
+
)
|
|
205
|
+
# => "00020101021138540010A00000072701240006…5802VN6225…6304<CRC>"
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`amount` is parsed, never coerced: anything that is not a whole non-negative number of
|
|
209
|
+
dong raises `Tingee::Error` with code `"QR_INPUT"`. That includes the formatted strings
|
|
210
|
+
a form field or CSV will hand you — `"1.234.567"` is rejected rather than silently
|
|
211
|
+
becoming a **1 ₫** QR. `Integer`, whole `Float`/`BigDecimal`/`Rational`, and a bare
|
|
212
|
+
digit string all work.
|
|
213
|
+
|
|
214
|
+
The gem returns the **EMVCo payload string, not an image** — QR pixel encoding is
|
|
215
|
+
Reed-Solomon plus masking, which would mean a runtime dependency, and this gem has
|
|
216
|
+
none. Render it app-side:
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
require "rqrcode" # add to YOUR Gemfile — deliberately not a dependency of this gem
|
|
220
|
+
RQRCode::QRCode.new(payload).as_png(size: 512)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
> **`description` is ASCII-folded and truncated to 25 chars** (`Thanh toán` → `Thanh toan`),
|
|
224
|
+
> because many bank scanners mangle or reject a non-ASCII memo. **Persist the string
|
|
225
|
+
> `normalize_description` returns and match your webhook's `description` against that** —
|
|
226
|
+
> matching against your original un-normalized text silently misses every payment.
|
|
227
|
+
>
|
|
228
|
+
> A description that survives normalization as nothing (emoji or symbols only) raises
|
|
229
|
+
> `"QR_INPUT"` rather than minting a QR with no reference the matcher could key on.
|
|
230
|
+
|
|
231
|
+
A plain transfer into the linked real account fires the payment webhook regardless of
|
|
232
|
+
which tool minted the QR, so nothing here needs Tingee's (broken) dynamic-QR endpoint.
|
|
233
|
+
Ported from [openhoangnc/vietqr](https://github.com/openhoangnc/vietqr) (MIT); the test
|
|
234
|
+
suite reproduces that project's fixtures byte-exact.
|
|
235
|
+
|
|
236
|
+
## Webhook verification
|
|
237
|
+
|
|
238
|
+
Tingee signs webhooks with `HMAC_SHA512(secret, timestamp + ":" + raw_body)` over
|
|
239
|
+
the **raw body bytes exactly as sent** (verified against a real captured payment
|
|
240
|
+
webhook). Pass the body verbatim — never re-parse/re-serialize it:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
Tingee::Signature.verify(
|
|
244
|
+
secret: Tingee.config.secret_token,
|
|
245
|
+
timestamp: request_headers["x-request-timestamp"],
|
|
246
|
+
raw_body: raw_request_body, # Rails: request.raw_post — verbatim!
|
|
247
|
+
signature: request_headers["x-signature"]
|
|
248
|
+
) # => true/false (constant-time comparison)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Rails controller example
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
class Webhooks::TingeeController < ActionController::Base
|
|
255
|
+
skip_forgery_protection
|
|
256
|
+
|
|
257
|
+
ACK = { code: "00", message: "Success" }.freeze
|
|
258
|
+
|
|
259
|
+
def create
|
|
260
|
+
raw = request.raw_post
|
|
261
|
+
payload = JSON.parse(raw) rescue nil
|
|
262
|
+
return render(json: ACK) if payload.nil?
|
|
263
|
+
|
|
264
|
+
# Dashboard connection test: unsigned {"event":"ping"} — ack it or the
|
|
265
|
+
# dashboard's test shows failure. Don't verify, don't process.
|
|
266
|
+
return render(json: ACK) if payload["event"] == "ping"
|
|
267
|
+
|
|
268
|
+
unless Tingee::Signature.verify(
|
|
269
|
+
secret: Tingee.config.secret_token,
|
|
270
|
+
timestamp: request.headers["x-request-timestamp"],
|
|
271
|
+
raw_body: raw,
|
|
272
|
+
signature: request.headers["x-signature"]
|
|
273
|
+
)
|
|
274
|
+
return head(:unauthorized)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# payload: {"transactionCode", "amount" (integer), "content" (memo),
|
|
278
|
+
# "accountNumber", "vaAccountNumber", "bankBin", "transactionDate", …}
|
|
279
|
+
# Route on vaAccountNumber (unique per link), idempotency-key on transactionCode.
|
|
280
|
+
render json: ACK # always ack so Tingee stops retrying
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Payment-callback field semantics are documented in the
|
|
286
|
+
[API reference §Webhooks](docs/tingee-api-reference.md#7-webhooks).
|
|
287
|
+
|
|
288
|
+
## Error handling
|
|
289
|
+
|
|
290
|
+
Every failure raises `Tingee::Error` with the raw Tingee code preserved:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
begin
|
|
294
|
+
client.confirm_va(bank_bin:, confirm_id:, otp_number:)
|
|
295
|
+
rescue Tingee::Error => e
|
|
296
|
+
e.code # "97" (bad signature), "90" (timestamp drift), "1001".."1076" (business),
|
|
297
|
+
# "HTTP_502" (non-JSON gateway page), "NETWORK" (transport), "CONFIG"
|
|
298
|
+
e.message # "Tingee error 97: Invalid signature"
|
|
299
|
+
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Transport failures (timeouts, DNS, TLS) are normalized to code `"NETWORK"` so
|
|
303
|
+
callers handle one error type.
|
|
304
|
+
|
|
305
|
+
## Signing rules (the things that will bite you)
|
|
306
|
+
|
|
307
|
+
- Signature = `HMAC_SHA512(secret, timestamp + ":" + minified_json_body)`, hex digest.
|
|
308
|
+
- Timestamp header format `yyyyMMddHHmmssSSS` in **UTC+7**; >10 min drift → error `90`.
|
|
309
|
+
- A bodyless request (e.g. GET) still signs the string `"{}"` — signing `""` → error `97`.
|
|
310
|
+
- Webhooks verify over **raw bytes**, outbound requests sign the minified body.
|
|
311
|
+
|
|
312
|
+
All handled by the gem; listed here so you don't fight them when debugging.
|
|
313
|
+
|
|
314
|
+
## Testing
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
bundle install
|
|
318
|
+
bundle exec rake test
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Poking the live API
|
|
322
|
+
|
|
323
|
+
Rails apps: `bin/rails console` (with the initializer set) — `Tingee::Client.new.get_banks`.
|
|
324
|
+
Without Rails:
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
TINGEE_CLIENT_ID=… TINGEE_SECRET_TOKEN=… bin/console
|
|
328
|
+
> client = Tingee::Client.new
|
|
329
|
+
> client.get_banks
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
One test reproduces a real captured signature and only runs when
|
|
333
|
+
`TINGEE_SECRET_TOKEN` is set; it is skipped otherwise.
|
|
334
|
+
|
|
335
|
+
## License
|
|
336
|
+
|
|
337
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# Bank auto-confirm integration guide
|
|
2
|
+
|
|
3
|
+
A step-by-step playbook for wiring `tingee_ruby_sdk` into an app so customer bank
|
|
4
|
+
transfers automatically confirm payments. Written to be handed to an AI coding
|
|
5
|
+
agent or a developer with zero prior context — follow it top to bottom.
|
|
6
|
+
|
|
7
|
+
Distilled from a production Rails integration (multi-tenant invoicing app,
|
|
8
|
+
2026-07). Rails examples throughout, but every rule is framework-agnostic; the
|
|
9
|
+
Rails-specific parts are marked. API-level facts live in
|
|
10
|
+
[`tingee-api-reference.md`](tingee-api-reference.md) (official docs:
|
|
11
|
+
[developers.tingee.vn/docs/banking](https://developers.tingee.vn/docs/banking/)) —
|
|
12
|
+
this file is the app-side architecture.
|
|
13
|
+
|
|
14
|
+
## What you're building
|
|
15
|
+
|
|
16
|
+
1. **Link**: a business owner links their Vietnamese bank account to Tingee
|
|
17
|
+
(OTP-verified). You store a routing key.
|
|
18
|
+
2. **Receive**: any plain bank transfer into that account (VietQR, manual
|
|
19
|
+
transfer — no Tingee QR needed) fires a signed webhook to your app.
|
|
20
|
+
3. **Match**: your app routes the webhook to the right account/tenant, matches it
|
|
21
|
+
to an open invoice/order (memo + exact amount), and marks it paid.
|
|
22
|
+
4. **Unlink**: reverses the link — also the ONLY way to stop Tingee's
|
|
23
|
+
per-webhook billing (every delivered webhook bills, matched or not).
|
|
24
|
+
|
|
25
|
+
## 0. Prerequisites
|
|
26
|
+
|
|
27
|
+
- Tingee merchant account with API credentials (`client_id`, `secret_token`).
|
|
28
|
+
- In the Tingee dashboard: register your webhook URL
|
|
29
|
+
(`https://yourapp.com/webhooks/tingee`) with auth = **API Credentials** (HMAC),
|
|
30
|
+
NOT the static API-key option.
|
|
31
|
+
- The gem: `gem "tingee_ruby_sdk", github: "lpwanw/tingee_ruby_sdk"`.
|
|
32
|
+
- Know your supported-bank story: **Techcombank is NOT supported** (see the API
|
|
33
|
+
reference §1 for the full list). Hide/explain the link button for unsupported
|
|
34
|
+
banks.
|
|
35
|
+
|
|
36
|
+
## 1. Configure credentials
|
|
37
|
+
|
|
38
|
+
Inject at boot; never let the gem read a credential store. Missing credentials
|
|
39
|
+
must not fail boot — `Tingee::Client.new` raises only when actually used.
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# config/initializers/tingee.rb (Rails)
|
|
43
|
+
Rails.application.config.to_prepare do
|
|
44
|
+
creds = Rails.application.credentials.tingee
|
|
45
|
+
next unless creds
|
|
46
|
+
|
|
47
|
+
Tingee.configure do |c|
|
|
48
|
+
c.client_id = creds[:client_id]
|
|
49
|
+
c.secret_token = creds[:secret_token]
|
|
50
|
+
c.base_url = creds[:base_url] if creds[:base_url] # default is prod
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`to_prepare` (not a bare initializer body) so the config survives dev reloads.
|
|
56
|
+
|
|
57
|
+
## 2. Data model
|
|
58
|
+
|
|
59
|
+
Three pieces of state. Adapt names to your domain (`organizations` below = your
|
|
60
|
+
tenant/account model).
|
|
61
|
+
|
|
62
|
+
### 2a. On the linked account (organization)
|
|
63
|
+
|
|
64
|
+
| Column | Source | Purpose |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `tingee_va_account_number` (string, **unique index**, nullable) | `confirm_va` → `vaAccountNumber` (`TNG…`) | THE routing key — webhooks route on it. Not a real account. |
|
|
67
|
+
| `bank_account_number` | `confirm_va` → `accountNumber` | The REAL account — what QRs show (feed it to `Tingee::VietQR.payload`), where money lands. |
|
|
68
|
+
| `tingee_bank_bin`, `tingee_shop_id` | `confirm_va` response | Needed for unlink / support. |
|
|
69
|
+
| `tingee_linked_at` (datetime) | you | Link state + audit. |
|
|
70
|
+
| `bank_auto_confirm_enabled` (boolean, default **false**) | you | Feature gate — launches OFF, flipped per account. |
|
|
71
|
+
|
|
72
|
+
Make the routing-key uniqueness a **partial unique DB index**
|
|
73
|
+
(`WHERE tingee_va_account_number IS NOT NULL`) plus a model validation — the same
|
|
74
|
+
bank account must never link to two of your tenants.
|
|
75
|
+
|
|
76
|
+
`linked?` = `tingee_va_account_number.present?`.
|
|
77
|
+
|
|
78
|
+
### 2b. In-flight link/unlink flow (one row per account)
|
|
79
|
+
|
|
80
|
+
The link is a two-step OTP conversation and the slow step runs in a background
|
|
81
|
+
job — so the pending state must live in the **DB, not the session** (a job can't
|
|
82
|
+
touch a session). One table, unique per account:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
tingee_link_requests:
|
|
86
|
+
organization_id (unique index)
|
|
87
|
+
step confirm_va | confirm_register_notify | start_delete_va | confirm_delete_va
|
|
88
|
+
status otp | verifying | failed
|
|
89
|
+
bank_code, bank_bin
|
|
90
|
+
confirm_id (from Tingee; survives a wrong OTP — retrying is legitimate)
|
|
91
|
+
account_name (confirm-va doesn't echo it; carry the typed value)
|
|
92
|
+
error_message
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Never store** the customer's CCCD (`identity`), `mobile`, or the OTP — they are
|
|
96
|
+
create-va call inputs only.
|
|
97
|
+
|
|
98
|
+
### 2c. Webhook log (metering + idempotency, NOT a ledger)
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
tingee_webhooks:
|
|
102
|
+
organization_id (nullable! unroutable webhooks still count — they still bill)
|
|
103
|
+
transaction_code (UNIQUE DB index — this IS the idempotency mechanism)
|
|
104
|
+
amount (integer)
|
|
105
|
+
memo_invoice_id (extracted from memo, e.g. /HD(\d+)/i — never store the raw memo: payer PII)
|
|
106
|
+
matched_invoice_id (nullable)
|
|
107
|
+
received_at, processed_at
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Idempotency via the DB unique index on `transaction_code` (race-safe), not a
|
|
111
|
+
model validation — a replay raises the DB's duplicate error, which the webhook
|
|
112
|
+
controller rescues and acks. `processed_at` distinguishes "processed, no match"
|
|
113
|
+
from "job never ran" — your match-rate metric depends on that.
|
|
114
|
+
|
|
115
|
+
## 3. The link flow
|
|
116
|
+
|
|
117
|
+
Two paths exist. **Use the manual API chain** — Tingee's hosted JS SDK
|
|
118
|
+
(`create_bank_link_session`) had a live crash bug at verification time; the raw
|
|
119
|
+
chain is the verified-in-production path.
|
|
120
|
+
|
|
121
|
+
### The state machine
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
create_va (sync, fast — bank sends OTP) → step: confirm_va, status: otp
|
|
125
|
+
owner submits OTP → status: verifying, enqueue job
|
|
126
|
+
job: confirm_va (SLOW — minutes at the bank) → success: apply link, destroy request
|
|
127
|
+
→ ACB only: register_notify → step: confirm_register_notify, status: otp (second OTP)
|
|
128
|
+
job failure, confirm_id present (wrong OTP/blip) → status: otp (retry legitimate)
|
|
129
|
+
job failure, no confirm_id → status: failed (terminal, only cancel)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Rules learned in production:
|
|
133
|
+
|
|
134
|
+
- **`confirm_va` MUST run in a background job with a raised read timeout**
|
|
135
|
+
(`Tingee::Client.new(read_timeout: 300)`): bank-side SmartOTP verification
|
|
136
|
+
routinely exceeds 90s and will 504 any synchronous request path.
|
|
137
|
+
- `create_va` can stay synchronous — it only initiates the bank's OTP send (fast).
|
|
138
|
+
- `mobile` must be domestic `0`-prefixed (`09…`), never `84…`.
|
|
139
|
+
- **No automatic retry on the confirm job**: the OTP is consumed by the first
|
|
140
|
+
attempt; a retry can never succeed. Failures land on the record for the owner.
|
|
141
|
+
- **ACB is special**: after `confirm_va` succeeds, call `register_notify` — a
|
|
142
|
+
second OTP round via `confirm_register_notify`. Call register_notify BEFORE
|
|
143
|
+
persisting the link locally: a link whose notify registration failed never
|
|
144
|
+
receives webhooks and silently breaks auto-confirm. On success, apply the link
|
|
145
|
+
and flip the request to the second OTP step in one transaction.
|
|
146
|
+
- **Routing-key collision** (same bank account, second tenant): the unique index
|
|
147
|
+
rejects `apply_link!` even though Tingee-side confirm succeeded. Mark the
|
|
148
|
+
request `failed` with a clear message.
|
|
149
|
+
- **Double-submit guard**: flip `otp → verifying` under a row lock; only the
|
|
150
|
+
transition enqueues the job. Enqueue AFTER the lock's transaction commits.
|
|
151
|
+
- **Stale-replay guard**: while a request is `verifying`, a job owns it —
|
|
152
|
+
a back-button form replay must not destroy/recreate it. Redirect instead.
|
|
153
|
+
- **Cancel** is allowed from ANY status (it's the escape hatch for a dead job);
|
|
154
|
+
the confirm job must tolerate the record vanishing (`find_by → return if nil`).
|
|
155
|
+
- Persist from `confirm_va`'s response: real `accountNumber` → what your QR
|
|
156
|
+
shows; `vaAccountNumber` → routing key; plus `bankBin`, `shopId`.
|
|
157
|
+
|
|
158
|
+
### UI pattern (Rails)
|
|
159
|
+
|
|
160
|
+
Settings page renders from the `tingee_link_request` state (form → OTP input →
|
|
161
|
+
verifying spinner → failed/cancel) and live-updates via Turbo broadcasts on the
|
|
162
|
+
record's `after_update_commit` / `after_destroy_commit` — the owner can leave the
|
|
163
|
+
page while the bank verifies. Any live-update mechanism (polling included) works;
|
|
164
|
+
the point is: the page follows the DB record.
|
|
165
|
+
|
|
166
|
+
## 4. The webhook endpoint
|
|
167
|
+
|
|
168
|
+
Public, no auth/tenancy — the tenant is resolved FROM the payload. Order matters:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
class Webhooks::TingeeController < ActionController::Base
|
|
172
|
+
skip_forgery_protection
|
|
173
|
+
ACK = { code: "00", message: "Success" }.freeze
|
|
174
|
+
|
|
175
|
+
def create
|
|
176
|
+
raw = request.raw_post
|
|
177
|
+
payload = JSON.parse(raw) rescue nil
|
|
178
|
+
return render(json: ACK) if payload.nil? # unparseable — ack, ignore
|
|
179
|
+
|
|
180
|
+
return render(json: ACK) if payload["event"] == "ping" # 1. unsigned dashboard test — ack or their UI shows failure
|
|
181
|
+
|
|
182
|
+
unless Tingee::Signature.verify( # 2. HMAC over RAW bytes — raw_post verbatim, never re-encode
|
|
183
|
+
secret: Tingee.config.secret_token,
|
|
184
|
+
timestamp: request.headers["x-request-timestamp"],
|
|
185
|
+
raw_body: raw,
|
|
186
|
+
signature: request.headers["x-signature"]
|
|
187
|
+
)
|
|
188
|
+
return head(:unauthorized)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
record(payload) # 3. log + route + enqueue
|
|
192
|
+
render json: ACK # 4. ALWAYS ack success — Tingee stops retrying
|
|
193
|
+
rescue ActiveRecord::RecordNotUnique
|
|
194
|
+
render json: ACK # replay — already recorded, safe no-op
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
def record(payload)
|
|
200
|
+
# nil-guard the routing key: a blank vaAccountNumber must route to NO tenant,
|
|
201
|
+
# not accidentally match every unlinked tenant's nil column.
|
|
202
|
+
va = payload["vaAccountNumber"].presence
|
|
203
|
+
org = va && Organization.find_by(tingee_va_account_number: va)
|
|
204
|
+
webhook = TingeeWebhook.create!(
|
|
205
|
+
organization: org,
|
|
206
|
+
transaction_code: payload["transactionCode"],
|
|
207
|
+
amount: payload["amount"].to_i,
|
|
208
|
+
memo_invoice_id: payload["content"].to_s[/HD(\d+)/i, 1]&.to_i,
|
|
209
|
+
received_at: Time.current
|
|
210
|
+
)
|
|
211
|
+
Tingee::PaymentJob.perform_later(webhook.id)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Route: `post "/webhooks/tingee"`. Non-negotiables:
|
|
217
|
+
|
|
218
|
+
- **Verify raw bytes** (`request.raw_post`) — re-parsing/re-serializing breaks
|
|
219
|
+
valid signatures.
|
|
220
|
+
- **Ack the unsigned ping** before verification.
|
|
221
|
+
- **Record + ack fast, match in a job.** You ack before matching, and Tingee
|
|
222
|
+
never resends an acked transaction — so the matching job MUST retry on
|
|
223
|
+
transient failure (`retry_on StandardError, attempts: 5`) or a hiccup silently
|
|
224
|
+
loses the payment. Matching is idempotent against a still-open invoice, so
|
|
225
|
+
retries are safe.
|
|
226
|
+
- Log EVERY webhook, routable or not — each one bills.
|
|
227
|
+
|
|
228
|
+
## 5. Payment matching
|
|
229
|
+
|
|
230
|
+
App-side policy (deliberately not in the gem). The safe-by-construction rules:
|
|
231
|
+
|
|
232
|
+
- **Memo convention**: your checkout/QR embeds a marker (`HD<invoice_id>`);
|
|
233
|
+
extract with a permissive regex — banks append their own reference text.
|
|
234
|
+
Mint the QR with `Tingee::VietQR.payload` (no `img.vietqr.io` call needed) and
|
|
235
|
+
**store what `Tingee::VietQR.normalize_description` returns, then match on that** —
|
|
236
|
+
it ASCII-folds and truncates the memo, so matching your original un-normalized
|
|
237
|
+
string misses every payment.
|
|
238
|
+
- **All must hold**: feature flag enabled for the tenant, invoice still
|
|
239
|
+
open/draft, **exact amount match**, invoice belongs to the routed tenant
|
|
240
|
+
(run the matcher inside the tenant scope so cross-tenant attribution is
|
|
241
|
+
impossible by construction).
|
|
242
|
+
- **Re-check open + exact amount UNDER a row lock, and pay in the same lock** —
|
|
243
|
+
a concurrent edit to the invoice between check and pay could settle it for a
|
|
244
|
+
total that no longer equals what was transferred.
|
|
245
|
+
- **Any miss is safe**: fall through to your existing manual confirm path and
|
|
246
|
+
stamp `processed_at`. Narrow rules, safe misses — never guess.
|
|
247
|
+
- Optional but valuable: a **self-test transfer** path — the settings page shows
|
|
248
|
+
a QR with a special memo; the matcher recognizes it, stamps
|
|
249
|
+
`tingee_test_verified_at`, and the page celebrates. Proves the whole pipe
|
|
250
|
+
end-to-end per account before real money relies on it.
|
|
251
|
+
|
|
252
|
+
## 6. Unlink
|
|
253
|
+
|
|
254
|
+
Mirror of linking, same state machine and job (`start_delete_va` →
|
|
255
|
+
`confirm_delete_va` with OTP):
|
|
256
|
+
|
|
257
|
+
- `delete_va` is slow too (it triggers the bank-side detach + OTP send) — run it
|
|
258
|
+
in the job, show the verifying spinner until the OTP form appears.
|
|
259
|
+
- Both `delete_va` and `confirm_delete_va` key the bank by **BIN** (`"970403"`).
|
|
260
|
+
Passing the short code (`bankName: "STB"`) to `delete_va` returns a `confirmId`
|
|
261
|
+
and fires the bank's OTP, but opens a session `confirm_delete_va` cannot confirm —
|
|
262
|
+
it fails with `"Lỗi hệ thống phương thức xác thực"` (live, 2026-07-17). The error
|
|
263
|
+
surfaces one step after the mistake, which is what makes it costly to diagnose.
|
|
264
|
+
- `delete_va`'s response shape varies by bank: `{confirmId}` (OTP banks, show the OTP
|
|
265
|
+
form), an `authorizeLink` (TPB — owner approves at the bank, a `delete-va-success`
|
|
266
|
+
webhook finishes it), or `{}` (VCB — already detached, finish locally now).
|
|
267
|
+
- On confirmed unlink: clear all `tingee_*` fields AND the test-verified stamp
|
|
268
|
+
(a re-link needs fresh proof).
|
|
269
|
+
- **Business rule**: disabling your feature flag does NOT stop Tingee's meter —
|
|
270
|
+
offer a "disable" that flags off AND unlinks in one action.
|
|
271
|
+
|
|
272
|
+
## 7. Go-live checklist
|
|
273
|
+
|
|
274
|
+
- [ ] Credentials in prod; boot succeeds without them in other envs.
|
|
275
|
+
- [ ] Webhook URL registered in the Tingee dashboard (API Credentials/HMAC auth);
|
|
276
|
+
dashboard "test webhook" returns success (the unsigned ping path works).
|
|
277
|
+
- [ ] `curl -X POST https://yourapp.com/webhooks/tingee -d '{}'` →
|
|
278
|
+
`{"code":"00","message":"Success"}`.
|
|
279
|
+
- [ ] Feature flag default-false for every account; enable per pilot account.
|
|
280
|
+
- [ ] Per pilot account, real end-to-end proof: link a supported bank → transfer
|
|
281
|
+
the exact total with the memo marker → invoice auto-pays with no manual
|
|
282
|
+
refresh; wrong amount does NOT auto-pay; manual confirm still works.
|
|
283
|
+
- [ ] Monitoring: webhooks vs matched per account (match rate),
|
|
284
|
+
`organization_id IS NULL` rows (unroutable = linking problem, still
|
|
285
|
+
billed), `processed_at IS NULL` older than a few minutes (stranded job —
|
|
286
|
+
re-enqueue; manual confirm is the safety net).
|
|
287
|
+
- [ ] Rollback story: flag off stops auto-pay instantly; unlink stops billing;
|
|
288
|
+
manual confirm untouched throughout — the feature degrades to the old
|
|
289
|
+
behavior, never worse.
|
|
290
|
+
|
|
291
|
+
## 8. Gotchas index (hard-won, don't relearn)
|
|
292
|
+
|
|
293
|
+
| # | Gotcha |
|
|
294
|
+
|---|---|
|
|
295
|
+
| 1 | `confirm_va`/`delete_va` take minutes at the bank — background job + `read_timeout: 300`, never a web request. |
|
|
296
|
+
| 2 | OTP is consumed on first attempt — no auto-retry on confirm jobs. |
|
|
297
|
+
| 3 | Webhook HMAC is over RAW bytes — `request.raw_post` verbatim. |
|
|
298
|
+
| 4 | Dashboard ping is UNSIGNED — ack it before verifying. |
|
|
299
|
+
| 5 | Ack-before-match means the match job MUST retry — Tingee never resends an acked txn. |
|
|
300
|
+
| 6 | Idempotency = DB unique index on `transactionCode`, rescue the duplicate error with an ack. |
|
|
301
|
+
| 7 | Blank `vaAccountNumber` must route nowhere — guard the nil-column lookup. |
|
|
302
|
+
| 8 | `delete_va`: query-string params, keyed by **BIN**. The `bankName` variant returns a confirmId and sends an OTP but opens an unconfirmable session — confirm-delete-va then 400s. |
|
|
303
|
+
| 9 | ACB needs `register_notify` (second OTP) — register BEFORE persisting the link. |
|
|
304
|
+
| 10 | `mobile` domestic `0`-prefix; never persist identity/mobile/OTP; never store raw memos (PII). |
|
|
305
|
+
| 11 | Exact-amount re-check under the invoice row lock. |
|
|
306
|
+
| 12 | Unlink is the only way to stop per-webhook billing. |
|
|
307
|
+
| 13 | Techcombank unsupported — handle in UI, not as an error surprise. |
|