Skip to content

本章是透過 Payment Gateway 商戶 API 撥付資金的權威參考。內容涵蓋 POST /withdrawal 端點、基於規則的審核引擎、渠道與帳戶兩種路由分支、狀態機,以及在正式環境會出現的陷阱。

概念與術語

術語意義
提款訂單一筆 WithdrawalOrder 列,代表從你的商戶餘額發出的一次出款意圖。以 order_no(由閘道指派)與 merchant_order_no(由你指派)識別。
收款人出款的最終收受方。由 beneficiary_namebeneficiary_account,以及可選的 beneficiary_bank_code / beneficiary_bank_branch 定義。帳號在儲存時以 AES 加密;API 接收的是明文。
商戶餘額支撐每一筆出款的帳本項目。提款在建立時凍結 amount + fee,在 COMPLETED 時確認凍結,或在 REJECTED / FAILED 時把資金釋放回可用餘額。
凍結會計平台把商戶餘額依幣別拆成 availablefrozen 兩個桶子。freeze_balanceamount + feeavailable 移到 frozenconfirm_freezefrozen 扣除(資金已支出)。release_freeze 移回 available(資金返還)。三者都有版本號,因此並發更新會以 OPTIMISTIC_LOCK_ERROR 浮現。
出款方式BANK_APIMANUALTHIRD_PARTY 三者之一。由閘道根據出款實際離開系統的方式設定。商戶無法選擇。
審核介於 PENDINGAPPROVED 之間、基於規則的閘門。部分訂單會自動審核,部分需要操作員處理。參見 審核規則
路由決定出款要送到哪裡。可能的目標有兩種:第三方出款渠道(THIRD_PARTY),或由平台管理的銀行帳戶(MANUALBANK_API)。
轉帳任務推動帳戶路由出款完成的內部工作。當路由命中的帳戶處於 AUTO 轉帳模式時會自動建立。商戶不會直接看到。
樂觀鎖定平台在更新商戶餘額與訂單狀態時所用的並發控制策略。並發更新可能以 OPTIMISTIC_LOCK_ERROR 浮現。參見 陷阱
回調訂單到達終態時,發往 callback_url 的已簽章 HTTP POST。簽章方案與對外請求相同。參見 Callbacks

查詢餘額

送出提款前,通常會想先知道自己有多少錢可以提。商戶 API 提供單一端點,回傳你帳號開通的所有幣別餘額。

MethodPath用途
GET/api/v1/merchant/balance列出目前認證商戶的所有餘額。

該端點需要標準 MD5 簽章標頭。沒有 body、也沒有 query 參數,所以簽章只對 timestamp 計算。

請求

bash
API_KEY="pre_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
BASE_URL="https://pre-api.open-pay.co"
TIMESTAMP=$(date +%s)

PARAMS="timestamp=${TIMESTAMP}"
SIGNATURE=$(python sign.py --api-key "$API_KEY" --params "$PARAMS")

curl -X GET "${BASE_URL}/api/v1/merchant/balance" \
  -H "X-API-Key: ${API_KEY}" \
  -H "X-Timestamp: ${TIMESTAMP}" \
  -H "X-Signature: ${SIGNATURE}"

回應

成功時回 HTTP 200 OK,每個幣別一筆條目:

json
{
  "merchant_id": "01J...",
  "items": [
    {
      "id": "01J...",
      "merchant_id": "01J...",
      "currency_code": "THB",
      "available_balance": "12500.00",
      "frozen_balance": "300.00",
      "total_balance": "12800.00",
      "version": 47,
      "created_at": "2026-04-01T08:00:00Z",
      "updated_at": "2026-05-20T08:00:00Z"
    },
    {
      "id": "01J...",
      "merchant_id": "01J...",
      "currency_code": "VND",
      "available_balance": "5000000.00",
      "frozen_balance": "0.00",
      "total_balance": "5000000.00",
      "version": 12,
      "created_at": "2026-04-01T08:00:00Z",
      "updated_at": "2026-05-18T08:00:00Z"
    }
  ]
}

欄位

欄位型別含義
currency_codestringISO 4217。你帳號開通的每個幣別一筆。
available_balancedecimal string可動用、可拿來建立新提款的餘額。
frozen_balancedecimal string已被進行中的提款(PENDING / APPROVED / PROCESSING)凍結的餘額。若提款失敗、被拒絕或取消,會回到 available_balance
total_balancedecimal stringavailable_balance + frozen_balance。為方便閱讀提供。
versioninteger餘額列的樂觀鎖版本。每次餘額變動都會 +1。可用來在你自己的對帳工作中偵測並發異動。
created_at / updated_attimestamp餘額列首次建立、最後變動的時間。

