跳轉到

任務與轉帳安全

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 preferreddevice_id legacy
bank_code 多 bank 必填;僅一 bank 可省略(server 取唯一)
callback_url 可選;origin 須與 tenant 預設相同。未帶用預設
狀態機 queuedrunningsucceeded | failed
priority 資訊用:transfer(9) > transaction_pull\|transfer_verify(5) > balance(1)不承諾開始時間
PIN 不進 request;worker 自 sealed store 取。未設定 → 終態 pin_not_configured

檢查順序(POST /v1/tasks

  1. ownership(跨 tenant → 404)
  2. idempotency_key 格式(transfer 必填)
  3. payload 正規化(422 不佔用 key、不建 row)
  4. 既有 key → 200 replayed / 409 conflict(優先於 423)
  5. 裝置鎖定 → 423
  6. 入列 → 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_pulltrigger=balance_changed)—— PAYMENT 會收到非自己下單的 callback。

transaction_pull

payload 欄位 必填 說明
account_number 可含 -,正規化為純數字
mode daily(預設)/recoveryfull
limit daily 預設 20、recovery 預設 50;clamp [1,50]
trigger 可選字串(如 recoverybalance_changed
KTB 增量欄位 monthcursorrequire_descriptionsstop_when_seenknown_fingerprint_refsKTB

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[] 明細列(新→舊);含 amountdirectionbooked_atfingerprintcounterparty 等 flow 正規化欄位
fetched_count 本趟實際筆數(== len(transactions)
limit 入列後的 clamp 上限
list_partial 列表未完整捲完/中斷。若 list_partialfingerprint_collision 為 true:terminal state=failed;outer callback errorresult.error 皆為 tx_store_incompleteresult 保留原 list_partialfingerprint_collision 並加 tx_store_incomplete=true(不入庫)
expand_partial 描述展開不完整(alone 仍可 succeeded/入庫)
fingerprint_collision 同 header 多列共用穩定 fingerprint;不可當唯一身份(與 list_partial 同為 terminal incomplete → tx_store_incomplete
scroll_mode 實際 mode(dailyrecoveryfull
scroll_stop 停止原因:day_floorno_advancelimitmax_scrollsemptywda_error
account.number 正規化後出款帳號
new_transactionsnew_countcursor 成功時 worker 呼叫 tx_store.apply 合併的 enrichment(new_transactions=首次見 fingerprint 列;cursor{account_number, seen_count, updated_at}
tx_store_cursor KTB paged path(monthcursorrequire_descriptionsstop_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 預設 truefalse 則 slip 後不立刻拉明細
verify_limit 轉帳後明細筆數,預設 15,floor [5,50]

amount validation(server/normalize.py::parse_transfer_amount

入列時正規化;失敗 → 422 amount invalid佔用 idempotency_key、不建 row)。

規則 說明
正數 必須 > 0;拒絕 0、負號、會計括號負值
上限 <= 1000000.00TRANSFER_AMOUNT_MAX
Canonical 回傳兩位小數字串(如 50"50.00"50.170"50.17"
接受例 50.17+1,000.0050
拒絕例 空、NaNInf、非數字、超過上限

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_idamount/…)
to_bankto_accountamountaccount_number money-path 回顯(canonical amount)
verify_limitsnapshot_limit post-slip 明細視窗(供 transfer_verify 原樣帶入)
snapshot_degraded 轉帳前 snapshot 不可靠

Confirm 失敗例:review_fields_missingreview_mismatch(可評估新 key)。 已出款但 slip 異常例:slip_account_unreadable / slip_account_mismatchmoney_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 實例:skippedverify_pull_failedtransfer_not_in_ledgerwda_error

transfer_verify

無金流、可重試。建議把 transfer deferred result 的欄位原樣帶入。

payload 必填 說明
to_bank / to_account / account_number / amount 同 transfer(amount 驗證同上)
exclude_fingerprintspre_fingerprints ✔ 其一 必須 list(可 []);缺/null → 422
snapshot_degraded bool,來自原 transfer
snapshot_limitverify_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_partialexpand_partial 為 true 時不可當 full verify 成功
fetched_count 明細視窗實際筆數
失敗例 transfer_not_in_ledgerneeds_manual_verificationambiguous_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"
curl -sS "$FARM_BASE/v1/tasks/{task_id}" \
  -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 尚不存在)在裝置已鎖時:

{"detail":{"error":"device_locked","reason":"pin_locked","locked_at":1754712345.6}}

已在佇列中的任務不會被中斷;worker 可能以 error=device_locked 終結並 callback。 冪等 replay 優先於 423 —— 同 key 重送仍可得原單 money_moved

POST /v1/transaction-pull/recovery

POST /v1/tasks + type=transaction_pull + payload.mode=recoverytrigger=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