綠界金流與 Rails 整合指南

Og sharing

前言

當我們在台灣製作本地使用的電商或是網路服務需要收費時,綠界(Ecpay)會是一個我們時常提到的名字,由於筆者在近期的工作中時常需要整合綠界金流的服務,又發現在國內 Ruby / Rails 圈比較缺乏對相關整合的介紹文章,因此編寫本文給需要的人參考。

本文涉及的綠界服務

  1. 綠界一般金流服務
    • 一般信用卡
    • 定期定額
    • ATM

服務申請

這部份不在本次文章介紹的範圍,需要的人可以自行到綠界官網參考

API 文件

可以從總索引頁面下載PDF

官方 Library

在上面提到的索引頁中有提供 Ruby 的 SDK Library,但實際上大部份只是複製 Java 版的命名方式過來的東西,和 Ruby 慣用的方式不同,因此本文將完全不使用。

綠界金流的特色

以綠界為首,台灣目前一般廣為使用的線上金流服務,幾乎都是所謂的 Off-Site Payment,意指在付款時需要從我們的網站產生一個 Off-Site Payment HTML Form 並 render 在使用者的瀏覽器上並將表單的 Action 設定為金流服務商指定的 URL,再從上面發送(Submit)這個表單,讓使用者在服務商的網站上輸入信用卡等的付款資訊後,再從那裡 Redirect 回來我們網站的這種流程。

流程

信用卡一次付清或分期付款

Gateway flow credit

ATM 付款

Gateway flow atm

信用卡定期定額

Gateway flow credit periodical

P.S. 以上流程只反映綠界部份的處理,不涉及實際 APP 的例如電商程式中的訂單處理等。

實作範例與要點

我們將實作分成兩大部份:Off-Site Payment Form 與接收綠界的 Request。

實作生成 Off-Site Payment Form 的 Service Object

通常我們會在某個 Controller Action 中,依照綠界的規定來生成這個表單用的 Hash,而這個表單同常是基於我們網站服務上的「訂單(Order)」和「付款(Payment)」來產生的,所以通常我會寫一組 Service Object 來產生,這邊的設定是先製作一組基底的 BaseFormService,信用卡、定期定額和 ATM 再分別製作繼承的 Sub-Class。

首先我們來製作基底的 BaseFormService

module Ecpay::Payment
  class BaseFormService
    include ActiveModel::Model
    # 假定我們有 Order 和 Payment 兩種物件代表訂單和支付資訊
    attr_accessor :order, :payment, :client_back_url, :return_url, :custom_field1, :custom_field2, 
                  :custom_field3, :custom_field4, :encrypt_type, :platform_id, :invoice_mark, 
                  :need_extra_paid_info, :merchant_trade_date, :payment_type

    def initialize(attributes={})
      super
      @encrypt_type ||= 1
      @invoice_mark ||= 'N'
      @need_extra_paid_info ||= 'Y'
      @payment_type ||= 'aio'
      @merchant_trade_date ||= Time.now.getlocal.strftime('%Y/%m/%d %H:%M:%S')
      @item_name ||= order.ecpay_trade_desc
      @total_amount ||= order.total.to_i
      @merchant_trade_no ||= [order.number, payment.number].join
      @trade_desc ||= order.ecpay_trade_desc
    end

    # 定義這三個 method,從 Settings Class 取值
    %w[hash_key hash_iv merchant_id].each do |set_key|
      define_method set_key do
        Settings.ecpay_payment.send(set_key)
      end
    end

    def params_without_check_mac_value
      Hash[fields_for_export.map{|field| [field, send(field.underscore)] }]
    end

    def params_with_check_mac_value
      res = params_without_check_mac_value.dup
      res['CheckMacValue'] = check_mac_value(res)
      res
    end

    def fields_for_export
      %w[MerchantTradeNo TotalAmount ItemName MerchantID MerchantTradeDate TradeDesc EncryptType
         PlatformID InvoiceMark PaymentType ChoosePayment NeedExtraPaidInfo ReturnURL].freeze
    end
    private
    def check_mac_value(params)
      query_string = params.transform_keys(&:downcase).to_a.sort_by(&:first).unshift(['HashKey', hash_key]).push(['HashIV', hash_iv]).map do |kav|
        kav.join("=")
      end.join("&")
      raw = urlencode_dot_net(query_string)
      Digest::SHA256.hexdigest(raw).upcase
    end

    def urlencode_dot_net(raw_data)
      CGI.escape(raw_data).downcase.gsub('%21', '!').gsub('%2a', '*').gsub('%28', '(').gsub('%29', ')')
    end
  end
end