常見用途

  • 提款前的預檢。 呼叫此端點、確認目標幣別 available_balance >= 金額 + 手續費 後再建立提款。略過檢查是安全的——閘道會在資金不足時用 INSUFFICIENT_BALANCE 拒絕——但在你自己的 UI 上先給使用者回饋體驗較好。
  • 儀表板渲染。 週期性輪詢以顯示各幣別當前餘額。
  • 對帳。 搭配充值 / 提款的 callback 處理器,偵測你方帳本與我方之間的差異;version 遞增是「有東西動過」的便宜判斷依據。

API 介面

MethodPath用途
POST/api/v1/merchant/orders/withdrawal建立一筆提款訂單。
GET/api/v1/merchant/query/withdrawal/{order_no}以閘道的 order_no 查詢提款訂單。
GET/api/v1/merchant/query/withdrawal?merchant_order_no=...以你的 merchant_order_no 查詢提款訂單。

所有端點都需要標準的 MD5 簽章標頭(對排序後參數串接 api_key 後計算 MD5,輸出小寫 hex)。參見 認證與簽章

WithdrawalOrderCreate(請求主體)

欄位型別必填說明
merchant_order_nostring (1-64)你對這筆出款的唯一參照。每個商戶內必須唯一。
amountdecimal必須大於 0。會做幣別精度驗證。
currency_codestring (3)ISO 4217 代碼。必須是你的商戶帳號已啟用的幣別。
beneficiary_namestring (1-100)收款人法定姓名,需與銀行帳戶上印的一致。加密儲存。
beneficiary_accountstring (1-100)收款人銀行帳號。加密儲存。格式因國家與渠道而異;參見 陷阱
beneficiary_bank_codestring (≤20)銀行識別碼。多數渠道需要;閘道會透過 provider bank mapping 表將其解析為渠道專用的銀行代碼。
beneficiary_bank_branchstring (≤200)分行代碼或名稱。部分渠道需要(特別是日本與印尼的通道)。
callback_urlstring (1-500)終態狀態變更時我們會呼叫的 HTTPS URL。
extra_datastring不透明字串,會在回調中原樣回傳。

schema 定義在 app/schemas/order.pyWithdrawalOrderCreate。模型是 app/models/order.pyWithdrawalOrder

WithdrawalOrderResponse(HTTP 201 與查詢回應)

欄位說明
id內部 UUID。查詢請用 order_no
order_no閘道指派的識別碼。格式:W + 14 位時間戳 + 8 位十六進位字元。
merchant_order_no原樣回傳請求中的值。
merchant_id, account_id商戶與被選定執行出款的銀行帳戶。即使是渠道路由訂單,account_id 也會被設定;它指向綁定渠道的記帳帳戶。
amount你請求的金額。
feeMerchantFeeConfig 計算。為外加費用 — 你的餘額會被扣除 amount + fee
actual_amount實際送到收款人手中的金額。等於 amount,因為手續費是外加,不從 amount 扣除。
currency_code原樣回傳請求中的值。
payout_methodTHIRD_PARTYMANUALBANK_API。由閘道根據路由決策設定。
statusWithdrawalOrderStatus。參見 狀態轉移
is_auto_approved若審核引擎放行訂單,或訂單被路由到渠道(渠道路由訂單會跳過人工審核),則為 true
approved_at, approved_by, approval_remarks審核稽核軌跡。自動審核訂單的 approved_by 為空。
processed_at訂單進入 PROCESSING 時設定。
completed_at訂單進入 COMPLETED 時設定。
bank_reference銀行或渠道回報的外部交易參照。COMPLETED 時填入。
callback_status, callback_count回調投遞狀態。參見 Callbacks
beneficiary_name, beneficiary_account, beneficiary_bank_code, beneficiary_bank_name, beneficiary_bank_branch讀取時解密。beneficiary_bank_namebeneficiary_bank_code 解析而來。
needs_manual_intervention, manual_reason自動出款流程卡住、需要操作員接手時設定。
channel_id, channel_name, channel_order_no渠道路由訂單會填入。
estimated_channel_cost, estimated_profit, actual_channel_cost, actual_profit利潤追蹤欄位。對多數整合方無關。

完整 schema 定義在 API reference

流程

