通常台灣電商的發票欄位長得大概像這樣:先選發票類型,選完個人還要再選載具,選了手機條碼才要填那串以斜線開頭的號碼;選捐贈則是填愛心碼;選公司才是抬頭加統編,這一篇要把這要的邏輯用 Additional Checkout Fields API 完整實作出來,全程不寫任何 JavaScript。
先把欄位關係整理出來
寫程式之前先把規格列清楚,這張表就是後面每個 hidden 與 required 規則的來源:
| 欄位 | 型別 | 顯示條件 | 格式 |
|---|---|---|---|
| 發票類型 | select | 永遠顯示、必填 | personal/company/donation |
| 發票抬頭 | text | 發票類型 = 公司 | 最長 60 字 |
| 統一編號 | text | 發票類型 = 公司 | 8 位數字且檢查碼正確 |
| 載具類型 | select | 發票類型 = 個人 | member/mobile/certificate |
| 手機條碼 | text | 個人 且 載具 = 手機條碼 | 斜線加 7 碼大寫英數 |
| 自然人憑證條碼 | text | 個人 且 載具 = 自然人憑證 | 2 碼大寫英文加 14 碼數字 |
| 愛心碼 | text | 發票類型 = 捐贈 | 3 到 7 位數字 |
七個欄位、五種顯示條件,其中兩個是兩層條件,用傳統佈景主題的思維,這時候大概已經開始想 jQuery 的 change 事件與 slideToggle() 了;區塊結帳頁不用,這些條件全部寫在 PHP 的註冊參數裡。
先複習欄位是怎麼註冊的
一個欄位就是一次 woocommerce_register_additional_checkout_field(),把 id、label、location、type 丟進去,前端表單、Store API、驗證、訂單儲存就都有了:
woocommerce_register_additional_checkout_field(
array(
'id' => 'block-theme/invoice-tax-id',
'label' => __( '統一編號', 'block-theme' ),
'location' => 'order',
'type' => 'text',
'required' => true, // 除了 true / false,還可以給一段條件規則.
'hidden' => false, // 同上.
)
);
機制的全部就在 required 與 hidden 這兩個參數:它們接受布林值,也接受一段條件規則。給布林值就是永遠必填、永遠顯示;給規則,就變成「符合某個狀態時才必填、才顯示」。
那規則要拿什麼來比對?結帳區塊在每次資料變動時,都會把當下的購物車與表單狀態整理成一個叫 document object 的 JSON,結構大致是這樣:
{
cart: { items, items_count, coupons, totals, needs_shipping, ... },
customer: { id, billing_address, shipping_address, additional_fields },
checkout: { additional_fields, payment_method, customer_note, ... }
}
顧客在發票類型選了「公司電子發票」,checkout.additional_fields 裡的 block-theme/invoice-type 就會變成 'company'。所謂條件規則,就是一段描述「這個 JSON 要長什麼樣」的 JSON Schema,比對成立,欄位就顯示或變成必填,比對不成立就消失。
條件規則是一段 JSON Schema
JSON Schema 不是資料,是**描述資料要長什麼樣的規格,**這裡拿它去問 document object 一個是非題 —「目前的狀態,發票類型是不是 company?」是的話欄位就顯示,不是就隱藏。
先看被問的那份資料。顧客在發票類型選了「公司電子發票」的當下,document object 是這樣:
{
"cart": { "items_count": 1, "needs_shipping": true },
"customer": { "id": 0, "billing_address": { } },
"checkout": {
"additional_fields": {
"block-theme/invoice-type": "company"
}
}
}
要問的值躲在三層裡面:checkout → additional_fields → block-theme/invoice-type,而 JSON Schema 的規定是每往下指一層,就要寫一個 properties 把下一層包起來,所以規則的形狀會跟資料的結構一模一樣,只是每層中間多插一個 properties:
| 資料(document object) | 規則(JSON Schema) |
|---|---|
checkout | properties → checkout |
checkout.additional_fields | properties → checkout → properties → additional_fields |
checkout.additional_fields['block-theme/invoice-type'] | 再一層 properties,然後才是欄位 ID |
把最後一層的值換成條件 array( 'const' => 'company' )(const 就是「必須等於這個值」,另外還有 enum 可以多選一),完整的規則長這樣:
array(
'type' => 'object',
'properties' => array(
'checkout' => array(
'properties' => array(
'additional_fields' => array(
'properties' => array(
'block-theme/invoice-type' => array( 'const' => 'company' ),
),
),
),
),
),
);
讀法是由外往內:「這份資料是個物件,它的 checkout 屬性裡面的 additional_fields 屬性裡面的 block-theme/invoice-type,值必須是 company」。看起來很囉唆,但這就是 JSON Schema 描述巢狀結構的標準寫法。
而 WooCommerce 允許省略最外層的 type 與 properties,只要規則最上層的 key 是 cart、checkout、customer 其中之一,它會自己補上外殼,所以同一個條件可以縮成這樣:
array(
'checkout' => array(
'properties' => array(
'additional_fields' => array(
'properties' => array(
'block-theme/invoice-type' => array( 'const' => 'company' ),
),
),
),
),
);
這段就是接下來每個欄位的 hidden 與 required 要填的東西。
路徑要記得跟 location 對應,order 的欄位在 checkout.additional_fields,contact 與 address 的欄位則在 customer.additional_fields 與 customer.billing_address 底下。
既然七個欄位都是同一個規則,我們可以把它寫一個產生器:
/**
* 產生一段比對訂單區欄位值的 schema rule。
*
* @param array $conditions 欄位 ID 對應的期待值,值為陣列時視為多選一。
* @return array
*/
function block_theme_invoice_rule( array $conditions ) {
$properties = array();
foreach ( $conditions as $field_id => $expected ) {
$properties[ $field_id ] = is_array( $expected )
? array( 'enum' => $expected )
: array( 'const' => $expected );
}
return array(
'checkout' => array(
'type' => 'object',
'required' => array( 'additional_fields' ),
'properties' => array(
'additional_fields' => array(
'type' => 'object',
'required' => array_keys( $properties ),
'properties' => $properties,
),
),
),
);
}
傳一個條件就是單層、傳兩個就是 AND(同一個 object 裡的多個 property 本來就是全部都要成立),要多選一就把值傳成陣列變成 enum。
顯示條件與必填條件是同一件事的正反面,所以再寫一個反向的:
function block_theme_invoice_rule_not( array $rule ) {
return array(
'not' => array( 'properties' => $rule ),
);
}
之後每個欄位都是這組固定寫法:
'required' => $is_company,
'hidden' => block_theme_invoice_rule_not( $is_company ),
一個 select 帶出兩層分支
主選單就是普通的 select,required 給 true:
woocommerce_register_additional_checkout_field(
array(
'id' => 'block-theme/invoice-type',
'label' => __( '發票類型', 'block-theme' ),
'location' => 'order',
'type' => 'select',
'required' => true,
'options' => array(
array(
'value' => 'personal',
'label' => __( '個人電子發票(存入載具)', 'block-theme' ),
),
array(
'value' => 'company',
'label' => __( '公司電子發票(三聯式)', 'block-theme' ),
),
array(
'value' => 'donation',
'label' => __( '捐贈發票', 'block-theme' ),
),
),
)
);
接著把五個條件先準備好,重複使用:
$is_company = block_theme_invoice_rule( array( 'block-theme/invoice-type' => 'company' ) );
$is_donation = block_theme_invoice_rule( array( 'block-theme/invoice-type' => 'donation' ) );
$is_personal = block_theme_invoice_rule( array( 'block-theme/invoice-type' => 'personal' ) );
// 兩層條件:發票類型是個人,而且載具類型是手機條碼。
$is_mobile = block_theme_invoice_rule(
array(
'block-theme/invoice-type' => 'personal',
'block-theme/invoice-carrier' => 'mobile',
)
);
公司分支的統編欄位:
woocommerce_register_additional_checkout_field(
array(
'id' => 'block-theme/invoice-tax-id',
'label' => __( '統一編號', 'block-theme' ),
'location' => 'order',
'type' => 'text',
'required' => $is_company,
'hidden' => block_theme_invoice_rule_not( $is_company ),
'attributes' => array(
'autocomplete' => 'off',
'maxLength' => '8',
'pattern' => '[0-9]{8}',
'title' => __( '8 位數字', 'block-theme' ),
),
'sanitize_callback' => 'block_theme_sanitize_digits',
'validate_callback' => 'block_theme_validate_invoice_tax_id',
)
);
手機條碼欄位只是把條件換成兩層的那組:
'required' => $is_mobile,
'hidden' => block_theme_invoice_rule_not( $is_mobile ),
載具類型 select 本身也是條件欄位($is_personal),於是就有了兩層連動:選「個人」出現載具類型,載具類型選「手機條碼」再出現號碼欄位。切回「公司」時,這兩個欄位一起從 DOM 消失,換成抬頭與統編。