關於每種支付方式需要哪些參數,請參閱 API 文件,以上程式碼中,主要的 Methods 如下:

  1. initialize:由於先 include ActiveModel::Model,使得我們可以像 ActiveRecord 物件一樣用 Hash 的方式設定初始值,並設定預設值。
  2. fields_for_export:設定本次表單中要有的欄位。
  3. params_without_check_mac_value:由 fields_for_export 指定的欄位 underscore 後取值,再用 Hash[ key, value, ... ] 的方式組合回 Hash。
  4. params_with_check_mac_value:將 params_without_check_mac_value 產生的 Hash 依下列規則產生 CheckMacValue 後再加入欄位。
    • 將所有欄位轉換小寫,再依欄位名稱排序後,於最前和最後加入 HashKey=HashKey 的值HashIV=HashIV 的值 後組成 URI Query String。
    • 將其使用 Asp.Net 的方式做 URI Encode。
    • 用 SHA256 的方式製造雜湊。

最後就會產生出用來製造 Off-Site Payment Form 用的全部欄位,比起一個一個 key 和 value 分別指定去造出 Hash 的方式來說,這個方式可以讓程式變得簡潔而且好設定。

後續再製作繼承 BaseFormServiceCreditFormService 產生信用卡交易用的表單:

module Ecpay::Payment
  class CreditFormService < BaseFormService
    attr_accessor :order_result_url
    def fields_for_export
      super + %w[OrderResultURL].freeze
    end
  end
end

AtmFormService 產生 ATM 的表單:

module Ecpay::Payment
  class AtmFormService < BaseFormService
    ATM_ADD_FIELDS = %w[ExpireDate PaymentInfoURL ClientRedirectURL ChooseSubPayment].freeze
    attr_accessor *ATM_ADD_FIELDS.map{|field| field.underscore.to_sym}
    def initialize(attributes={})
      super
      @expire_date ||= 3
      @choose_sub_payment ||= 'ESUN'
    end
    def fields_for_export
      super + ATM_ADD_FIELDS
    end
  end
end

以上都使用繼承的方式實作,因此 BaseFormService 有的參數與預設值即無需再設定;接下來由於信用卡定期定額是信用卡交易加上幾個參數,因此 PeriodCreditFormService 是繼承自 CreditFormService

module Ecpay::Payment
  class PeriodCreditFormService < CreditFormService
    PERIOD_ADD_FIELDS = %w[PeriodAmount PeriodType Frequency ExecTimes PeriodReturnURL].freeze
    attr_accessor *PERIOD_ADD_FIELDS.map{|field| field.underscore.to_sym}
    def fields_for_export
      super + PERIOD_ADD_FIELDS
    end
  end
end

順便在這邊說明一下定期定額的參數,假設一筆定期定額的交易總金額是 18000 元,分六期,月繳的話,參數會長這樣:

欄位 說明
TotalAmount 18000
PeriodAmount 3000
PeriodType M
Frequency 1 PeriodType 多久扣一次,例如 PeriodType=M, Frequency=2, ExecTimes=6 就是 2 個月扣一次,總扣 6 次
ExecTimes 6
PeriodReturnURL 自己填

PeriodType * ExecTimes 一定要等於 TotalAmount

這樣在 Controller 中由以下方式引用:

class PaymentsController < ApplicationController
  def new
    @order = Order.find_by(number: params[:order_id])
    @payment = @order.payments.new
    @offiste_form_params = Ecpay::Payment::CreditFormService.new(order: @order, payment: @payment, order_result_url: ecpay_payment_order_results_url, 
                                                                 return_url: ecpay_payment_order_returns_url).params_with_check_mac_value
    @offsite_form_action = Settings.ecpay_payment.test ? 'https://payment-stage.ecpay.com.tw/Cashier/AioCheckOut/V5' : 'https://payment.ecpay.com.tw/Cashier/AioCheckOut/V5'
  end
end

在 View 裡 render 欄位並用 Javascript 的方式直接 Submit Form。

<form action="<%=@offsite_form_action%>" method="post" id="OffsitePaymentForm">
  <%@offiste_form_params.each do |name, val|%>
    <%=hidden_field_tag name, val%>
  <%end%>
</form>
<script>
document.addEventListener("DOMContentLoaded", function(){
  document.getElementById('OffsitePaymentForm').submit();
})
</script>

這邊直接使用 HTML Form Tag 而不用一般常見的 form_tag 是因為 helper 會產生一些例如 _methodauthenticity_token 之類在這邊用不到的 hidden field,會造成綠界不接受這些參數而無法完成交易。

實作接收綠界的 Callback Actions

回應綠界從前端或後端來的 POST Request,有以下幾種,分別以表格列舉之。

在 Off-Site Payment Form 中的參數名 前/後端 在哪種付款有用 說明 備註
ClientRedirectURL ATM/CVS/BARCODE 非同步付款完成訂單時,通知取號或條碼內容
PaymentInfoURL ATM/CVS/BARCODE 非同步付款完成訂單時,通知取號或條碼內容
OrderResultURL Credit 訂單付款完成時,通知付款完成訊息
ReturnURL ALL 訂單付款完成時,通知付款完成訊息
PeriodReturnURL 信用卡定期定額 定期定額每期完成時通知該期付款狀況

