◆ wport | pm_40

company_pay 台灣金流商工程實作比較

company_pay 金流串接

來源 doc/feature/pm_40/html/company-pay-payment-gateway-engineering.md

company_pay 台灣金流商工程實作比較

文件入口:company-pay.md

配套文件:

對象:後端工程師、技術主管。專注「實作會遇到什麼」而不是「賣不賣」。 最後更新:2026-06-22


1. 工程視角 TL;DR

維度綠界藍新紅陽TapPayStripe
API 風格表單跳轉 + 後端驗簽同綠界同綠界RESTful JSONRESTful JSON
認證方式MD5 / SHA256 簽章AES-256 + SHA256MD5 簽章API KeySecret Key
Webhook 風格URL-encoded form POSTURL-encoded form POSTURL-encoded form POSTJSON POSTJSON POST
Webhook 簽章驗證CheckMacValue 自驗AES 解密自驗CheckSum 自驗HMAC SHA256HMAC SHA256(內建 SDK 驗)
SDK 品質PHP / Node 還行PHP 較完整、Node 老舊幾乎沒 SDKJS / iOS / Android 完整全平台一流
文件結構PDF + 線上版混雜同綠界大量 PDF對標 Stripe,現代業界標竿
Sandbox✅ 完整✅ 完整✅ 有✅ 完整✅ 完整
預估串接工時5-8 工作日5-10 工作日10-15 工作日3-5 工作日3-5 工作日
文件踩雷率中高極低

2. API 互動模型差異(這是最大鴻溝)

2.1 傳統台灣金流(綠界 / 藍新 / 紅陽)

「表單跳轉派」

1. 你的後端產生一張隱藏表單 + 簽章
2. 前端 auto-submit 表單到金流商 hosted page
3. 使用者在 hosted page 完成付款
4. 金流商 server-to-server POST 回你的 callback URL(webhook)
5. 同時也會把使用者瀏覽器導回你的 return URL
6. 你要驗 webhook 簽章、回應 "1|OK"(綠界)或 "OK"(藍新)

特徵

  • 沒有 JSON request/response,全部是 application/x-www-form-urlencoded
  • 簽章演算法要自己實作(綠界 CheckMacValue、藍新 AES + SHA256)
  • 回應內容要符合特定格式(綠界 1|OK、藍新 SUCCESS),否則重試
  • 「return URL」與「callback URL」是兩個不同概念,初學者最容易混淆

2.2 現代派(TapPay / Stripe)

「JSON API 派」

1. 前端用 SDK 把卡號送到金流商(拿到 token / payment_method)
2. 前端拿 token 呼叫你的後端
3. 你的後端用 token + Secret Key 呼叫金流商 REST API
4. 金流商回 JSON 結果
5. 後續事件透過 webhook(JSON)通知

特徵

  • 標準 RESTful,回 JSON
  • 簽章 SDK 內建處理
  • Webhook 用 HMAC,SDK 直接驗
  • 開發體驗跟一般 SaaS API 一樣

2.3 為什麼這條差很多

影響表單跳轉派JSON API 派
Mock 測試痛苦(要 mock form post)簡單(mock fetch)
自動化測試需要 headless browser直接 unit test
錯誤訊息中文混英文、不一致結構化 error code
工程文化適配接得到但工程師會抱怨工程師覺得正常

3. 訂閱建立流程對比(程式碼層級)

3.1 綠界 ECPay 定期定額

// 後端產生訂單 + 簽章
import crypto from 'crypto'

const params = {
  MerchantID: '2000132',
  MerchantTradeNo: 'NO' + Date.now(),
  MerchantTradeDate: '2026/06/22 12:00:00',
  PaymentType: 'aio',
  TotalAmount: 3000,
  TradeDesc: '橋外生 Basic 月費',
  ItemName: 'Basic Plan',
  ReturnURL: 'https://wport.me/api/payment/ecpay/webhook',
  ClientBackURL: 'https://wport.me/recruitment/billing/success',
  ChoosePayment: 'Credit',
  // 定期定額參數
  PeriodAmount: 3000,
  PeriodType: 'M',          // M=月, D=日, Y=年
  Frequency: 1,              // 每 N 個 PeriodType 扣一次
  ExecTimes: 99,             // 最多扣幾次
  PeriodReturnURL: 'https://wport.me/api/payment/ecpay/period-webhook',
}

// 1. 參數按 key 字母排序
// 2. 串成 a=1&b=2&...
// 3. 前後加 HashKey、HashIV
// 4. URL encode + 轉小寫
// 5. SHA256 大寫
const hashKey = process.env.ECPAY_HASH_KEY
const hashIV = process.env.ECPAY_HASH_IV
const sorted = Object.keys(params).sort().map(k => `${k}=${params[k]}`).join('&')
const raw = `HashKey=${hashKey}&${sorted}&HashIV=${hashIV}`
const encoded = encodeURIComponent(raw).toLowerCase()
  .replace(/%20/g, '+')  // 綠界規範要 + 而不是 %20
  .replace(/%2d/g, '-')
  .replace(/%5f/g, '_')
  // ... 還有一串特殊字元處理