順帶提兩個註冊參數的細節:
attributes有白名單,只有maxLength、readOnly、pattern、autocomplete、autocapitalize、title以及aria-/data-開頭的屬性會被保留,其他(例如placeholder、inputMode)會被丟掉並留下一則_doing_it_wrong警告。- 欄位順序就是註冊順序,附加欄位沒有
index參數可以調(那是核心地址欄位才有的)。
格式錯誤要在後端驗證
pattern 屬性只是瀏覽器層的提示,真正的把關在 validate_callback,統編除了 8 位數字,還有檢查碼可以驗:
function block_theme_is_valid_tax_id( $tax_id ) {
if ( ! preg_match( '/^\d{8}$/', $tax_id ) || '00000000' === $tax_id ) {
return false;
}
$weights = array( 1, 2, 1, 2, 1, 2, 4, 1 );
$sum = 0;
foreach ( str_split( $tax_id ) as $position => $digit ) {
$product = (int) $digit * $weights[ $position ];
// 乘積的十位數與個位數相加。
$sum += intdiv( $product, 10 ) + ( $product % 10 );
}
if ( 0 === $sum % 5 ) {
return true;
}
// 第 7 碼是 7 的號碼允許差 1,這是財政部規則裡的例外。
return '7' === $tax_id[6] && 0 === ( $sum + 1 ) % 5;
}
搭配 sanitize_callback 先把非數字清掉,客人貼上「12345675」以外的格式(例如帶空格)也不會誤判:
'sanitize_callback' => 'block_theme_sanitize_digits', // preg_replace( '/\D/', '', $value )
載具的兩個欄位同樣各有格式:手機條碼是 #^/[0-9A-Z.+-]{7}$#、自然人憑證是 /^[A-Z]{2}\d{14}$/,兩個都先用 strtoupper() 正規化再驗,因為客人很習慣打小寫。
實際送出時,錯誤會顯示在欄位旁邊:

另外,註冊參數其實還有一個 validation 可以用 schema 描述格式規則,但它跑出來的錯誤訊息是固定的 Please provide a valid %s,而且一旦提供 validation,你的 validate_callback 就會被它取代(CheckoutFields::get_validate_callback())。要給客人看得懂的中文訊息,還是用 callback。
不用寫 JS,但要知道它做了什麼
條件欄位的比對是在瀏覽器端跑的,WooCommerce 為此準備了一支 wc-schema-parser,而且只有在真的有欄位註冊了 schema rule 時才會載入,沒用到條件欄位的網站不會多下載這支腳本。
伺服器端則是 Store API 收到請求時會重建 document object,把隱藏欄位整個排除在驗證之外,所以「隱藏的必填欄位」不會擋住結帳,這點不用自己處理。
如果請 AI 開發這段
直接說「加台灣電子發票欄位」,AI 大機率給你 woocommerce_checkout_fields 的舊寫法,再配一段 jQuery 顯示隱藏,要它一次到位條件要明確:
用 woocommerce_register_additional_checkout_field 在區塊結帳頁註冊台灣電子發票欄位,location 用 order。發票類型是 select(個人/公司/捐贈),其餘欄位用 hidden 與 required 的 schema rule 做條件顯示,規則要包含 required 與 type object 避免欄位在沒有值時全部展開,不要寫任何 JavaScript。
驗收時盯這幾點:
- **有沒有寫 JS,**看到
addEventListener('change')就是走錯路。 - 規則裡有沒有
required,沒有的話條件全部形同虛設。 - document object 的路徑對不對,
order位置的欄位在checkout.additional_fields。 type是不是只用了 text/select/checkbox,number、email會安靜地不出現。- 驗證有沒有在後端,只有
pattern屬性等於沒有驗。
欄位到這裡就完整了,下一篇我們繼續討論物流串接,也就是自訂 Shipping Method 與超商取貨門市電子地圖。