線稿索引所有 spec最後更新 2026-07-07
specs/travelinvoice-integration.md

代收轉付電子收據整合

一句話:訂單的代收轉付收據開立 / 待開觸發 / 折讓 / 作廢 / 查回 / 報稅 export 整進後台作業流程;provider 中立(travelinvoice.com.tw 真簽章 + test mock,env flag 切換),憑證 per-tenant KMS 加密、無平台層 fallback(fail-closed),收據掛在訂單上但不另建金流模型(金額真相仍在 order-money-model)。

1. 目標 / 問題

荒野(與未來租戶)過去用 Google Sheet(0304代轉 分頁)人工湊代收轉付收據資料、再到平台批次上傳,並手動追「申報註記(0 未申報 / 1 已申報)」與「代轉缺件」。本子系統讓 ERP:

  • 已付清訂單由後台開立代收轉付收據(立即出號或「待開」再觸發);
  • 部分退款開折讓單;錯開收據作廢(作廢後可重開);
  • 逐筆查回收據狀態,回寫申報狀態與收據號;
  • 產出給外部會計師事務所報稅的總表 CSV(欄位對齊 0304代轉)。

不做:抽籤、毛利總帳、多幣別、跨公司合併(見 control-finance 的「不在本檔重做」)。開立所需的「真實成本 / 內外帳」金額由 control-finance 提供,本檔只負責「把收據送出去 / 查回來」。

第三方合作方代收代付(pass-through,GS 露營區代收款匯回 / 代退款):替合作方(露營區等)代收的款項屬代收轉付 pass-through(非旅行社收入),收據開立沿用本檔機制(代收轉付收據涵蓋商業合作方代收,不限標準行程項);匯回合作方payables 出款 / account-settlement 帳戶間移轉(GS 永豐→台新 186,982),代退款走 refunds 標 pass-through。「不入損益營收」這條界線見 operating-expenses §3.5

2. 資料模型

Schema 真相:packages/core/src/schema/business.tstaxReceipts / taxReceiptAllowances / taxReceiptInvalidations)。

2.1 租戶憑證(per-tenant,KMS 加密)

每家旅行社在 travelinvoice 有自己的 MerchantID + HashKey + HashIV,存 tenant_settings

  • travelinvoice_merchant_id 明文;travelinvoice_secrets_enc{hashKey, hashIv} JSON 的 KMS envelope 密文(EncryptedField,AAD 綁 db_name),write-only、UI 只回遮罩——與 travelers PII / ECPay 憑證同一套加密基建。
  • 無平台層 env fallback(與 ECPay 的 staging 過渡 fallback 不同):代收轉付一律 per-tenant,無憑證即 fail-closed——開立落缺件(pending + last_error),不丟例外炸流程。

2.2 收據紀錄 tax_receipts

每筆代收轉付收據一列。狀態欄前進(pendingissuedallowance / invalid),折讓 / 作廢各掛子紀錄,不覆寫原收據列;DELETE 不設 trigger,但 order_id FK 用 restrict(收據是法定憑證,訂單不可硬刪帶走)。多租戶開放性provider 欄('travelinvoice' | 'test')+ provider_payload JSONB(回執原文),欄位名不綁單一平台。

欄位說明
order_id對應訂單(同庫 FK,restrict)。
merchant_order_no送平台的自訂編號(MerchantOrderNo),每租戶唯一;由 next_merchant_order_no() sequence 鑄號(比照 next_order_number()),同號重送 = 平台冪等語意。
provider開立收據的平台(開立當下的 TAX_RECEIPT_PROVIDER)。
categoryB2B(開統編三聯)/ B2C(二聯);開立 Dialog 人工選,B2B 必填 buyer_ubn
buyer_name / buyer_ubn / buyer_address / buyer_email / buyer_phone買受人;buyer_ubn 僅 B2B。
total_amt_twd收據總額(整數 TWD,CHECK > 0)。
item_snapshot開立當下的商品明細快照(JSONB 陣列 {name, count, unit, price, amount});order_lines 事後變動不影響已開立收據。
tour_name / tour_no / tour_date旅遊專屬欄,由訂單 / 梯次帶入(tour_no = 團號)。
invoice_number / invoice_trans_no / random_num / display_url平台回傳:收據號 / 交易號 / 隨機碼 / 查詢連結(作廢時 display_url 清空)。
statuspending(待開 / 缺件 / hold)→ issuedallowance(部分折讓)/ invalid(作廢);CHECK 約束 + assertReceiptTransition 狀態機雙層守。
declared_status申報狀態 undeclared / declared,由 §3.4 查回回寫(對應試算表「申報註記」)。
provider_payload平台回執原文(normalize 前)。
last_error缺件 / 失敗原因(無憑證、缺統編、明細金額不一致、API 失敗)。非 NULL = 待補後重送。
last_synced_at最近一次查回時間。
created_by_user_id觸發開立的 actor。