const checkMacValue = crypto.createHash('sha256').update(encoded).digest('hex').toUpperCase()

// 然後吐一個 HTML form 給前端 auto-submit

踩雷點

  • MerchantTradeDate 格式是 YYYY/MM/DD HH:mm:ss斜線不是橫線
  • 簽章前的 URL encode 有一堆例外字元(+, -, _, ., !, *, (, ))
  • 金額不能有小數點
  • MerchantTradeNo 不能超過 20 字、且不可重複(even after refund)

3.2 藍新 NewebPay 定期定額

import crypto from 'crypto'

const data = {
  MerOrderNo: 'NO' + Date.now(),
  ProdDesc: '橋外生 Basic 月費',
  PeriodAmt: 3000,
  PeriodType: 'M',
  PeriodPoint: '15',         // 每月 15 日扣
  PeriodTimes: 99,
  PayerEmail: 'user@example.com',
  // ... 還有十幾個欄位
}

// 1. 轉成 query string
const queryString = new URLSearchParams(data).toString()

// 2. AES-256-CBC 加密
const hashKey = process.env.NEWEBPAY_HASH_KEY  // 32 字元
const hashIV = process.env.NEWEBPAY_HASH_IV    // 16 字元
const cipher = crypto.createCipheriv('aes-256-cbc', hashKey, hashIV)
let encrypted = cipher.update(queryString, 'utf8', 'hex')
encrypted += cipher.final('hex')

// 3. SHA256 簽章
const shaRaw = `HashKey=${hashKey}&${encrypted}&HashIV=${hashIV}`
const tradeSha = crypto.createHash('sha256').update(shaRaw).digest('hex').toUpperCase()

// 4. 吐 form 給前端 submit
const formData = {
  MerchantID_: 'MS123456',
  PostData_: encrypted,
  TradeSha: tradeSha,
}

踩雷點

  • AES key / IV 是「字元數」不是 byte,要剛好 32 / 16
  • PeriodPoint 規則複雜(月扣傳日期、週扣傳星期、日扣傳天數)
  • Webhook 回傳的資料也是 AES 加密,要解密才能讀
  • 部分欄位沒填會被當成空字串送出,導致簽章對不上

3.3 TapPay 訂閱

// 前端拿卡號
const result = await TPDirect.card.getPrime()
const prime = result.card.prime

// 後端用 prime 建立 Card Token
const tokenRes = await fetch('https://sandbox.tappaysdk.com/tpc/card/bind', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-api-key': process.env.TAPPAY_PARTNER_KEY,
  },
  body: JSON.stringify({
    prime,
    merchant_id: 'YOUR_MERCHANT_ID',
    currency: 'TWD',
    cardholder: { name, email, phone_number },
  }),
})
const { card_token } = await tokenRes.json()

// 自己用 cron 排程,每月用 card_token 扣款
await fetch('https://sandbox.tappaysdk.com/tpc/payment/pay-by-token', {
  method: 'POST',
  headers: { 'x-api-key': process.env.TAPPAY_PARTNER_KEY },
  body: JSON.stringify({
    card_key: card_token,
    amount: 3000,
    currency: 'TWD',
    order_number: 'SUB-' + Date.now(),
  }),
})

特徵

  • 標準 REST,每一步都是 JSON
  • 訂閱排程要你自己寫(cron / queue)
  • 失敗重試、續扣失敗通知、cancellation 都要自己實作

3.4 Stripe 訂閱

import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

// 建立 Checkout Session
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{ price: 'price_xxx', quantity: 1 }],
  success_url: 'https://wport.me/billing/success?session_id={CHECKOUT_SESSION_ID}',
  cancel_url: 'https://wport.me/billing/cancel',
  customer_email: 'user@example.com',
})

// 把 session.url 回傳給前端,前端跳轉就好
return { checkout_url: session.url }

特徵

  • 5 行 code 搞定
  • 續扣、proration、trial、Smart Retries、稅率、發票全部自動
  • 文件、SDK、社群一流

4. Webhook 處理對比

4.1 綠界 Webhook(重點:回應格式)

app.post('/api/payment/ecpay/webhook', async (req, res) => {
  const params = req.body  // x-www-form-urlencoded
  const receivedMac = params.CheckMacValue
  delete params.CheckMacValue

  // 自己重算 CheckMacValue
  const calculatedMac = calculateCheckMacValue(params)
  if (calculatedMac !== receivedMac) {
    return res.status(400).send('0|InvalidCheckMacValue')
  }

  if (params.RtnCode === '1') {
    // 付款成功
    await markOrderPaid(params.MerchantTradeNo)
  }

  // ⚠️ 重要:必須回 "1|OK",不是 200 OK
  res.send('1|OK')
})