失敗分支:

  • 審核拒絕。 操作員在後台 UI 拒絕。後端呼叫 reject_order:凍結餘額與預留額度釋放回商戶;狀態移至 PENDING → REJECTED。觸發此動作的端點僅限管理員;商戶只能透過查詢或回調觀察到變化(回調是否觸發取決於設定)。
  • 渠道回報失敗。 渠道回調帶失敗狀態抵達。後端呼叫 fail_order:凍結餘額與預留額度釋放,狀態移至 PROCESSING → FAILED。會以 status=failed 觸發已簽章的商戶回調。
  • 轉帳任務取消。 若平台取消底層的轉帳任務(罕見;操作員動作),提款訂單移至 PROCESSING → CANCELLED。預留餘額目前會保持凍結,等待操作員對帳(這是平台已知的 follow-up 項目)。
  • 送單時的渠道建立錯誤。 若渠道 adapter 在 POST /withdrawal 期間拋出 ChannelAPIError,訂單列會被刪除,凍結釋放,端點以 HTTP 400 BUSINESS_ERROR 回應。沒有訂單被建立,也不會觸發回調。

閘道如何在渠道與帳戶間做選擇

路由引擎(ChannelRoutingService.find_target_for_order)按以下順序查詢 merchant_channel_rule 列:

  1. 此商戶 + 幣別 + 金額區間的明確規則。 若匹配規則指向 PaymentChannel,訂單走渠道路由。若指向 Account,則使用該特定帳戶(並檢查額度)。
  2. 無規則,或規則未匹配。 退回到該幣別第一個額度足夠的可用出款帳戶,由 AccountService.find_available_withdrawal_account 挑選。
  3. 連帳戶都沒有。 建立呼叫以 BusinessError 失敗,訊息為 No available payout account for <CCY> with sufficient quota。在此情況下商戶餘額永遠不會被凍結。

商戶無法從商戶 API 指定渠道或帳戶。若你的特定使用案例需要可預測的路由,請聯絡平台運營為你的商戶安裝 merchant_channel_rule

審核規則:PENDING 何時變成 APPROVED

審核行為依路由目標而定。平台是基於規則的,並非只有人工或只有自動。

渠道路由訂單

若路由引擎為訂單選擇 PaymentChannel,平台會同步將 is_auto_approved=trueapproved_at=now 設定好,然後送單給渠道 adapter。訂單完全跳過操作員佇列,並從 PROCESSING 開始(或短暫停在 PENDING,直到 adapter 呼叫成功返回)。渠道路由提款永遠不會出現在人工審核佇列中。

帳戶路由訂單

若路由引擎選擇 Account(或無規則匹配、退回方案以額度挑選帳戶),平台會查詢 ApprovalConfig。第一個匹配者勝出:

  1. 商戶層設定ApprovalConfig.merchant_id = <你的商戶 id>is_active = true)。
  2. 全域設定ApprovalConfig.merchant_id IS NULLis_active = true)。

若沒有任何有效設定,訂單需要人工審核(status = PENDING)。

若存在有效設定,自動審核引擎依序檢查:

  1. auto_approve_enabled 旗標。 若為 false,無論其他規則為何皆需人工審核。
  2. auto_approve_max_amount 閾值。 若訂單金額超過此閾值,需人工審核。
  3. ApprovalWhitelist 查找。beneficiary_account 的 SHA-256 雜湊匹配到一條 beneficiary_account 型別的有效白名單項目,訂單會立即自動審核,跳過下方的每日限額檢查。
  4. 每日筆數限制。 若今日此商戶的自動審核訂單筆數已達到 auto_approve_daily_count,需人工審核。
  5. 每日金額限制。 若今日已自動審核的訂單金額加上本筆訂單的 amount 超過 auto_approve_daily_limit,需人工審核。

若全部檢查通過,訂單會被自動審核(is_auto_approved=truestatus=APPROVEDapproved_at=now)。

自動審核後的訂單是否立即進入 PROCESSING,取決於路由命中帳戶的 transfer_mode

  • transfer_mode = AUTO:自動建立 TransferTask。訂單移至 APPROVED → PROCESSING,並設定 processed_at
  • transfer_mode = MANUAL:訂單停在 APPROVED,直到操作員處理。

實作位於 app/services/withdrawal.pyWithdrawalService._check_auto_approvalWithdrawalService.create。把規則視為平台設定;不要在商戶端寫死特定審核模式的邏輯。

範例

底下程式片段假設你已依照 Authentication 文件產生 X-TimestampX-Signature 標頭。簽章對象是 JSON 請求主體,不是 URL。

bash
#!/usr/bin/env bash
# MD5 signature for Payment Gateway merchant API (bash reference).
#
# Usage:
#   ./sign.sh --api-key <key> --params 'k1=v1&k2=v2&...'
set -euo pipefail