折讓 / 作廢子紀錄:tax_receipt_allowances(原收據號快照 + 折讓金額 + 回傳折讓號 + status/last_error)、tax_receipt_invalidations(原收據號 + reason + status/last_error),皆引 tax_receipt_id(restrict)。

2.3 缺件語意

無憑證、欄位不齊(B2B 缺統編)、明細加總 ≠ 總額、API 失敗者不靜默丟棄:停在 status='pending' + last_error,列在 /admin/travelinvoice 缺件面板待補重送(對應試算表「代轉缺件」)。hold(待開)模式平台受理未出號時也停 pending,但 last_error 為空(等 touchIssue 觸發,非錯誤)。

3. 邏輯 / 存取層

3.1 Provider 抽象(provider 中立 + 純函式簽章)

TaxReceiptProvider 介面(packages/core/src/invoice/types.ts):issue / touchIssue / allowance / invalid / search。兩個實作:

  • TravelinvoiceProviderinvoice/travelinvoice.ts):真平台。組內層參數 → PostData_(AES 加密 bin2hex)→ POST {MerchantID_, PostData_};回應 decrypt + 驗 CheckCode → normalize。
  • TestTaxReceiptProviderinvoice/test.ts):本地 mock(比照 payment 的 test provider)。不打外網,收據號由 merchantOrderNo 確定性派生(同號同結果,冪等語意與真平台一致);嚴格供 dev / e2e / 單測。

切換走 TAX_RECEIPT_PROVIDER env(invoice/flags.ts,比照 PAYMENT_PROVIDER):travelinvoice 才打真平台,未設或未知值一律降級 test(fail-safe:寧可本地 mock,不誤開真收據)

簽章 / 加解密在 invoice/travelinvoice-shared.ts純函式、creds 參數注入、不讀全域 env(比照 ecpay-shared.ts):

  • PostData_ 加密:參數組 query string(URLSearchParams)→ AES-256-CBC(key=HashKey 32 byte、iv=HashIV 16 byte、PKCS#7)→ bin2hex。key/iv 長度錯直接拋(fail-closed 不靜默截斷)。
  • CheckCode 整合驗證碼:指定欄位 ksort 後組 HashIV={iv}&{params}&HashKey={key},取 SHA256 大寫(注意 HashIV 在前——與 ECPay CheckMacValue 的 HashKey 在前相反)。驗回應用 constant-time 比對(timingSafeEqual);開立回應帶 CheckCode 驗不過視為失敗(防偽造回執)。

端點(staging capi. / prod api.TRAVELINVOICE_IS_STAGING === 'true' 切換、未設 = production fail-closed):

動作端點
開立/invoice_issueStatus=1 立即 / 2 待開)
觸發待開/invoice_touch_issue
折讓/allowance_issue
作廢/invoice_invalid
編輯/invoice_edit
查回/invoice_search(SearchType 1 開立 / 2 折讓 / 3 作廢)

3.2 憑證解析

resolveTravelinvoiceCredentials(db)invoice/credentials.ts):tenant_settingstravelinvoice_merchant_id + 解密 travelinvoice_secrets_enc → 60s per-tenant cache(比照 resolveEcpayCredentials)→ 憑證欄不齊(只有其一)視同未設定,回 null(fail-closed,不 fallback env)。settings 儲存時 invalidateTravelinvoiceCredentialsCache(dbName) 失效快取。

3.3 收據服務層(tax-receipts/repo.ts

生命週期為後台待審制(operator-driven):收據動作由 /admin/travelinvoice 人工觸發,不自動掛金流事件(自動開立 / 自動折讓為 future,狀態見 roadmap)。開立基準 = 全額付清:開立 Dialog 的訂單來源 listReceiptEligibleOrders 只列 payment_state='paid'尚無有效收據的訂單(invalid 不算有效——錯開作廢後可重開;pending/issued/allowance 皆視為已有);預帶買受人 / 應收總額(Σ order_charge_lines)/ 團名團號出團日(lodging 訂單團欄為 null 仍可開)。

  • issueReceipt 三段式:(1) 同 tx 鑄號 + 建 pending 列 + audit;(2) fail-closed 檢核(買受人 / B2B 統編 / 明細加總 = 總額)→ 失敗停 pending 記 last_error;(3) 送平台 → 成功轉 issued 回寫回傳欄,失敗停 pending 記 last_errormode='hold' 平台受理未出號 → 停 pending 等 touchIssueReceipt
  • issueAllowance:折讓(掛 tax_receipt_allowances 子紀錄,原收據 status → 'allowance')。金流語意:對應退款減應收refund_kind='receivable_reduction');overpayment_return 退溢收現金不開折讓(原收據金額未變)。
  • invalidateReceipt:作廢(tax_receipt_invalidations 子紀錄 + 原收據 status → 'invalid'、清 display_url),reason 必填。
  • 狀態機assertReceiptTransition 守全部動作(如 invalid 後不可再折讓);與 DB CHECK 雙層。
  • 每個動作寫 audit_log(target type tax_receipt;action tax_receipt.create/issue/hold/allowance/invalid/sync_mismatch)。