踩雷點

  • 回應必須是 1|OK 否則綠界會一直重送(24 小時內 10+ 次)
  • 重送機制冪等性要自己處理
  • 定期定額的續扣 webhook 跟首次付款 webhook 是不同 URL 不同欄位

4.2 藍新 Webhook(重點:解密)

app.post('/api/payment/newebpay/webhook', async (req, res) => {
  const { TradeInfo, TradeSha } = req.body

  // 1. 驗 sha
  const expectedSha = crypto.createHash('sha256')
    .update(`HashKey=${hashKey}&${TradeInfo}&HashIV=${hashIV}`)
    .digest('hex').toUpperCase()
  if (expectedSha !== TradeSha) return res.status(400).send()

  // 2. AES 解密
  const decipher = crypto.createDecipheriv('aes-256-cbc', hashKey, hashIV)
  let decrypted = decipher.update(TradeInfo, 'hex', 'utf8')
  decrypted += decipher.final('utf8')
  const data = JSON.parse(decrypted)

  if (data.Status === 'SUCCESS') {
    await markOrderPaid(data.Result.MerchantOrderNo)
  }

  res.send('SUCCESS')  // 跟綠界回應內容不同
})

踩雷點

  • 解密失敗時通常是 padding 問題,要注意 PKCS7
  • 定期定額的 webhook payload 結構跟首次付款不同
  • 回應內容必須是 SUCCESS,不是 HTTP 200

4.3 Stripe Webhook(標準作法)