API_KEY=""
PARAMS=""
while [[ $# -gt 0 ]]; do
  case "$1" in
    --api-key) API_KEY="$2"; shift 2 ;;
    --params)  PARAMS="$2";  shift 2 ;;
    *) echo "Unknown flag: $1" >&2; exit 2 ;;
  esac
done

# Sort key=value pairs ASCII order (C locale), drop empty trailing field
SORTED=$(printf '%s' "$PARAMS" | tr '&' '\n' | LC_ALL=C sort | grep -v '^$' | tr '\n' '&' | sed 's/&$//')
SIGN_STRING="${SORTED}${API_KEY}"

# MD5: md5sum on Linux/CI, md5 on macOS
if command -v md5sum >/dev/null 2>&1; then
  printf '%s' "$SIGN_STRING" | md5sum | cut -d' ' -f1
else
  printf '%s' "$SIGN_STRING" | md5
fi
python
#!/usr/bin/env python3
"""Reference implementation: MD5 signature for Payment Gateway merchant API.

Usage:
  python sign.py --api-key <key> --params 'k1=v1&k2=v2&...'

Algorithm:
  1. Parse params into a dict.
  2. Sort keys alphabetically (ASCII order).
  3. Build query string 'k=v&k=v', skipping None values.
  4. Append api_key directly (no '&').
  5. MD5 the UTF-8 bytes; output lowercase hex.
"""
import argparse
import hashlib
import sys
from urllib.parse import parse_qsl


def create_signature(params: dict[str, str], api_key: str) -> str:
    sorted_items = sorted(params.items())
    query_string = "&".join(f"{k}={v}" for k, v in sorted_items if v is not None)
    return hashlib.md5((query_string + api_key).encode("utf-8")).hexdigest()


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--api-key", required=True)
    parser.add_argument("--params", required=True)
    args = parser.parse_args()
    params = dict(parse_qsl(args.params, keep_blank_values=True))
    print(create_signature(params, args.api_key))
    return 0


if __name__ == "__main__":
    sys.exit(main())
php
<?php
/**
 * MD5 signature for Payment Gateway merchant API (PHP reference).
 *
 * Usage:
 *   php sign.php --api-key=<key> --params='k1=v1&k2=v2&...'
 *
 * Note: getopt() requires --flag=value form (no space between flag and value).
 */
$opts = getopt('', ['api-key:', 'params:']);
if (!isset($opts['api-key']) || !isset($opts['params'])) {
    fwrite(STDERR, "Usage: php sign.php --api-key=<key> --params='k=v&...'\n");
    exit(2);
}

parse_str($opts['params'], $params);
ksort($params, SORT_STRING);

$parts = [];
foreach ($params as $k => $v) {
    if ($v === null) continue;
    $parts[] = "$k=$v";
}
echo md5(implode('&', $parts) . $opts['api-key']);
typescript
#!/usr/bin/env node
/**
 * MD5 signature for Payment Gateway merchant API (Node.js / TypeScript reference).
 *
 * Usage:
 *   tsx sign.ts --api-key <key> --params 'k1=v1&k2=v2&...'
 */
import { createHash } from "crypto";

function parseArgs(argv: string[]): { apiKey: string; params: string } {
  let apiKey = "", params = "";
  for (let i = 2; i < argv.length; i++) {
    if (argv[i] === "--api-key") apiKey = argv[++i];
    else if (argv[i] === "--params") params = argv[++i];
  }
  if (!apiKey || !params) {
    process.stderr.write("Usage: sign.ts --api-key <key> --params 'k=v&...'\n");
    process.exit(2);
  }
  return { apiKey, params };
}

function createSignature(paramsQuery: string, apiKey: string): string {
  const parsed = new Map<string, string>();
  for (const pair of paramsQuery.split("&")) {
    if (!pair) continue;
    const eq = pair.indexOf("=");
    const k = eq >= 0 ? pair.slice(0, eq) : pair;
    const v = eq >= 0 ? pair.slice(eq + 1) : "";
    parsed.set(k, v);
  }
  const sorted = [...parsed.entries()].sort(([a], [b]) =>
    a < b ? -1 : a > b ? 1 : 0
  );
  const query = sorted.map(([k, v]) => `${k}=${v}`).join("&");
  return createHash("md5").update(query + apiKey, "utf8").digest("hex");
}

const { apiKey, params } = parseArgs(process.argv);
process.stdout.write(createSignature(params, apiKey));

範例 1:提交一筆 THB 的泰國銀行提款

把 5,000 THB 出款到一個 Siam Commercial Bank 帳戶。

