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,317 @@
1
+ # Tingee BaaS — verified API reference
2
+
3
+ Official documentation: **https://developers.tingee.vn/docs/banking/**
4
+
5
+ Everything here was **observed against the live production API (2026-07-16)**, not
6
+ copied from the docs above. Claims marked `NOT OBSERVED` are unverified. This is
7
+ the contract `Tingee::Client` and `Tingee::Signature` are built against. Where
8
+ this file and the official docs disagree, this file records what the API
9
+ actually did; re-verify against the live API before trusting either.
10
+
11
+ Redaction: account numbers → last 4, no identity/CCCD, no mobile numbers, no
12
+ client_id/secret_token, no payer PII from memos.
13
+
14
+ ## 0. Auth & signing (OBSERVED)
15
+
16
+ - Base URL (prod): `https://open-api.tingee.vn`
17
+ - Headers on every request: `x-client-id`, `x-request-timestamp`
18
+ (`yyyyMMddHHmmssSSS`, **UTC+7**), `x-signature`, `Content-Type: application/json`.
19
+ - Signature: `HMAC_SHA512(secret_token, x-request-timestamp + ":" + JSON.stringify(body))`, **hex** digest.
20
+ - **`body` is `payload || {}`** — a no-body GET signs the string `"{}"`, NOT empty.
21
+ Signing `""` returns `code 97 Invalid signature`. Confirmed against the official
22
+ `tingee-node` SDK (`src/signature/signer.ts`, `src/client/http.ts`:
23
+ `generateSignature(secretKey, timestamp, body || {})`) and a live `get-banks`
24
+ call. The wire request still omits the body on GET; only the SIGNED string is `"{}"`.
25
+ - Timestamp window: ±10 min of server clock (else error `90`).
26
+
27
+ Known-good triple from a live `get-banks` GET (the ENV-gated signature test asserts
28
+ this; the secret is not stored here):
29
+
30
+ ```
31
+ timestamp: 20260716155928753
32
+ body: {}
33
+ signature: 93a3031c8e3a26ede4d4d684cad1f69a459acbfc24deb87a39ed918a6b70f909537105e8f7c491f59932ff9401c5499c8d92a02e6709a8310ddc947255ab4de4
34
+ ```
35
+
36
+ Ruby encoding note: `OpenSSL::HMAC.hexdigest` on a UTF-8 message warns
37
+ "UTF-8 string passed as BINARY" under json 3.0 — the gem `.b`-forces the signing
38
+ string to bytes. The digest of ASCII/UTF-8 bytes is unchanged either way.
39
+
40
+ ## Response envelope
41
+
42
+ All endpoints return `{code, message, data}`; `code == "00"` is success and the
43
+ gem unwraps and returns `data`. **Exception: `get-banks` returns a bare JSON
44
+ array** — the array IS the success payload.
45
+
46
+ ## 1. get-banks — supported banks (LIVE)
47
+
48
+ `GET /v1/get-banks`. Row shape:
49
+ `{code, name, bin, shortName, urlLogo, termsAndConditions:{url, urlVA}}`.
50
+
51
+ **Tingee supports 14 real banks (+ NEXTPAY mPOS).** Napas BIN → Tingee code:
52
+
53
+ | Bank | Napas BIN | Tingee code | Notes |
54
+ |---|---|---|---|
55
+ | Vietcombank | 970436 | VCB | |
56
+ | VietinBank | 970415 | CTG | |
57
+ | BIDV | 970418 | BIDV | |
58
+ | MB Bank | 970422 | MBB | |
59
+ | ACB | 970416 | ACB | needs register-notify (extra OTP round) |
60
+ | OCB | 970448 | OCB | |
61
+ | VPBank | 970432 | VPB | |
62
+ | Sacombank | 970403 | STB | |
63
+ | VIB | 970441 | VIB | |
64
+ | TPBank | 970423 | TPB | authorizeLink flow |
65
+ | MSB | 970426 | MSB | |
66
+ | PGBank | 970430 | PGB | |
67
+ | Shinhan | 970424 | SHINHAN | |
68
+ | Co-opBank | 970446 | COB | |
69
+
70
+ **Canonical supported code list** (leaked by a `create-bank-link-session` 400 on
71
+ `allowedBanks`): `OCB, BIDV, MBB, ACB, VPB, PGB, VIB, STB, CTG, VCB, AGRIBANK,
72
+ SHINHAN, COB, MSB, NEXTPAY, TPB`. Note `AGRIBANK` is accepted here but was NOT
73
+ returned by `get-banks` — Agribank is supported (via bank-link) despite the
74
+ get-banks gap.
75
+
76
+ **❌ NOT supported** (verified absent from both get-banks and allowedBanks):
77
+ **Techcombank (TCB)** — the one large bank genuinely unsupported — plus SHB,
78
+ HDBank, Eximbank, SeABank, NamABank, SCB, LPBank, VietABank, ABBANK, BacABank,
79
+ PVcomBank, NCB, VietCapitalBank, SaigonBank, BaoVietBank, VietBank, GPBank,
80
+ Oceanbank/MBV.
81
+
82
+ ## 2. create-bank-link-session — hosted SDK flow (LIVE)
83
+
84
+ `POST /v1/create-bank-link-session` with an **empty body** → `code 00`,
85
+ `data` = SDK URL string.
86
+
87
+ - **No `merchantId` / sub-merchant provisioning needed** for the default merchant —
88
+ credentials resolve to a merchant directly. Pass `merchantId` only for a
89
+ sub-merchant.
90
+ - URL returned points at prod (`https://bank-link.tingee.vn?token=…&s=…`) when the
91
+ configured base is prod. Linking there links a real account.
92
+ - The token is base64 JSON: `{merchantId, type, timestamp, clientId}` — clientId is
93
+ the public id (not the secret).
94
+ - Optional params: `redirectUrl`, `allowedBanks` (array of Tingee codes),
95
+ `bankName`, `shopId`.
96
+
97
+ **Known Tingee bug (2026-07-16, escalated):** the hosted SDK JS crashes on some
98
+ banks with `TypeError: e.confirmId.startsWith is not a function` after the user
99
+ links. It's their frontend bug — the SDK internally calls `create-va` (it holds a
100
+ `confirmId`) and mishandles it. Fallback: the raw create-va chain below, which is
101
+ fully verified.
102
+
103
+ ## 3. Manual link chain — create-va → confirm-va (LIVE, works end-to-end)
104
+
105
+ > **Redirect-authorize banks (VCB, TPB) use the same endpoint with a different
106
+ > shape:** add `appType:"baas"` + `redirectUrl` and create-va answers with an
107
+ > authorize link (`deepLink` for VCB, `authorizeLink` for TPB) instead of sending an
108
+ > OTP. There is no confirm-va step — the result arrives on your webhook as
109
+ > `status:"confirm-va-success"|"confirm-va-failed"` (§7). Pass your own `requestId`:
110
+ > it is echoed back on that webhook, and **TPB returns no `confirmId` at all**, so it
111
+ > is the only correlation key. One `Client#create_va` covers all shapes; unset params
112
+ > are omitted from the payload.
113
+
114
+ Bypasses the hosted SDK. Verified flow (Sacombank, real account):
115
+
116
+ 1. `POST /v1/create-va`
117
+ `{accountType:"personal-account", bankBin, accountNumber, accountName,
118
+ identity (CCCD), mobile, isNotifyAccountNumber, webhookUrl}`
119
+ → `data: {confirmId, otpMethod}`.
120
+ - **`mobile` must be domestic `0`-prefixed** (`09…`); `84…` →
121
+ `400 mobile must be a valid phone number`.
122
+ - STB returns `otpMethod:"SmartOTP"` (bank-app OTP, not SMS). No `authorizeLink`
123
+ (that's TPBank only).
124
+ - **`isNotifyAccountNumber: false` is the verified-working mode**: a real VietQR
125
+ transfer to the linked real account fired the payment webhook on a
126
+ notify=false link. notify=true ("watch the real account") MAY also work but
127
+ was never confirmed to fire on a plain transfer.
128
+ 2. `POST /v1/confirm-va` `{bankBin, confirmId, otpNumber}`
129
+ → `data: {bankName, accountType, accountNumber, vaAccountNumber, shopId}`.
130
+ - Bank-side OTP verification **can take minutes** — run in a background job with
131
+ a raised read timeout, not a web request.
132
+ - STB needs NO register-notify — confirm-va alone activates. **Only ACB needs
133
+ the extra step** (§4).
134
+ 3. `get-va-paging` then shows the link:
135
+ `{bankName, bankBin, accountType, accountName, accountNumber:"xxxx…119",
136
+ vaAccountNumber:"TNG60716171228", status:"active", creationTime, shopId}`.
137
+
138
+ ### The two account numbers (critical)
139
+
140
+ confirm-va returns BOTH:
141
+
142
+ - `accountNumber` — the **real** bank account. Money lands here; this is what a
143
+ customer transfers to and what a QR should show.
144
+ - `vaAccountNumber` — `TNG` + timestamp digits, a **Tingee-internal routing
145
+ handle**, NOT a transferable account. Store it as your unique routing key for
146
+ webhooks.
147
+
148
+ Also store `bankBin` and `shopId` from the response — `delete-va` needs the BIN.
149
+
150
+ ## 4. register-notify — ACB only (LIVE contract)
151
+
152
+ `POST /v1/register-notify` `{vaAccountNumber, bankBin}` → `data: {confirmId}` —
153
+ run once right after confirm-va succeeds, then
154
+ `POST /v1/confirm-register-notify` `{bankBin, confirmId, otpNumber}` with the
155
+ second OTP.
156
+
157
+ ## 5. get-va-paging (LIVE)
158
+
159
+ `POST /v1/get-va-paging` (optional `{merchantId}`) → `data: {totalCount, items}`.
160
+
161
+ ## 6. Unlink — delete-va → confirm-delete-va (LIVE)
162
+
163
+ Tingee is inconsistent about *where* the params ride, but both key the bank by
164
+ **BIN** — verified live:
165
+
166
+ - `POST /v1/delete-va` — params ride the **QUERY STRING** (not the JSON body):
167
+ `?bankBin=970403&vaAccountNumber=TNG…`. The bodyless-signing convention still
168
+ applies (the signed string is `"{}"`). → `data: {confirmId}`.
169
+ - `POST /v1/confirm-delete-va` — params ride the **BODY**:
170
+ `{bankBin, confirmId, otpNumber}`.
171
+
172
+ **Trap (2026-07-17):** a `?bankName=STB` variant of delete-va *appears* to work — it
173
+ returns a `confirmId` and the bank really does send an OTP — but the session it opens
174
+ is unconfirmable: confirm-delete-va then fails with
175
+ `"Lỗi hệ thống phương thức xác thực"`. The failure surfaces one step later than the
176
+ mistake, which is what makes it costly to diagnose. Use the BIN on both calls.
177
+
178
+ Response shape varies by bank: OTP banks return `{confirmId}`, TPB returns an
179
+ `authorizeLink` (owner approves at the bank; a `delete-va-success` webhook finishes
180
+ it), and VCB returns `{}` — already detached, nothing to confirm.
181
+
182
+ Unlinking is also how per-webhook billing stops for an account (see §9).
183
+
184
+ ## 6b. transaction/get-paging — transaction history (DOC, not yet live-verified)
185
+
186
+ `POST /v1/transaction/get-paging` → `data: {totalCount, items}`. Body:
187
+
188
+ - `startTime`, `endTime` — **required**, format `"yyyyMMddHHmmss"` (UTC+7). Each
189
+ request may span at most a **10-day** window (Tingee errors past that).
190
+ - Optional: `filter` (keyword — tx code/content), `skipCount`, `maxResultCount`,
191
+ `merchantId` (required for a Master Merchant), `shopIds[]`, `vaAccountNumbers[]`,
192
+ `bankBin`.
193
+
194
+ Item shape (per the docs): `{transactionId, accountNumber, amount, type
195
+ (CREDIT/DEBIT), bankBin, bankName, transactionTime, description}`.
196
+
197
+ ## 7. Webhooks
198
+
199
+ ### Payment callback (LIVE, real captured payload)
200
+
201
+ ```json
202
+ {"clientId":"…","transactionCode":"<acct>/FT26197KVBJB","amount":2000,
203
+ "content":"hello FT26197646813469","bank":"STB","bankBin":"970403",
204
+ "accountNumber":"<real acct>","vaAccountNumber":"TNG60716173559",
205
+ "transactionDate":"20260716174026","additionalData":[]}
206
+ ```
207
+
208
+ Field semantics (all confirmed real):
209
+
210
+ - idempotency key → `transactionCode` (`<accountNumber>/FT<ref>`, unique per txn)
211
+ - amount → `amount` — **integer**, no decimals observed
212
+ - memo → `content` (payer text; the bank may append its FT ref)
213
+ - routing → `vaAccountNumber` (unique per link) or `accountNumber` (real);
214
+ `bankBin` IS present
215
+ - `transactionDate` — `yyyyMMddHHmmss`
216
+ - `additionalData: []` — empty for a plain transfer; a billId-bound dynamic QR may
217
+ populate it
218
+
219
+ **A plain bank transfer (any VietQR / manual transfer into the real account) fires
220
+ the webhook** — no Tingee-minted QR required. Verified with a real transfer.
221
+
222
+ ### Bank-link result callback (redirect-authorize banks)
223
+
224
+ Redirect-authorize banks (VCB, TPB) have no confirm-va step — the link's outcome
225
+ arrives here instead, on the same endpoint as payments:
226
+
227
+ ```json
228
+ {"requestId":"<the requestId YOU sent to create-va>","status":"confirm-va-success",
229
+ "vaAccountNumber":"TNG…","accountNumber":"<real acct>","accountName":"NGUYEN VAN A"}
230
+ ```
231
+
232
+ - `status` — `confirm-va-success` | `confirm-va-failed` | `delete-va-success`.
233
+ - **Distinguish it from a payment by SHAPE**: a link result has `status` and NO
234
+ `transactionCode`; a payment has `transactionCode` and no `status`. The two are
235
+ disjoint, so branch on that rather than guessing from other fields.
236
+ - Correlate on `requestId` — it echoes the one you sent to create-va. VCB echoes it
237
+ as `confirmId` in the create-va response too; **TPB returns no confirmId at all**,
238
+ so your own `requestId` is the only key that works for both.
239
+ - Unlike confirm-va, this payload DOES echo `accountName` — prefer it over whatever
240
+ the owner typed.
241
+ - `delete-va-success` is best matched on `vaAccountNumber` (authoritative for "which
242
+ account just detached"); its `requestId` echo is unverified.
243
+
244
+ ### Signature verification (LIVE — raw bytes, resolved 2026-07-16)
245
+
246
+ Tingee signs the **RAW body bytes exactly as sent**:
247
+ `x-signature = HMAC_SHA512(secret, x-request-timestamp + ":" + raw_body)`.
248
+
249
+ A genuine payment callback's signature was reproduced from the raw body verbatim.
250
+ Do NOT re-parse/re-serialize — hash raw. (The official `tingee-node` SDK's
251
+ `verifyWebhookSignature` re-serializes via `JSON.stringify(JSON.parse(body))`;
252
+ against the real webhook, raw bytes are what verified. Raw is also immune to
253
+ Ruby-vs-JS number formatting: a re-serialized whole-number float would render
254
+ `250000.0` in Ruby vs `250000` in JS and break a valid signature.)
255
+ Rails: pass `request.raw_post` verbatim to `Tingee::Signature.verify`.
256
+
257
+ ### Connection-test ping (LIVE)
258
+
259
+ Tingee's dashboard "test webhook" sends
260
+ `{"event":"ping","message":"Tingee Webhook Connection Test","timestamp":<ms>}`
261
+ with **NO `x-signature` / `x-request-timestamp` headers — the ping is UNSIGNED**.
262
+ Your endpoint must short-circuit on `event == "ping"` (or "only verify when the
263
+ signature header is present") and 200-ack, else the dashboard's connection test
264
+ shows failure.
265
+
266
+ ### Ack & retry contract
267
+
268
+ - Success ack: `{"code":"00","message":"Success"}` at HTTP 200 — Tingee stops
269
+ retrying on it. Always ack replays too (idempotency on `transactionCode`).
270
+ - Retry interval/count on failure: Tingee's docs conflict (1 min vs 5 min) —
271
+ `NOT OBSERVED` precisely.
272
+ - Whether `"02"` (already-processed) is honored for replays: `NOT OBSERVED`.
273
+
274
+ ### Registering the webhook
275
+
276
+ In the Tingee dashboard, register your webhook URL with auth =
277
+ **API Credentials** (HMAC) — not the static API-key option — so callbacks carry
278
+ `x-signature`/`x-request-timestamp`.
279
+
280
+ ## 8. Error codes
281
+
282
+ | Code | Meaning |
283
+ |---|---|
284
+ | `00` | Success |
285
+ | `90` | Timestamp outside the ±10 min window |
286
+ | `91` | Timeout |
287
+ | `97` | Invalid signature (including the signed-`""`-instead-of-`"{}"` mistake) |
288
+ | `1001`–`1076` | Business errors (undocumented series, codes kept raw) |
289
+
290
+ The gem raises `Tingee::Error` with the raw code preserved; transport failures are
291
+ normalized to code `"NETWORK"`, non-JSON gateway/WAF pages to `"HTTP_<status>"`.
292
+
293
+ ## 9. Operational notes
294
+
295
+ - **Billing is per delivered webhook** (check Tingee's current pricing).
296
+ Delivered ≠ matched — unroutable webhooks still bill. Whether retries
297
+ bill: `NOT OBSERVED` (ask support).
298
+ - Disabling your own feature flags does NOT stop the meter — **only unlinking
299
+ (`delete-va` chain) stops webhook billing** for an account.
300
+ - Post-unlink webhook behavior (do they fully stop?): `NOT OBSERVED`.
301
+ - `vaAccountNumber` uniqueness scope (global vs bank-scoped): `NOT OBSERVED`.
302
+ - Outgoing-transfer callbacks (does a debit fire a webhook too?): `NOT OBSERVED` —
303
+ determines whether the per-webhook cost covers only customer payments or all
304
+ balance changes.
305
+
306
+ ## 10. Known Tingee bugs (escalated 2026-07-16, off this gem's critical path)
307
+
308
+ - `/v1/generate-dynamic-qr` returns
309
+ `500 "Field 'accountNumber' doesn't have a default value"` for a valid documented
310
+ request (their dashboard mints the same QR fine). Not wrapped by this gem —
311
+ plain transfers fire the webhook, so dynamic QRs aren't needed for payment
312
+ confirmation. Unnecessary anyway: `Tingee::VietQR.payload` mints the transfer QR
313
+ locally, and a plain transfer into the real account fires the webhook whatever
314
+ minted the QR.
315
+ - Hosted bank-link JS SDK crashes
316
+ `TypeError: e.confirmId.startsWith is not a function` (§2). Workaround: the raw
317
+ create-va chain (§3).
@@ -0,0 +1,130 @@
1
+ # 🛠️ Tài liệu hướng dẫn tích hợp API — Liên kết tài khoản VCB Cá nhân
2
+
3
+ > Nguồn: `Tài_liệu_Liên_kết_VCB_Cá_nhân.docx` (Tingee by Heno). Chuyển sang Markdown, giữ nguyên nội dung gốc.
4
+ >
5
+ > **Lưu ý luồng:** VCB cá nhân dùng **deepLink** mở app VCB Digibank để khách xác nhận — KHÔNG có bước `confirm-va` bằng OTP như các ngân hàng khác. Kết quả liên kết trả về **bất đồng bộ qua webhook** (`confirm-va-success` / `confirm-va-failed`).
6
+
7
+ ## I. Tổng quan
8
+
9
+ Tài liệu này hướng dẫn cách tích hợp API của hệ thống Tingee để thực hiện:
10
+
11
+ - Liên kết tài khoản VCB cá nhân
12
+ - Nhận webhook khi có kết quả
13
+
14
+ ## II. Hướng dẫn chi tiết
15
+
16
+ ### Bước 1: Đăng ký tài khoản Tingee
17
+
18
+ Truy cập: <https://app.tingee.vn/>
19
+
20
+ ### Bước 2: Lấy thông tin ứng dụng
21
+
22
+ Truy cập: **Trang chủ Tingee → Avatar → Developers**
23
+
24
+ Tại đây bạn sẽ lấy được:
25
+
26
+ - `x-client-id`
27
+ - `Secret token`
28
+
29
+ ![Trang Developers — ClientId, Secret token, Url webhook](./images/tingee-developers-credentials.png)
30
+
31
+ ## III. API liên kết tài khoản ngân hàng VCB
32
+
33
+ **Endpoint:** `POST https://open-api.tingee.vn/v1/create-va`
34
+
35
+ ### Headers
36
+
37
+ | Tên | Kiểu | Mô tả |
38
+ |-----|------|-------|
39
+ | `x-client-id` | string | Định danh ứng dụng (lấy tại mục Developers) |
40
+ | `x-request-timestamp` | string | Dấu thời gian theo định dạng `yyyyMMddHHmmssSSS` |
41
+ | `x-signature` | string | Chuỗi hash HMAC SHA512: `{timestamp}:{requestBodyString}` với Secret token làm key |
42
+
43
+ ### Request Body
44
+
45
+ | Tên | Kiểu | Mô tả |
46
+ |-----|------|-------|
47
+ | `requestId` | UUID | ID request liên kết tài khoản |
48
+ | `bankName` / `bankBin` | string | Mã code / Mã bin VCB |
49
+ | `mobile` | string | Số điện thoại KH |
50
+ | `merchantId` | number | ID merchant Tingee |
51
+ | `shopId` | number | ID shop muốn liên kết VA (bỏ qua nếu muốn tạo shop mới trên Tingee) |
52
+ | `merchantName` | string | Tên shop của merchant |
53
+ | `merchantAddress` | string | Địa chỉ shop |
54
+ | `accountNumber` | string | Số tài khoản |
55
+ | `accountType` | string | Truyền `personal-account` |
56
+ | `vaPrefix` | string | Mã đầu VA (dùng cho luồng quét QR trên loa để liên kết) |
57
+ | `vaSuffix` | string | Chuỗi VA (dùng cho luồng quét QR trên loa để liên kết) |
58
+ | `appType` | string | Mặc định để `baas` |
59
+ | `redirectUrl` | string | URL để redirect về app FinOne |
60
+ | `webhookUrl` | string | URL nhận webhook kết quả |
61
+
62
+ ### Response
63
+
64
+ | Tên | Kiểu | Mô tả |
65
+ |-----|------|-------|
66
+ | `code` | string | Mã lỗi |
67
+ | `message` | string | Thông tin lỗi |
68
+ | `data` | object | |
69
+ | `data.confirmId` | string | ID confirm |
70
+ | `data.deepLink` | string | Deeplink mở app VCB |
71
+
72
+ ### Sample
73
+
74
+ **Request (curl):**
75
+
76
+ ```bash
77
+ curl -X 'POST' \
78
+ 'http://open-api.tingee.vn/v1/create-va' \
79
+ -H 'accept: */*' \
80
+ -H 'x-signature: 222da13b161cd6a15f337a4c35a22cf3a0ff375bab494a4ae302d07c27687aa281b7899d7a31eeceb9caad7eb0dab506f880b590c01f49e750329fee54213ab5' \
81
+ -H 'x-request-timestamp: 20250828140611111' \
82
+ -H 'x-client-id: 3021ab73e90e72e3a0f01cbfd8f6d604' \
83
+ -H 'Content-Type: application/json' \
84
+ -d '{
85
+ "requestId": "000000c4-62bd-4e50-a213-b51e3772074e",
86
+ "bankName": "VCB",
87
+ "mobile": "0987665555",
88
+ "merchantId": 140998,
89
+ "merchantName": "Cửa hàng số 1",
90
+ "merchantAddress": "Hà Nội",
91
+ "accountNumber": "0912323232",
92
+ "accountType": "personal-account",
93
+ "appType": "baas",
94
+ "redirectUrl": "finonemerchant://"
95
+ }'
96
+ ```
97
+
98
+ **Response:**
99
+
100
+ ```json
101
+ {
102
+ "code": "00",
103
+ "message": "Success",
104
+ "data": [
105
+ {
106
+ "confirmId": "000000c4-62bd-4e50-a213-b51e3772074e",
107
+ "deepLink": "vcbpartner://linkPaymentEvent?token=eyJhbGciOiJSUzI1NiJ9...."
108
+ }
109
+ ]
110
+ }
111
+ ```
112
+
113
+ ## IV. Nhận Webhook thông báo kết quả
114
+
115
+ - Khi khách hàng xác nhận liên kết xong trên app VCB Digibank, VCB sẽ gửi webhook kết quả về cho Tingee. Từ đó Tingee xử lý dữ liệu rồi gửi webhook lại cho FinOne qua **Webhook URL** mà đối tác đã gửi qua Tingee ở API liên kết tài khoản.
116
+
117
+ ### Dữ liệu Webhook
118
+
119
+ | Tên | Kiểu | Mô tả |
120
+ |-----|------|-------|
121
+ | `requestId` | string | ID request khi gọi API liên kết |
122
+ | `merchantId` | number | ID Merchant |
123
+ | `shopId` | number | ID shop |
124
+ | `bankName` | string | Mã code VCB |
125
+ | `bankBin` | string | Mã bin VCB |
126
+ | `accountNumber` | string | Số tài khoản |
127
+ | `accountName` | string | Tên chủ tài khoản |
128
+ | `vaAccountNumber` | string | Số tài khoản ảo |
129
+ | `isNotifyAccountNumber` | boolean | `false`: Liên kết và tạo TK ảo |
130
+ | `status` | string | Trạng thái liên kết — `"confirm-va-success"`: Thành công / `"confirm-va-failed"`: Thất bại |
@@ -0,0 +1,205 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module Tingee
6
+ # HTTP + endpoint methods. Only live-verified endpoints are wrapped — no
7
+ # speculative "complete SDK". Responses use a uniform {code, message, data}
8
+ # envelope EXCEPT get-banks, which returns a bare array (verified live).
9
+ class Client
10
+ # read_timeout: web requests keep the 90s default (stay under your proxy's
11
+ # response timeout); background jobs should pass a larger value — bank-side
12
+ # OTP verification on confirm_va can take minutes.
13
+ def initialize(config = Tingee.config, read_timeout: 90)
14
+ @config = config
15
+ @read_timeout = read_timeout
16
+ @config.validate!
17
+ end
18
+
19
+ # GET /v1/get-banks — returns the bare bank array (the supported-bank/BIN map).
20
+ def get_banks
21
+ get("/v1/get-banks")
22
+ end
23
+
24
+ # POST /v1/create-bank-link-session — returns the SDK URL string (data). No
25
+ # merchant_id is needed for the default merchant; pass one only for a sub-merchant.
26
+ def create_bank_link_session(merchant_id: nil, redirect_url: nil, allowed_banks: nil, bank_name: nil, shop_id: nil)
27
+ payload = { type: "bank-link" }
28
+ payload[:merchantId] = merchant_id if merchant_id
29
+ payload[:redirectUrl] = redirect_url if redirect_url
30
+ payload[:allowedBanks] = Array(allowed_banks) if allowed_banks
31
+ payload[:bankName] = bank_name if bank_name
32
+ payload[:shopId] = shop_id if shop_id
33
+ post("/v1/create-bank-link-session", payload)
34
+ end
35
+
36
+ # POST /v1/get-va-paging — returns {totalCount, items} of linked virtual accounts.
37
+ def get_va_paging(merchant_id: nil)
38
+ payload = {}
39
+ payload[:merchantId] = merchant_id if merchant_id
40
+ post("/v1/get-va-paging", payload)
41
+ end
42
+
43
+ # POST /v1/create-va — starts a bank link (the raw API chain, usable when Tingee's
44
+ # hosted JS SDK is unavailable or broken — docs/tingee-api-reference.md §create-va,
45
+ # live-verified). ONE method serves all three bank shapes; which one you get is
46
+ # decided by the bank, not by a different endpoint:
47
+ #
48
+ # OTP banks (STB, ACB, MBB, …) — pass account_number/account_name/identity/mobile.
49
+ # The bank sends/pushes an OTP to `mobile`. Returns {confirmId, otpMethod};
50
+ # finish with #confirm_va.
51
+ # Redirect-authorize banks (VCB) — additionally pass app_type: "baas" +
52
+ # redirect_url + request_id. Returns an authorizeLink/deepLink instead of
53
+ # sending an OTP; the owner confirms in the bank's app and the result arrives
54
+ # asynchronously on webhook_url as {status: "confirm-va-success"|"confirm-va-failed"}.
55
+ # There is NO confirm_va step for these.
56
+ # No-account-field banks (TPB, doc-sourced) — pass neither account nor identity
57
+ # fields; the owner picks the account on the bank's own web. TPB returns NO
58
+ # confirmId at all, so the request_id YOU send is the only key that can
59
+ # correlate the settle webhook back to your pending request. Always pass and
60
+ # STORE your own request_id for any redirect-authorize flow.
61
+ #
62
+ # Every optional field is omitted from the payload when nil, so a bank only ever
63
+ # receives the params its contract actually defines (VCB tolerates and overrides
64
+ # what it doesn't use — verified live; TPB's contract has none of them).
65
+ #
66
+ # is_notify_account_number: FALSE is the DEFAULT because it is the mode proved
67
+ # end-to-end — a real VietQR transfer to the linked real account fired the webhook
68
+ # on a notify=false link (2026-07-16). notify=true is documented as "watch the real
69
+ # account" and MAY also work, but was never confirmed to fire on a plain transfer;
70
+ # do not switch the default to true without a real-transfer test on a true link.
71
+ def create_va(bank_bin:, webhook_url:, account_number: nil, account_name: nil,
72
+ identity: nil, mobile: nil, account_type: "personal-account",
73
+ is_notify_account_number: false, app_type: nil, redirect_url: nil,
74
+ request_id: nil, merchant_id: nil, merchant_name: nil,
75
+ merchant_address: nil, shop_id: nil, va_prefix: nil, va_suffix: nil)
76
+ payload = {
77
+ accountType: account_type, bankBin: bank_bin,
78
+ isNotifyAccountNumber: is_notify_account_number,
79
+ webhookUrl: webhook_url
80
+ }
81
+ payload[:accountNumber] = account_number if account_number
82
+ payload[:accountName] = account_name if account_name
83
+ payload[:identity] = identity if identity
84
+ payload[:mobile] = mobile if mobile
85
+ payload[:appType] = app_type if app_type
86
+ payload[:redirectUrl] = redirect_url if redirect_url
87
+ payload[:requestId] = request_id if request_id
88
+ payload[:merchantId] = merchant_id if merchant_id
89
+ payload[:merchantName] = merchant_name if merchant_name
90
+ payload[:merchantAddress] = merchant_address if merchant_address
91
+ payload[:vaPrefix] = va_prefix if va_prefix
92
+ payload[:vaSuffix] = va_suffix if va_suffix
93
+ # One shop per project; an explicit arg wins over the configured default.
94
+ shop_id ||= @config.shop_id
95
+ payload[:shopId] = shop_id if shop_id
96
+ post("/v1/create-va", payload)
97
+ end
98
+
99
+ # POST /v1/confirm-va — finishes the link with the bank's OTP. Returns
100
+ # {bankName, accountType, accountNumber (real), vaAccountNumber (routing key), shopId}.
101
+ def confirm_va(bank_bin:, confirm_id:, otp_number:)
102
+ post("/v1/confirm-va", { bankBin: bank_bin, confirmId: confirm_id, otpNumber: otp_number })
103
+ end
104
+
105
+ # POST /v1/register-notify — ACB only, run once right after confirm_va succeeds.
106
+ # Returns {confirmId} for a second OTP round (see #confirm_register_notify).
107
+ def register_notify(bank_bin:, va_account_number:)
108
+ post("/v1/register-notify", { vaAccountNumber: va_account_number, bankBin: bank_bin })
109
+ end
110
+
111
+ def confirm_register_notify(bank_bin:, confirm_id:, otp_number:)
112
+ post("/v1/confirm-register-notify", { bankBin: bank_bin, confirmId: confirm_id, otpNumber: otp_number })
113
+ end
114
+
115
+ # POST /v1/delete-va — starts an unlink. Params ride the QUERY STRING (not the
116
+ # JSON body); the bank is identified by `bankBin`, per the documented contract.
117
+ #
118
+ # A `bankName` variant (Tingee's short bank CODE, e.g. "STB") also returns a
119
+ # confirmId AND triggers the bank's OTP, so it looks like it worked — but the
120
+ # session it creates cannot be confirmed: #confirm_delete_va then 400s with
121
+ # "Lỗi hệ thống phương thức xác thực" (seen live 2026-07-17). Do not reintroduce
122
+ # it. Unlink is the only way to stop Tingee's per-webhook billing, so an unlink
123
+ # that silently cannot complete is expensive.
124
+ #
125
+ # Returns {confirmId} (OTP banks). Bank-shape variations — TPB answers with an
126
+ # authorizeLink, VCB returns {} because it detaches immediately — are the caller's
127
+ # to branch on; see docs/tingee-api-reference.md §6.
128
+ def delete_va(bank_bin:, va_account_number:)
129
+ request(:post, "/v1/delete-va", query: { bankBin: bank_bin, vaAccountNumber: va_account_number })
130
+ end
131
+
132
+ # POST /v1/confirm-delete-va — finishes the unlink with the bank's OTP. Params
133
+ # ride the BODY this time (unlike delete_va's query string above), and the bank
134
+ # is identified by bankBin here — bankName gets ignored and Tingee then fails
135
+ # with "Lỗi hệ thống phương thức xác thực" (seen live 2026-07-17).
136
+ def confirm_delete_va(bank_bin:, confirm_id:, otp_number:)
137
+ post("/v1/confirm-delete-va", { bankBin: bank_bin, confirmId: confirm_id, otpNumber: otp_number })
138
+ end
139
+
140
+ # POST /v1/transaction/get-paging — transaction history. `start_time`/`end_time`
141
+ # are required, format "yyyyMMddHHmmss" (UTC+7); Tingee caps each query at a
142
+ # 10-day window (over that it returns an error — not enforced here). Optional
143
+ # params are only sent when given. Returns {totalCount, items}.
144
+ def get_transactions(start_time:, end_time:, filter: nil, skip_count: nil, max_result_count: nil,
145
+ merchant_id: nil, shop_ids: nil, va_account_numbers: nil, bank_bin: nil)
146
+ payload = { startTime: start_time, endTime: end_time }
147
+ payload[:filter] = filter if filter
148
+ payload[:skipCount] = skip_count if skip_count
149
+ payload[:maxResultCount] = max_result_count if max_result_count
150
+ payload[:merchantId] = merchant_id if merchant_id
151
+ payload[:shopIds] = Array(shop_ids) if shop_ids
152
+ payload[:vaAccountNumbers] = Array(va_account_numbers) if va_account_numbers
153
+ payload[:bankBin] = bank_bin if bank_bin
154
+ post("/v1/transaction/get-paging", payload)
155
+ end
156
+
157
+ private
158
+
159
+ def get(path) = request(:get, path)
160
+ def post(path, body) = request(:post, path, body)
161
+
162
+ def request(method, path, payload = nil, query: nil)
163
+ signed_body = JSON.generate(payload || {}) # bodyless request signs "{}", not ""
164
+ ts = Signature.timestamp
165
+ sig = Signature.generate(secret: @config.secret_token, timestamp: ts, body: signed_body)
166
+ uri = URI.join(@config.base_url, path)
167
+ uri.query = URI.encode_www_form(query) if query # delete-va reads query params, not the body
168
+
169
+ req = (method == :get ? Net::HTTP::Get : Net::HTTP::Post).new(uri)
170
+ req["Content-Type"] = "application/json"
171
+ req["x-client-id"] = @config.client_id
172
+ req["x-request-timestamp"] = ts
173
+ req["x-signature"] = sig
174
+ req.body = signed_body unless method == :get
175
+
176
+ parse(perform(uri, req))
177
+ end
178
+
179
+ # Transport seam — tests override with canned responses (see client_test.rb).
180
+ def perform(uri, req)
181
+ http = Net::HTTP.new(uri.host, uri.port)
182
+ http.use_ssl = uri.scheme == "https"
183
+ http.open_timeout = 10
184
+ http.read_timeout = @read_timeout
185
+ http.request(req)
186
+ rescue Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
187
+ # Normalize transport failures to Tingee::Error so callers handle one error type —
188
+ # a bare timeout mid-link would otherwise crash the caller and strand the flow.
189
+ raise Error.new("NETWORK", e.message)
190
+ end
191
+
192
+ def parse(res)
193
+ body = res.body.to_s.empty? ? nil : JSON.parse(res.body)
194
+ return body if body.is_a?(Array) # get-banks: a bare array IS the success payload
195
+
196
+ raise Error.new("HTTP_#{res.code}", res.body.to_s) unless body.is_a?(Hash)
197
+ raise Error.new(body["code"], body["message"]) unless body["code"] == "00"
198
+
199
+ body["data"]
200
+ rescue JSON::ParserError
201
+ # A gateway/WAF error page (non-JSON) — surface as Tingee::Error, not a raw parse crash.
202
+ raise Error.new("HTTP_#{res.code}", res.body.to_s)
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,21 @@
1
+ module Tingee
2
+ # Injected credentials — set via Tingee.configure (e.g. from a Rails initializer);
3
+ # this layer never reads any credential store itself.
4
+ class Configuration
5
+ # shop_id: every VA this app creates is grouped under one Tingee shop
6
+ # (one shop per project). Optional: nil sends no shopId and Tingee
7
+ # auto-creates a throwaway shop per link.
8
+ attr_accessor :client_id, :secret_token, :base_url, :shop_id
9
+
10
+ def initialize
11
+ @base_url = "https://open-api.tingee.vn"
12
+ end
13
+
14
+ # Called when a client is built, not at load — a credential-less env still boots.
15
+ def validate!
16
+ return if client_id && secret_token
17
+
18
+ raise Error.new("CONFIG", "Missing Tingee credentials (client_id, secret_token) — set them via Tingee.configure")
19
+ end
20
+ end
21
+ end