3.4 申報狀態查回

syncReceiptDeclaration:逐筆(後台 row action 觸發)search 平台 → 回寫 invoice_number / declared_status / last_synced_at。平台狀態與本地不符(平台已作廢本地仍 issued、平台已折讓本地 issued)→ 記 tax_receipt.sync_mismatch audit,不覆寫本地狀態(由人工判)。排程批次 sweep 為 future(見 roadmap)。

3.5 報稅總表 export

GET /admin/travelinvoice/exportinvoice.export 閘):CSV(BOM + CRLF),欄位對齊試算表 0304代轉——買受人名稱 / 統編 / 地址 / Email / 手機 / 經辦 / 收據總額 / 自訂編號 / 收據號 / 團名 / 團號 / 出團日 / 申報註記 / 商品明細;只納已開立(issued / allowance)收據,支援 from / to / declared filter。供會計交外部事務所,非送件路徑(送件走 §3.1 API)。

4. UI 與權限

  • /admin/travelinvoiceinvoice.read 閘):KPI 統計 + 收據列表(tab:全部 / 已開立 / 待處理 / 未申報)+ 開立 Dialog(訂單 Combobox 選 §3.3 eligible 訂單、B2B/B2C 選擇、明細編輯)+ row actions(觸發待開 / 折讓 / 作廢 / 查回)+ 缺件面板(pending + last_error 待補重送)。
  • 設定/admin/settings 付款 tab 內的代收轉付憑證卡invoice.settings,admin-only)填 MerchantID + HashKey / HashIV(write-only 遮罩,比照 ECPay 憑證 UI、與其同 tab)。
  • 權限:resource invoicepackages/core/src/permissions-ac.ts catalog)。
角色readissue / allowance / invalidexportsettings 憑證
admin
accountant
op
sales
customer / guide

5. 開放議題

  • 固定出口 IP(live 串接前置,上線收尾):travelinvoice 要求註冊伺服器 IP 白名單,Cloud Run 預設出口 IP 浮動 → 需 static egress IP / Cloud NAT 並報平台。這是 ECPay 沒有的新基建,進 infra/
  • 金流事件自動掛鉤(future):付清自動開立(或自動建 pending 待審,tenant_settings 切「自動 / 待審」)、receivable_reduction 退款核准自動折讓、取消自動作廢——現全為後台手動;接線時沿用 §3.3 既有服務層函式。
  • 申報查回排程(future):比照 /api/jobs/expire-atm 的 token-gated per-tenant sweep 批次 search;現為逐筆手動。
  • api/v1 + CLI parity(future)invoice resource 尚未鏡像到 /api/v1@toko-cloud/cli
  • 與內外帳的金額對應:收據 total_amt_twd 取自訂單應收,與 control-finance 的「外帳可申報發票額」是否需一致性校驗(避免開立額 ≠ 申報成本)?
  • 折讓的品項顆粒度:現折讓明細為單列快照(金額 = 折讓額);是否需對應原收據品項逐項拆。

Changelog

版本日期變更Commit
12026-06-20初版(草案):per-tenant 憑證 + provider 結構比照 ECPay、收據生命週期掛金流事件、申報回寫、報稅 export。未實作。
22026-06-20對齊金流模型:折讓只由 refund_kind='receivable_reduction' 觸發(overpayment_return 退溢收現金不開折讓);收據開立基準定為全額付清(payment_state='paid',非訂金階段)。
32026-07-07Code-Is-Truth 全面對齊 2026-07-04 落地實作(migration 0050):provider 中立抽象(travelinvoice 真簽章+test mock、TAX_RECEIPT_PROVIDER fail-safe 降級)、tax_receipts 三表實際欄位與雙層狀態機、憑證欄位定名(travelinvoice_merchant_id/travelinvoice_secrets_enc)、生命週期定為後台待審制(/admin/travelinvoice,非自動掛金流事件)、逐筆查回+sync_mismatch 語意、報稅 CSV 欄位;自動掛鉤 / 排程查回 / api-v1 parity 移入開放議題。a0ec35a2 / d7119539