其中 ClientRedirectURLPaymentInfoURL 以及 OrderResultURLReturnURL 的內容在同一筆訂單來說會是相同的,但是發起的地方不同,在實作處理時要注意以下事項:

  1. 需要 skip_before_action :verify_authenticity_token:綠界的 Request 並非 Rails Action。
  2. 前端的 Action 需要確認 User Session,後端的則否。
  3. 兩端的 Action 都要做 CheckMacValue 的檢測。
  4. 以經驗來說後端的 Request 通常會早於前端那一個,例如 PaymentInfoURL 會比 ClientRedirectURL 先發送。
  5. 同一個處理的前端和後端 Request,請務必要分開 Action 處理。

以下是範例程式:

class Ecpay::Payment::OrderResultsController < ApplicationController
  skip_before_action :verify_authenticity_token
  before_action :check_mac_value_for_paid, :authenticate_user!
  def create
    # 這邊執行 Rails 端付款完成的處理
    redirect_to succesfull_payment_path
  end
  private
  def check_mac_value_for_paid
    render(action: :error) unless Ecpay::Payment::CheckMacService.new(params).run
  end
end

class Ecpay::Payment::ReturnsController < ApplicationController
  skip_before_action :verify_authenticity_token
  before_action :check_mac_value_for_paid
  OK_RESP = '1|OK'
  FAIL_RESP = '0|ERROR'
  def create
    # 這邊執行 Rails 端付款完成的處理
    render OK_RESP
  end
  private
  def check_mac_value_for_paid
    render FAIL_RESP unless Ecpay::Payment::CheckMacService.new(params).run
  end
end

class Ecpay::Payment::CheckMacService
  attr_accessor :params, :current_check_mac_value
  def initialize(params)
    # 需要剪掉和綠界無關的 params
    @params = params.slice(*params.keys - %w[action controller id].freeze)
    @current_check_mac_value = @params.delete('CheckMacValue')
  end

  def run
    check_mac_value(params) == current_check_mac_value
  end

  private
  def check_mac_value(params)
    # 這邊和上面 BaseFormService 的一樣
  end
end

以上就是程式碼的部份,接下來將從個人經驗中列舉一些關於綠界金流的踩雷經驗:

踩雷經驗

測試帳號的區別

綠界提供的測試 Credentials(在這邊就是 MerchantID、HashKey、HasIV)有分有啟用 OTP 和關閉 OTP 的,通常我們為了測試方便以及編寫測試程式的關係,會使用後者;但如果你也有在同時開發物流和電子發票的話,由於關閉 OTP 的那一組和物流的 MerchantID 不一樣,因此在設定值上需要針對不同的綠界服務做區隔。

某些東西是無法測試

雖然綠界有提供給你測試後台,但其實很多功能還是無法測試的,茲列舉如下:

  • ATM / 超商條碼、代碼這類非同步付款,無法測試「完成付款」的功能。
  • 信用卡退款功能。

定期定額的分期測試

定期定額信用卡交易時的 PeriodReturnURL 其實是可以在綠界的測試後台中做模擬付款的,路徑在左側 sidebar 選單的「信用卡收單」->「信用卡定期定額」中,對一筆交易點選「模擬付款」(如下圖紅色框匡所示),即會對發起交易時設定的 PeriodReturnURL 發起 Request。

Simulate pay periodical

關於 InvoiceMark

綠界在 Off-Site Payment Form 的參數中有 InvoiceMark 這個參數決定是否要在訂單成立時同時開立電子發票,設定是 Y 的話則還要跟電子發票的欄位同時設定,不過由於在正式環境中開通這個功能需要特別的會員資格,因此開發時需要確認是否具備此一資格再進行開發,如果開發之後才發現不能用的話會造成不必要的損失。

Sandbox 與正式環境的差異

雖然綠界有提供測試環境與 Credentials,但不代表在測試環境中沒問題的程式碼,搬到正式環境後也一定沒問題,筆者自己就遇到過在信用卡交易中加入了 多餘的欄位 ChooseSubPaymentESUN(代表 ATM 時的玉山銀行),在測試環境中沒發生問題,但是在正式環境會出現沒有明確說明的錯誤代碼,拿掉之後才正常,因此在上線前最好還是用正式環境去測試才較為保險。

結語

做為國內最主要也是最多人使用的金流服務供應商之一,由於 API 先天設計上的部份不合理性,導致在整合時容易遇到解耦合性,也就是如何適度的將金流邏輯和業務邏輯分隔的問題,在這邊分享自己的經驗,期望能讓大家少踩到一些雷。