bash
curl -X POST https://pre-api.open-pay.co/api/v1/merchant/orders/withdrawal \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -d '{
    "merchant_order_no": "WD-20260520-0001",
    "amount": "5000.00",
    "currency_code": "THB",
    "beneficiary_name": "SOMSAK CHAIYAPORN",
    "beneficiary_account": "1234567890",
    "beneficiary_bank_code": "SCB",
    "callback_url": "https://merchant.example.com/api/pg/callback",
    "extra_data": "payroll-2026-05"
  }'

回應(渠道路由、自動審核,HTTP 201):

json
{
  "id": "0a3f5c2e-1c0b-4a3b-9c2d-1f4e7a8b9c0d",
  "order_no": "W202605200815120ABCDEF12",
  "merchant_order_no": "WD-20260520-0001",
  "merchant_id": "c1234567-89ab-cdef-0123-456789abcdef",
  "account_id": "d2345678-9abc-def0-1234-56789abcdef0",
  "amount": "5000.00",
  "fee": "20.00",
  "actual_amount": "5000.00",
  "currency_code": "THB",
  "payout_method": "third_party",
  "status": "processing",
  "is_auto_approved": true,
  "approved_at": "2026-05-20T08:15:12Z",
  "approved_by": null,
  "processed_at": "2026-05-20T08:15:13Z",
  "completed_at": null,
  "bank_reference": null,
  "callback_status": null,
  "callback_count": 0,
  "created_at": "2026-05-20T08:15:12Z",
  "updated_at": "2026-05-20T08:15:13Z"
}

回應(帳戶路由、需人工審核,節錄):

json
{
  "order_no": "W202605200815120ABCDEF12",
  "status": "pending",
  "is_auto_approved": false,
  "payout_method": "manual",
  "approved_at": null
}

order_no 與你的 merchant_order_no 對應保存。查詢與回調對帳會用到。在收到 COMPLETED 回調之前,不要依據 actual_amount 採取行動;該值在建單時就設定,但資金尚未移動。

範例 2:建立後查詢提款

以閘道的 order_no 查詢:

bash
curl https://pre-api.open-pay.co/api/v1/merchant/query/withdrawal/W202605200815120ABCDEF12 \
  -H "X-API-Key: $API_KEY" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG"

或以你的 merchant_order_no 查詢:

bash
curl "https://pre-api.open-pay.co/api/v1/merchant/query/withdrawal?merchant_order_no=WD-20260520-0001" \
  -H "X-API-Key: $API_KEY" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG"

兩者都回傳完整的 WithdrawalOrderResponse schema,包含 statusbank_referencecompleted_atcallback_status。用回應跟你內部紀錄對帳。GET 端點可以反覆呼叫;它們只讀取狀態。

範例 3:處理 INSUFFICIENT_MERCHANT_BALANCE

提款在建立時凍結 amount + fee。若你該幣別的商戶餘額低於這個總額,建立呼叫會失敗:

json
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "success": false,
  "code": "INSUFFICIENT_MERCHANT_BALANCE",
  "message": "Insufficient merchant balance",
  "data": null
}

標準錯誤信封與 code 定義請參見 Errors

建議的恢復方式:

  1. 不要盲目重試同一筆提款。
  2. 為你的商戶餘額儲值,或等待入帳的充值結算。
  3. 重新送單前,透過平台餘額端點查詢餘額(參見 API reference)。
  4. 若你重新送單,重用相同的 merchant_order_no。若原本第一次其實已悄悄落地,重複檢查可以保護你。

範例 4(續):回調長什麼樣子

當訂單到達 COMPLETED,閘道會 POST 一個已簽章的請求主體到你的 callback_url。回調可用的欄位包含 order_nomerchant_order_nostatusamountactual_amountfeecurrency_codebank_referencecompleted_atextra_data。確切的傳輸格式與簽章方案記載於 Callbacks

上面那筆訂單成功出款的回調(節錄):

json
{
  "order_no": "W202605200815120ABCDEF12",
  "merchant_order_no": "WD-20260520-0001",
  "status": "completed",
  "amount": "5000.00",
  "actual_amount": "5000.00",
  "fee": "20.00",
  "currency_code": "THB",
  "bank_reference": "FT26052000001234",
  "completed_at": "2026-05-20T08:42:11Z",
  "extra_data": "payroll-2026-05"
}

你的處理器應該:

  1. 驗證簽章。拒絕未簽章或簽章錯誤的請求。
  2. order_no(優先)或 merchant_order_no 查詢訂單。
  3. 若訂單在你本地已是終態,回 200 OK 並返回 — 冪等。
  4. 否則更新你的帳本:amount + fee 從你的「處理中」桶子離開;actual_amount 抵達收款人。
  5. 200 OK(包含 success 的回應主體也會被閘道的重試策略接受)。

