通常台灣電商的發票欄位長得大概像這樣:先選發票類型,選完個人還要再選載具,選了手機條碼才要填那串以斜線開頭的號碼;選捐贈則是填愛心碼;選公司才是抬頭加統編,這一篇要把這要的邏輯用 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 不是資料,是**描述資料要長什麼樣的規格,**這裡拿它去問 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,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。
條件欄位的比對是在瀏覽器端跑的,WooCommerce 為此準備了一支 wc-schema-parser,而且只有在真的有欄位註冊了 schema rule 時才會載入,沒用到條件欄位的網站不會多下載這支腳本。
伺服器端則是 Store API 收到請求時會重建 document object,把隱藏欄位整個排除在驗證之外,所以「隱藏的必填欄位」不會擋住結帳,這點不用自己處理。
直接說「加台灣電子發票欄位」,AI 大機率給你 woocommerce_checkout_fields 的舊寫法,再配一段 jQuery 顯示隱藏,要它一次到位條件要明確:
用 woocommerce_register_additional_checkout_field 在區塊結帳頁註冊台灣電子發票欄位,location 用 order。發票類型是 select(個人/公司/捐贈),其餘欄位用 hidden 與 required 的 schema rule 做條件顯示,規則要包含 required 與 type object 避免欄位在沒有值時全部展開,不要寫任何 JavaScript。
驗收時盯這幾點:
addEventListener('change') 就是走錯路。required,沒有的話條件全部形同虛設。order 位置的欄位在 checkout.additional_fields。type 是不是只用了 text/select/checkbox,number、email 會安靜地不出現。pattern 屬性等於沒有驗。欄位到這裡就完整了,下一篇我們繼續討論物流串接,也就是自訂 Shipping Method 與超商取貨門市電子地圖。
文章目錄:https://oberonlai.blog/category/2026-ithome/
Hi, 我是 Oberon Lai,十多年前我從一個不懂程式的平面設計師,一頭栽進 WordPress 的世界。從佈景主題到外掛開發,從接案到自研產品,這段旅程讓我深刻理解:好的技術不只是寫出能跑的程式碼,而是真正解決人的問題。
我積極投入參與社群,公開演講紀錄如下:
我專精 WordPress 開發,從企業形象網站的設計與開發、佈景主題客製化,到既有網站的改版升級,提供完整的 WordPress 建置服務。開發面涵蓋外掛開發與維護、區塊編輯器(Gutenberg)客製區塊、ACF 與 Custom Post Type 的資料架構設計,以及 REST API 整合與 Multisite 多站架構建置。同時也協助網站效能改善與 SEO 調校、安全性檢測與強化,並導入自動化部署與版本控制流程,搭配長期的技術顧問與維運支援,讓網站上線後也能穩定運作。
亦提供 WooCommerce 商店的建置與設定,並串接綠界、LINE Pay、藍新等台灣主流金流。可依需求進行結帳頁面客製化、訂單狀態自動化流程設計,以及商品管理與庫存系統的客製開發;也支援 WooCommerce Subscription 定期定額、REST API 應用開發、報表與數據匯出等進階需求。此外,透過購物流程 UX 改善、電商網站效能調校與 HPOS 高效能訂單儲存相容開發,全面提升營運效率,並提供電商營運技術顧問服務。
AI 浪潮席捲而來,我選擇擁抱而非恐懼,我把 AI 融入開發工作流以及客戶的產品中,也持續累積「AI 看不見的部分」:真實踩坑經驗、最新漏洞情報那些只有第一線工程師才看得見的細節,如果你有任何 WordPress 的客製化需求或是 AI 開發相關的問題非常歡迎加入 LINE 官方帳號與我聯繫:
https://page.line.me/vrf7844t?oat_content=url&openQrModal=true
如果想要獲取 AI 開發實戰經驗也能訂閱我的電子報,每週五上午準時出刊:
https://oberonlai.blog/wordpress-newsletter/