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 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
+ [![Gem Version](https://img.shields.io/gem/v/tingee_ruby_sdk)](https://rubygems.org/gems/tingee_ruby_sdk)
4
+ [![License](https://img.shields.io/badge/license-MIT-blue)](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. |