FAILED 回調使用 status: "failed",並省略 bank_referencecompleted_at。你的處理器應把 amount + fee 釋放回你的可用桶子。完整信封與重試策略參見 Callbacks

範例 5:以重試處理 OPTIMISTIC_LOCK_ERROR

平台對商戶餘額與訂單列使用樂觀鎖定。在並發負載下,一次送單可能與並行更新發生衝突,API 會回傳:

json
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "success": false,
  "code": "OPTIMISTIC_LOCK_ERROR",
  "message": "Concurrent update conflict, please retry",
  "data": null
}

錯誤碼是 OPTIMISTIC_LOCK_ERROR。這是暫時性的。建議的客戶端模式是有上限的重試加抖動

python
import random
import time

import requests

def submit_withdrawal(payload, headers, max_attempts=3):
    last_response = None
    for attempt in range(max_attempts):
        resp = requests.post(
            "https://pre-api.open-pay.co/api/v1/merchant/orders/withdrawal",
            json=payload,
            headers=headers,
            timeout=10,
        )
        last_response = resp
        if resp.status_code == 201:
            return resp.json()
        if resp.status_code == 400 and "Concurrent update conflict" in resp.text:
            # Back off with jitter: 200ms, 500ms, 1200ms (capped)
            backoff = min(0.2 * (2 ** attempt), 1.2) + random.uniform(0, 0.2)
            time.sleep(backoff)
            continue
        # Any other 4xx/5xx: stop and surface
        resp.raise_for_status()
    raise RuntimeError(
        f"Withdrawal submission failed after {max_attempts} attempts: "
        f"{last_response.status_code} {last_response.text}"
    )

把重試預算設在 3 次。不要無限重試 — 持續的鎖衝突通常代表後端有問題,而不是暫時的競態。重試用完後,以 merchant_order_no 查詢訂單:它有可能在第一次就悄悄建單成功。參見 OPTIMISTIC_LOCK_ERROR

樂觀鎖定實際在哪裡觸發

提款上 OPTIMISTIC_LOCK_ERROR 最常見的來源是商戶餘額更新:freeze_balance 讀取餘額列、修改它、寫回時做版本檢查。同一筆商戶餘額列上若有並行的提款或並行的充值結算,都會讓檢查失敗。較少見的情況是渠道 adapter(特別是 TGCPay)在上游使用樂觀鎖,閘道把錯誤往下傳。重試模式對兩種情況的處理方式完全相同。

狀態轉移

enum 值定義在 app/models/order.pyWithdrawalOrderStatus。傳輸值為小寫:pendingapprovedprocessingcompletedrejectedfailedcancelled

