任務與轉帳安全¶
Transfer 重試閘門(必讀)
| 條件 | PAYMENT 動作 |
|---|---|
money_moved=true |
禁止重送 transfer |
needs_reconciliation=true |
禁止重送;人工對帳 |
error=interrupted_needs_reconciliation |
禁止重送;人工對帳(與上列等同) |
verification.deferred=true |
不重送出款;只送 transfer_verify |
| Confirm 前明確失敗(且無上列旗標) | 才可評估新 idempotency_key 重試 |
Submit 超時:必須用同一把 idempotency_key 重送,或
GET /v1/tasks?idempotency_key=。禁止對 transfer 直接換新 key。
flowchart TD
A[收到 transfer 終態] --> B{money_moved == true?}
B -->|是| Z[禁止重送出款]
B -->|否| C{needs_reconciliation 或 interrupted_*?}
C -->|是| Z2[人工對帳 禁止自動重送]
C -->|否| D{verification.deferred?}
D -->|是| V[只送 transfer_verify]
D -->|否| E{Confirm 前明確失敗?}
E -->|是| N[可評估新 key]
E -->|否| Z2
通用 lifecycle¶
sequenceDiagram
participant P as PAYMENT
participant F as Farm
participant W as Worker
P->>F: POST /v1/tasks
alt 新 key
F-->>P: 202 queued
else 同 key 同指紋
F-->>P: 200 replayed + task
else 同 key 不同指紋
F-->>P: 409 conflict + task
else 裝置已鎖(新任務)
F-->>P: 423 device_locked
end
F->>W: queue
W->>W: queued → running → succeeded|failed
W-->>P: task terminal callback(best-effort)
P->>F: GET /v1/tasks/{id} 或 ?idempotency_key=
共通規則¶
| 規則 | 說明 |
|---|---|
| 裝置鍵 | device_code preferred;device_id legacy |
bank_code |
多 bank 必填;僅一 bank 可省略(server 取唯一) |
callback_url |
可選;origin 須與 tenant 預設相同。未帶用預設 |
| 狀態機 | queued → running → succeeded | failed |
priority |
資訊用:transfer(9) > transaction_pull\|transfer_verify(5) > balance(1);不承諾開始時間 |
| PIN | 不進 request;worker 自 sealed store 取。未設定 → 終態 pin_not_configured |
檢查順序(POST /v1/tasks)¶
- ownership(跨 tenant → 404)
idempotency_key格式(transfer 必填)- payload 正規化(422 不佔用 key、不建 row)
- 既有 key → 200 replayed / 409 conflict(優先於 423)
- 裝置鎖定 → 423
- 入列 → 202
四種 task¶
balance¶
Request:
{
"device_code": "TH-XXXX",
"bank_code": "SCB",
"type": "balance",
"callback_url": "https://payment.example/callback"
}
Terminal result(成功 placeholder;欄位來自 balance flow):
{
"status": "ok",
"deposits_total": "0.00",
"accounts": [
{
"nickname": "Savings",
"number": "0000000000",
"balance": "0.00"
}
],
"accounts_partial": false
}
| 欄位 | 說明 |
|---|---|
status |
成功為 "ok";失敗為 "failed"(另見 error) |
deposits_total |
畫面總餘額字串;可能為 null(讀不到) |
accounts[] |
帳戶列;number 為純數字(flow 已剝 -) |
accounts[].nickname / balance |
UI 可見值;缺則 null |
accounts_partial |
true 表示至少一列跨摺線/不完整;observe 不進 baseline |
餘額異動時 Farm 可能另送 balance.changed 並自動入列 transaction_pull
(trigger=balance_changed)—— PAYMENT 會收到非自己下單的 callback。
transaction_pull¶
| payload 欄位 | 必填 | 說明 |
|---|---|---|
account_number |
✔ | 可含 -,正規化為純數字 |
mode |
daily(預設)/recovery/full |
|
limit |
daily 預設 20、recovery 預設 50;clamp [1,50] |
|
trigger |
可選字串(如 recovery、balance_changed) |
|
| KTB 增量欄位 | month/cursor/require_descriptions/stop_when_seen/known_fingerprint_refs 僅 KTB |
Request:
{
"device_code": "TH-XXXX",
"bank_code": "SCB",
"type": "transaction_pull",
"payload": {
"account_number": "000-000000-0",
"mode": "daily",
"limit": 20
}
}
| mode | 用途 |
|---|---|
daily |
日常/自動拉;日切 early-stop |
recovery |
漏抓補強;硬捲上限、無日切 |
full |
內部/transfer 路徑用捲動策略 |
Terminal result(成功 placeholder;transactions / partial / scroll metadata):
{
"status": "ok",
"bank_code": "SCB",
"account": {
"number": "0000000000"
},
"limit": 20,
"fetched_count": 1,
"transactions": [
{
"index": 0,
"amount": "50.17",
"direction": "withdraw",
"booked_at": "2026-08-12T10:00:00+07:00",
"fingerprint": "…",
"counterparty": {
"bank": "KTB",
"account": "0000000000"
}
}
],
"list_partial": false,
"expand_partial": false,
"fingerprint_collision": false,
"scroll_mode": "daily",
"scroll_stop": "day_floor",
"new_transactions": [
{
"index": 0,
"amount": "50.17",
"direction": "withdraw",
"booked_at": "2026-08-12T10:00:00+07:00",
"fingerprint": "…",
"counterparty": {
"bank": "KTB",
"account": "0000000000"
}
}
],
"new_count": 1,
"cursor": {
"account_number": "0000000000",
"seen_count": 1,
"updated_at": 0
}
}
| 欄位 | 說明 |
|---|---|
transactions[] |
明細列(新→舊);含 amount/direction/booked_at/fingerprint/counterparty 等 flow 正規化欄位 |
fetched_count |
本趟實際筆數(== len(transactions)) |
limit |
入列後的 clamp 上限 |
list_partial |
列表未完整捲完/中斷。若 list_partial 或 fingerprint_collision 為 true:terminal state=failed;outer callback error 與 result.error 皆為 tx_store_incomplete;result 保留原 list_partial/fingerprint_collision 並加 tx_store_incomplete=true(不入庫) |
expand_partial |
描述展開不完整(alone 仍可 succeeded/入庫) |
fingerprint_collision |
同 header 多列共用穩定 fingerprint;不可當唯一身份(與 list_partial 同為 terminal incomplete → tx_store_incomplete) |
scroll_mode |
實際 mode(daily/recovery/full) |
scroll_stop |
停止原因:day_floor/no_advance/limit/max_scrolls/empty/wda_error 等 |
account.number |
正規化後出款帳號 |
new_transactions/new_count/cursor |
成功時 worker 呼叫 tx_store.apply 合併的 enrichment(new_transactions=首次見 fingerprint 列;cursor={account_number, seen_count, updated_at}) |
tx_store_cursor |
KTB paged path(month/cursor/require_descriptions/stop_when_seen)為避免覆寫 flow 的 cursor,worker 改放 enrich cursor 於此鍵 |
transfer¶
Request:
{
"device_code": "TH-XXXX",
"bank_code": "SCB",
"type": "transfer",
"idempotency_key": "ORD-PLACEHOLDER-001",
"payload": {
"to_bank": "KTB",
"to_account": "0000000000",
"account_number": "0000000000",
"amount": "50.17",
"verify": true,
"verify_limit": 15
}
}
| payload | 必填 | 說明 |
|---|---|---|
to_bank |
✔ | 收款銀行代碼 |
to_account |
✔ | 收款帳號 |
account_number |
✔ | 出款帳號 |
amount |
✔ | 金額字串;見下方 amount validation(正規化後進指紋) |
verify |
預設 true;false 則 slip 後不立刻拉明細 |
|
verify_limit |
轉帳後明細筆數,預設 15,floor [5,50] |
amount validation(server/normalize.py::parse_transfer_amount)¶
入列時正規化;失敗 → 422 amount invalid(不佔用 idempotency_key、不建 row)。
| 規則 | 說明 |
|---|---|
| 正數 | 必須 > 0;拒絕 0、負號、會計括號負值 |
| 上限 | <= 1000000.00(TRANSFER_AMOUNT_MAX) |
| Canonical | 回傳兩位小數字串(如 50 → "50.00"、50.170 → "50.17") |
| 接受例 | 50.17、+1,000.00、50 |
| 拒絕例 | 空、NaN/Inf、非數字、超過上限 |
Money-path / ledger result 旗標:
| 欄位 | 語意 |
|---|---|
money_moved=true |
已見 success slip — 禁止自動重試出款 |
money_moved=null + needs_reconciliation=true |
Confirm 後 slip 不明或 running 重投 — 人工對帳 |
error=interrupted_needs_reconciliation |
worker 對 transfer+running 重投:不重跑,直接 failed |
verification.deferred=true |
slip ok 但明細未證實 → 另送 transfer_verify |
ledger_verified |
明細是否 strong match |
not_before / pre_fingerprints |
deferred 時供 follow-up |
slip |
成功回單欄位(ref_id/amount/…) |
to_bank/to_account/amount/account_number |
money-path 回顯(canonical amount) |
verify_limit/snapshot_limit |
post-slip 明細視窗(供 transfer_verify 原樣帶入) |
snapshot_degraded |
轉帳前 snapshot 不可靠 |
Confirm 前失敗例:review_fields_missing、review_mismatch(可評估新 key)。
已出款但 slip 異常例:slip_account_unreadable / slip_account_mismatch 且 money_moved=true。
Terminal result — slip ok + ledger 已證實(placeholder):
{
"status": "ok",
"money_moved": true,
"ledger_verified": true,
"to_bank": "KTB",
"to_account": "0000000000",
"amount": "50.17",
"currency": "THB",
"account_number": "0000000000",
"slip": {
"ref_id": "…",
"amount": "50.17"
},
"snapshot_degraded": false,
"not_before": "2026-08-12T03:00:00+00:00",
"pre_fingerprints": [],
"verify_limit": 15,
"snapshot_limit": 15,
"matched_transaction": {
"fingerprint": "…",
"amount": "50.17",
"direction": "withdraw"
},
"verification": {
"matched": true,
"deferred": false,
"fingerprint": "…",
"excluded_pre_count": 0,
"not_before": "2026-08-12T03:00:00+00:00"
}
}
Terminal result — slip ok 但 ledger deferred(另送 transfer_verify):
{
"status": "ok",
"money_moved": true,
"ledger_verified": false,
"to_bank": "KTB",
"to_account": "0000000000",
"amount": "50.17",
"currency": "THB",
"account_number": "0000000000",
"slip": {
"ref_id": "…",
"amount": "50.17"
},
"snapshot_degraded": false,
"not_before": "2026-08-12T03:00:00+00:00",
"pre_fingerprints": [],
"verify_limit": 15,
"snapshot_limit": 15,
"verification": {
"matched": false,
"deferred": true,
"reason": "transfer_not_in_ledger",
"excluded_pre_count": 0,
"not_before": "2026-08-12T03:00:00+00:00"
}
}
verification.reason 實例:skipped/verify_pull_failed/transfer_not_in_ledger/wda_error。
transfer_verify¶
無金流、可重試。建議把 transfer deferred result 的欄位原樣帶入。
| payload | 必填 | 說明 |
|---|---|---|
to_bank / to_account / account_number / amount |
✔ | 同 transfer(amount 驗證同上) |
exclude_fingerprints 或 pre_fingerprints |
✔ 其一 | 必須 list(可 []);缺/null → 422 |
snapshot_degraded |
✔ | bool,來自原 transfer |
snapshot_limit 或 verify_limit |
✔ 其一 | int [5,50];follow-up 視窗不可縮小 |
not_before |
✔ | 可解析 ISO-8601 |
limit |
若帶入,仍強制使用完整 snapshot_limit 視窗 |
Request:
{
"device_code": "TH-XXXX",
"bank_code": "SCB",
"type": "transfer_verify",
"payload": {
"to_bank": "KTB",
"to_account": "0000000000",
"account_number": "0000000000",
"amount": "50.17",
"not_before": "2026-08-12T03:00:00+00:00",
"pre_fingerprints": [],
"snapshot_degraded": false,
"snapshot_limit": 15
}
}
Terminal result — ledger 已證實(placeholder):
{
"status": "ok",
"ledger_verified": true,
"to_bank": "KTB",
"to_account": "0000000000",
"amount": "50.17",
"currency": "THB",
"account_number": "0000000000",
"snapshot_degraded": false,
"list_partial": false,
"expand_partial": false,
"not_before": "2026-08-12T03:00:00+00:00",
"matched_transaction": {
"fingerprint": "…",
"amount": "50.17",
"direction": "withdraw"
},
"fetched_count": 15,
"verification": {
"matched": true,
"deferred": false,
"fingerprint": "…",
"excluded_pre_count": 0
}
}
| 欄位 | 說明 |
|---|---|
ledger_verified |
strong match 且非 degraded/partial 才為 true |
matched_transaction |
命中明細列(有 match 時) |
list_partial/expand_partial |
為 true 時不可當 full verify 成功 |
fetched_count |
明細視窗實際筆數 |
| 失敗例 | transfer_not_in_ledger、needs_manual_verification、ambiguous_match(皆 ledger_verified=false) |
此 type 無 money_moved(不經 Confirm/出款 UI)。
冪等與 submit timeout¶
| 情況 | HTTP | Body |
|---|---|---|
| 新 key(或不帶 key 的非 transfer) | 202 | {task_id, device_id, device_code, bank_code, type, status:"queued", priority} |
| 同 key、同指紋 | 200 | {"replayed": true, "task": {…GET body…}} |
| 同 key、不同指紋 | 409 | {"error":"idempotency_key_conflict","conflict_fields":[…],"task":{…}} |
- 指紋 = server 正規化後的
(type, device_id, bank_code, payload)。callback_url不進指紋。 - 原單一律在
task下不攤平(避免蓋掉error裡的 money-path 旗標)。 idempotency_key永不釋放(含 failed)。規則:1–128字元[A-Za-z0-9_.:-]。transfer必填 key;其他 type 選填。- publish 失敗留下
failed/enqueue_failed的原單仍燒掉該 key;該終態證明錢沒動,可用新 key。
反查¶
curl -sS "$FARM_BASE/v1/tasks?idempotency_key=ORD-PLACEHOLDER-001" \
-H "Authorization: Bearer $FARM_API_KEY"
GET body 骨架(result 依 type 見上方四種 terminal 示例,勿假設永遠 {}):
{
"task_id": "…",
"device_id": "TH-XXXX",
"type": "transfer",
"state": "succeeded",
"result": {
"status": "ok",
"money_moved": true,
"ledger_verified": true
},
"error": null,
"created_at": 0,
"updated_at": 0
}
423 device_locked¶
新任務(無 key 或 key 尚不存在)在裝置已鎖時:
已在佇列中的任務不會被中斷;worker 可能以 error=device_locked 終結並 callback。
冪等 replay 優先於 423 —— 同 key 重送仍可得原單 money_moved。
POST /v1/transaction-pull/recovery¶
與 POST /v1/tasks + type=transaction_pull + payload.mode=recovery(trigger=recovery)等價。
{
"device_code": "TH-XXXX",
"account_number": "0000000000",
"limit": 50,
"bank_code": "SCB",
"idempotency_key": "optional-key"
}
回 202 body 與一般入列相同。不在此重複整套 result schema。
PAYMENT 必須保存¶
| 欄位 | 用途 |
|---|---|
task_id |
GET 權威狀態、callback 去重 |
idempotency_key(client) |
submit timeout 重送/反查 |
| transfer result 的 money-path 旗標 | 重試閘門 |
deferred 的 not_before / fingerprints / snapshot_* |
transfer_verify |