app.post('/api/payment/stripe/webhook',
  express.raw({ type: 'application/json' }),  // ⚠️ 必須 raw body
  async (req, res) => {
    const sig = req.headers['stripe-signature']

    let event
    try {
      event = stripe.webhooks.constructEvent(
        req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
      )
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`)
    }

    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutCompleted(event.data.object)
        break
      case 'invoice.payment_succeeded':
        await handleInvoicePaid(event.data.object)
        break
      // ...
    }

    res.json({ received: true })
  }
)

特徵

  • SDK 內建 HMAC 驗證
  • 回應只要 200 即可
  • Stripe Dashboard 可重送、可看每次發送歷史

5. 各業者常見踩雷點

5.1 綠界

雷點解法
CheckMacValue 算不對URL encode 規則跟標準 RFC 不同,要照官方 PDF 寫
MerchantTradeNo 重複即使訂單作廢也不能重用,前面加日期前綴
金額有小數點被拒一律用整數 TWD
測試環境跟正式環境 HashKey 不同環境變數分開、Code Review 強檢
定期定額第一次扣款的 webhook 跟後續續扣不同 URL兩個都要實作
退款不能用 API,要登入後台手動SOP 設計時要考慮

5.2 藍新

雷點解法
AES key / IV 長度錯必須剛好 32 / 16 字元(不是 byte)
Webhook 解密失敗通常是 raw body 被 middleware 改過,要先存原始 body
PeriodPoint 規則複雜月扣 = 日(1-28)、週扣 = 星期、日扣 = 天數差
文件 PDF 跟線上版內容對不上以線上版為準,PDF 是舊的
Sandbox 跟正式環境 API endpoint 不同環境變數分開

5.3 紅陽

雷點解法
文件很多在 PDF 附件開戶時請業務寄完整文件包
API 命名跟其他家差異大抽象層必做,不然耦合死
沒有官方 Node.js SDK自己包,或找社群版(品質不保證)
客服反應慢重要案件直接打電話

5.4 TapPay

雷點解法
訂閱排程要自己寫用 BullMQ / cron,注意重試與冪等
Card Token 過期處理TapPay 提供卡片更新通知,需訂閱
3DS 流程要前端配合用官方 JS SDK

5.5 Stripe

雷點解法
Webhook raw body 處理Express 要用 express.raw() 而非 express.json()
海外卡 3DS 強制用 PaymentIntent 而非舊版 Charge API
台灣帳號限制Stripe Taiwan 部分功能(Connect Express)尚未支援
撥款週期 7 天起新帳號可能更久,要先跟 Stripe 確認
Webhook 順序不保證用 idempotency key 與 status 機判斷

6. 工時預估(單一金流,不含雙軌)

以「完整訂閱流程 + 自定義收款 + 雙發票」為例:

項目綠界藍新紅陽TapPayStripe
環境設定 / 申請帳號1 天1 天1 天1 天1 天
訂閱建立流程1.5 天1.5 天3 天1 天0.5 天
Webhook 接收 + 驗簽1 天1.5 天2 天0.5 天0.5 天
一次性收款(admin 自定義)1 天1 天1.5 天0.5 天0.5 天
訂閱續扣排程 / 失敗重試內建內建內建3 天內建
電子發票串接1 天1 天2 天2 天N/A
Sandbox 測試1 天1.5 天2 天1 天0.5 天
對帳 + 退款 SOP1 天1 天1.5 天1 天0.5 天
小計~7.5 天~8.5 天~13 天~10 天~3.5 天

⚠️ 注意:

  • 雙軌(綠界 + Stripe)= 約 11 天,但抽象層額外 2-3 天
  • 上述為「順利情況」,踩雷會 1.5-2 倍
  • 不含設計、PM 對齊、測試 QA、code review

7. Sandbox / 測試環境

業者Sandbox 完整度測試卡號模擬 webhookLocal 開發
綠界✅ 完整官方提供✅ 後台可手動觸發需 ngrok
藍新✅ 完整官方提供⚠️ 較難主動觸發需 ngrok
紅陽⚠️ 有但較陽春部分需申請⚠️ 較弱需 ngrok
TapPay✅ 完整官方提供✅ 可重送需 ngrok
Stripe✅ 業界標竿數十種測試卡涵蓋各種錯誤情境✅ CLI 可直接轉發到 localhostStripe CLI 直接打進 localhost,不用 ngrok

Stripe CLI 是工程體驗的代差stripe listen --forward-to localhost:3000/webhook 一行,Webhook 直接打進你本機,不用對外暴露端口。


8. 推薦架構(PaymentProvider 抽象)

不管最後選哪家,這個抽象層都該做

// packages/payment-core/src/types.ts
export type CreateCheckoutInput = {
  userId: string
  planId: string
  email: string
  country: 'TW' | string
  successUrl: string
  cancelUrl: string
}

export type CheckoutResult = {
  checkoutUrl: string
  provider: 'stripe' | 'ecpay'
  sessionId: string
}

export interface PaymentProvider {
  readonly name: 'stripe' | 'ecpay' | 'newebpay' | 'tappay'

  // 訂閱
  createCheckoutSession(input: CreateCheckoutInput): Promise<CheckoutResult>
  cancelSubscription(subId: string, mode: 'immediate' | 'at_period_end'): Promise<void>
  updatePaymentMethod(customerId: string): Promise<{ updateUrl: string }>

  // 一次性收款
  createOneTimeCharge(input: CreateChargeInput): Promise<{ payUrl: string; chargeId: string }>
  voidCharge(chargeId: string): Promise<void>

  // Webhook
  verifyWebhook(rawBody: Buffer | string, signature: string): WebhookEvent

  // 共用
  retrieveInvoice(invoiceId: string): Promise<Invoice>
}

// packages/payment-core/src/factory.ts
export function getProvider(country: string): PaymentProvider {
  return country === 'TW'
    ? new ECPayProvider(config.ecpay)
    : new StripeProvider(config.stripe)
}

業務邏輯只認 interface,不認 provider。換家時改 factory 就好。


9. 工程面最終建議

9.1 主推:綠界 + Stripe

理由

  • 綠界踩雷時 StackOverflow / 鐵人賽 / Medium 文章最多
  • Stripe 工程體驗無對手
  • 兩家都有完整 Sandbox

9.2 不推:紅陽

工程面有明顯的代差,新團隊上手痛、文件落後、社群案例少。除非有強烈商業綁定理由,不該選。

9.3 TapPay 適合的場景

如果橋外生未來轉向 B2C 一次性購買(例如求職者付費下載報告、買履歷加值),TapPay 工程體驗會明顯好過綠界。但現階段 B2B 訂閱不選

9.4 必做:抽象層 + Webhook idempotency

不論選哪家,這兩件事都要做:

  1. PaymentProvider 抽象:避免業務邏輯綁死,未來換家成本可控
  2. Webhook 冪等性處理:用 event_id + processed_events table,重送也不會重複扣款

10. Sprint 0 工程清單

選定金流後第一週要做的:

  • 申請 Sandbox 帳號(綠界 / Stripe 各一)
  • 申請正式商戶帳號(流程跑 1-2 週)
  • 環境變數結構設計(dev / staging / prod 三組金鑰)
  • PaymentProvider interface 定義
  • payment schema 設計(subscription、charge、invoice、webhook_event 四張表)
  • Webhook idempotency 機制(用 webhook_event_id 鎖)
  • Sandbox 測試卡號清單收集
  • Stripe CLI 安裝(如選 Stripe)
  • 對帳排程框架(cron job 預留)
  • 監控 metric 規劃(checkout 成功率、webhook 成功率)

11. 參考連結

綠界

藍新

紅陽

TapPay

Stripe