從 → 到觸發條件商戶觀察到
[*] → PENDINGPOST /withdrawal 對需要人工審核的帳戶路由訂單成功。商戶餘額凍結保持中;提款額度已在帳戶上預留。201 帶 status=pending。尚無回調。
[*] → APPROVED(跳過 PENDINGPOST /withdrawal 對自動審核(白名單命中、未超過閾值與限額)的帳戶路由訂單成功,且該帳戶為 MANUAL 轉帳模式。201 帶 status=approvedis_auto_approved=true。尚無回調。
[*] → PROCESSING(跳過 PENDINGAPPROVEDPOST /withdrawal 對渠道路由訂單成功,或對自動審核、AUTO 轉帳模式帳戶路由訂單成功。201 帶 status=processing。尚無回調。
PENDING → APPROVED操作員在後台 UI 審核通過(approve_order),或自動審核引擎執行延遲評估(罕見)。透過查詢可見。無回調。
PENDING → REJECTED操作員拒絕(reject_order)。凍結餘額與預留額度釋放。透過查詢可見。回調是否觸發取決於設定。
PENDING → CANCELLED關聯的 TransferTask 被取消。透過查詢可見。凍結餘額目前保持凍結;由操作員對帳。
APPROVED → PROCESSING平台建立 TransferTask(帳戶路由、AUTO 模式)或渠道 adapter 接受送單。設定 processed_at透過查詢可見。無回調。
APPROVED → FAILED失敗在訂單開始處理前浮現(罕見)。fail_order 釋放凍結與額度。觸發回調,帶 status=failed
PROCESSING → COMPLETED渠道回調回報成功,或操作員把訂單標為完成。後端確認餘額凍結(資金已支出),並設定 completed_atbank_reference已簽章回調,帶 status=completed
PROCESSING → FAILED渠道回調回報失敗,或操作員把訂單標為失敗。後端釋放凍結與額度。已簽章回調,帶 status=failed
PROCESSING → CANCELLED轉帳任務在進行中被取消。透過查詢可見。凍結餘額目前保持凍結;由操作員對帳。

終態:COMPLETEDREJECTEDFAILEDCANCELLED。其中沒有任何一個可由商戶 API 反轉。要「重試」一筆失敗的提款,請以新的 merchant_order_no 建立新訂單。

由操作員驅動的轉移

部分轉移源自後台 UI,而非商戶 API 或渠道回調。在此列出以提高透明度,避免你的對帳器做出錯誤假設:

  • approve_orderPENDING → APPROVED。設定 approved_by 為操作員使用者 id,approved_at 為審核時間,並可能帶上 approval_remarks
  • reject_orderPENDING → REJECTED。釋放凍結與預留。設定 approved_by 為拒絕的操作員(重用同一欄位),approval_remarks 為原因。
  • process_order 將未自動路由的訂單從 APPROVED → PROCESSING。當操作員接手人工出款時使用。可能覆寫 payout_method(操作員挑選 BANK_APIMANUAL),並可能預先填入 bank_reference
  • complete_orderPROCESSING → COMPLETED。當操作員確認人工出款,或由渠道回調處理器呼叫時使用。確認凍結,並可能預先填入 bank_reference
  • fail_orderAPPROVED → FAILEDPROCESSING → FAILED。釋放凍結與預留。由渠道回調處理器在失敗時呼叫,或由操作員把卡住的出款標為失敗時呼叫。

商戶 API 介面不會直接暴露上述操作。商戶透過查詢與終態的已簽章回調觀察到最終的 status

陷阱

  1. 跨次嘗試重用 merchant_order_no 後端會以 HTTP 409 與標準錯誤信封中的 CONFLICT code 拒絕重複。每一筆新的邏輯出款意圖請使用新的 merchant_order_no。對於同一個意圖的重試,重用相同的 merchant_order_no — 重複檢查反而讓你可以安全查詢既有訂單。參見 CONFLICT

  2. beneficiary_account 格式不符。 平台把值大致原樣傳給出款渠道(儲存時 AES 加密、送單時解密)。渠道專屬規則:

    • 泰國銀行帳號:只允許數字,不接受破折號或空格。對部分銀行而言,前導零有意義。
    • 越南銀行帳號:只允許數字,但部分渠道接受最長 19 碼。不要補零。
    • 印尼銀行帳號:10–16 位數字。部分渠道即使欄位在 schema 看起來可選,仍要求 beneficiary_bank_branch。 送出前剔除使用者輸入的空白。在商戶端驗證;API 不會做這件事。
  3. 幣別與渠道不符。 若你送出的 currency_code 沒有任何有效的出款渠道或有效的帳戶支援,建立呼叫會以 BUSINESS_ERROR 失敗,訊息為 No available payout account for <CCY> with sufficient quota(或來自渠道層的路由錯誤)。在此情況下商戶餘額永遠不會被凍結。參見 BUSINESS_ERROR

  4. REJECTED 當成可重試。 拒絕是終態。在拒絕後重用同一個 merchant_order_no 會觸發重複檢查。請以新的 merchant_order_no 建立新訂單,並處理拒絕原因(透過查詢可在 approval_remarks 看到)。

  5. 以明文記錄 beneficiary_account 平台在儲存時使用 AES 加密。你這一側也必須這麼做:在日誌中除了最後 4 碼以外全部遮罩,永遠不要把完整帳號以明文存在你的加密儲存以外的地方。對於有 PII 規範的司法管轄區,beneficiary_name 也適用相同規則。

  6. OPTIMISTIC_LOCK_ERROR 不重試。 把它視為暫時性的。以指數退避加抖動最多重試 3 次(參見 範例 4)。若重試用完,重新送單前先查詢 — 鎖可能其實已套用成功,重試會建立重複訂單。參見 OPTIMISTIC_LOCK_ERROR

  7. 以為 PROCESSING → COMPLETED 很快。 真實出款渠道根據結算窗口、銀行營業日與 KYC,會花幾秒到幾小時。提款停在 PROCESSING 30 分鐘是正常的。把 processed_atcreated_at 揭露給你的運營團隊;不要過早對終端使用者揭露「卡住」的狀態。

  8. 以輪詢取代依靠回調。 回調在終態時觸發一次。輪詢 GET /query/withdrawal/{order_no} 會增加負載與延遲。查詢只應作為遺漏或未驗證回調的對帳備援。參見 Callbacks

  9. 忽略 FAILED 訂單失敗時,平台會釋放凍結與預留回你的可用餘額。你的對帳器必須觀察回調並更新內部帳本;否則你會以為資金已支出,但其實還在平台上。失敗回調的投遞重試語意參見 Callbacks

  10. APPROVEDPENDING 期望收到回調。 訂單在等待審核或停在 APPROVED 時不會觸發回調。已簽章回調只在終態時觸發。若你的業務需要「已收到審核」通知,請以符合操作員 SLA 的退避策略輪詢查詢端點,或把這件事建在你的操作員端 UI 內。

  11. 混淆 amountactual_amountfee 手續費模型是「外加」 — 加在 amount 之上。你的餘額會被扣除 amount + fee。收款人收到 actual_amount,等於 amount。本版本商戶 API 沒有任何幣別換算,因此三個欄位共享相同的 currency_code

  12. 在邏輯中寫死 payout_method payout_method 由閘道根據路由決策設定,可能在看起來相似的訂單間變動。把它當成資訊性欄位即可。狀態轉移無論方式為何都是一樣的。

  13. 以為人工審核很快。 當審核引擎路由到人工,訂單可能在 PENDING 停留數分鐘到數小時。不要讓你面向終端使用者的 UI 卡在提款完成上。先確認送單,然後由對帳器推送狀態更新。

  14. 上線前未測試 FAILED 路徑。 沙盒透過 mock channel 支援出款失敗模擬。確認你的回調處理器正確對帳餘額,且 UI 能把失敗揭露給操作員。參見 Sandbox

  15. 呼叫商戶 API 取消。 沒有商戶 API 端點可以取消提款。CANCELLED 只能透過內部 TransferTask 取消達成,那是操作員動作。若你誤送了訂單,請聯絡平台運營。

  16. account_id 當成面向付款人的識別碼。 回應中的 account_id 指的是路由引擎選定的平台內部銀行帳戶。它不是收款人帳戶,可能在路由決策之間變動,絕對不可顯示給終端使用者。

  17. 隱性信任 is_auto_approved=true 渠道路由訂單一律帶 is_auto_approved=true,但這並不代表資金已經移動。讀 status 才知道實際發生什麼:pendingprocessing(處理中)、completed(已結算)、failed / rejected / cancelled(已返還)。

完整錯誤碼目錄在 Errors

上線檢查清單

把正式環境流量指向閘道之前:

  • [ ] 回調 URL 是 HTTPS、有監控、會在 5 秒內回 200 OK(或回應主體包含 success)。
  • [ ] 回調簽章驗證已實作並有單元測試。參見 Authentication
  • [ ] 你的正式環境 NAT 出口 IP 已在 Cloudflare Access 允許清單中。Cloudflare 邊緣來源 IP 模型參見 Sandbox
  • [ ] 冪等策略已決定:重試時重用相同的 merchant_order_no,且你有處理重複回應的程式路徑。
  • [ ] currency_code 會對你商戶帳號已啟用的幣別做驗證,並對該幣別實際設定的渠道做驗證。
  • [ ] 送單前已依目標國家標準化銀行帳號格式(無空格、無破折號、保留前導零)。參見 陷阱
  • [ ] beneficiary_name 以 UTF-8 編碼並截斷至 100 字元。預先測試你本地的字元處理對最罕見的收款人姓名是否正常;部分銀行會拒絕非 ASCII。
  • [ ] OPTIMISTIC_LOCK_ERROR 重試模式已在客戶端實作,上限 3 次並帶抖動。
  • [ ] 已在沙盒跑過端到端測試,至少包含 happy path(PROCESSING → COMPLETED)與失敗路徑(PROCESSING → FAILED)。
  • [ ] 正式環境的 API key 與簽章金鑰存在你的密鑰管理器中,不在原始碼或 CI 日誌中。
  • [ ] 操作員手冊涵蓋 PENDING(人工審核)、CANCELLED(操作員動作),以及 needs_manual_intervention=true(自動流程卡住)。
  • [ ] 你的帳本對帳器把 COMPLETED 視為唯一的「資金已支出」訊號,把 REJECTED / FAILED 視為「凍結已釋放、資金返還」。

交叉參照

  • 認證與簽章 — 每個請求與每個回調都使用的 MD5 排序串接簽章方案。
  • Callbacks — 伺服器對伺服器的狀態通知、重試策略、回應格式。
  • Errors — 權威錯誤碼參考。從 陷阱 段落交叉連結。
  • API reference — 每個請求與回應欄位的完整 schema 定義。
  • Sandbox — PRE 環境、mock channel、Cloudflare 邊緣 IP 允許清單模型。
  • Quickstart — 建立第一筆充值的端到端範例。
  • Deposits — 反方向;將資金收入你的商戶餘額。