一句話:訂單的代收轉付收據開立 / 待開觸發 / 折讓 / 作廢 / 查回 / 報稅 export 整進後台作業流程;provider 中立(travelinvoice.com.tw 真簽章 + test mock,env flag 切換),憑證 per-tenant KMS 加密、無平台層 fallback(fail-closed),收據掛在訂單上但不另建金流模型(金額真相仍在 order-money-model)。
荒野(與未來租戶)過去用 Google Sheet(0304代轉 分頁)人工湊代收轉付收據資料、再到平台批次上傳,並手動追「申報註記(0 未申報 / 1 已申報)」與「代轉缺件」。本子系統讓 ERP:
0304代轉)。不做:抽籤、毛利總帳、多幣別、跨公司合併(見 control-finance 的「不在本檔重做」)。開立所需的「真實成本 / 內外帳」金額由 control-finance 提供,本檔只負責「把收據送出去 / 查回來」。
第三方合作方代收代付(pass-through,GS 露營區代收款匯回 / 代退款):替合作方(露營區等)代收的款項屬代收轉付 pass-through(非旅行社收入),收據開立沿用本檔機制(代收轉付收據涵蓋商業合作方代收,不限標準行程項);匯回合作方走 payables 出款 / account-settlement 帳戶間移轉(GS 永豐→台新 186,982),代退款走 refunds 標 pass-through。「不入損益營收」這條界線見 operating-expenses §3.5。
Schema 真相:packages/core/src/schema/business.ts(taxReceipts / taxReceiptAllowances / taxReceiptInvalidations)。
每家旅行社在 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 憑證同一套加密基建。last_error),不丟例外炸流程。tax_receipts每筆代收轉付收據一列。狀態欄前進(pending → issued → allowance / 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)。 |
category | B2B(開統編三聯)/ 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 清空)。 |
status | pending(待開 / 缺件 / hold)→ issued → allowance(部分折讓)/ 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)。
無憑證、欄位不齊(B2B 缺統編)、明細加總 ≠ 總額、API 失敗者不靜默丟棄:停在 status='pending' + last_error,列在 /admin/travelinvoice 缺件面板待補重送(對應試算表「代轉缺件」)。hold(待開)模式平台受理未出號時也停 pending,但 last_error 為空(等 touchIssue 觸發,非錯誤)。
TaxReceiptProvider 介面(packages/core/src/invoice/types.ts):issue / touchIssue / allowance / invalid / search。兩個實作:
TravelinvoiceProvider(invoice/travelinvoice.ts):真平台。組內層參數 → PostData_(AES 加密 bin2hex)→ POST {MerchantID_, PostData_};回應 decrypt + 驗 CheckCode → normalize。TestTaxReceiptProvider(invoice/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_issue(Status=1 立即 / 2 待開) |
| 觸發待開 | /invoice_touch_issue |
| 折讓 | /allowance_issue |
| 作廢 | /invoice_invalid |
| 編輯 | /invoice_edit |
| 查回 | /invoice_search(SearchType 1 開立 / 2 折讓 / 3 作廢) |
resolveTravelinvoiceCredentials(db)(invoice/credentials.ts):tenant_settings 讀 travelinvoice_merchant_id + 解密 travelinvoice_secrets_enc → 60s per-tenant cache(比照 resolveEcpayCredentials)→ 憑證欄不齊(只有其一)視同未設定,回 null(fail-closed,不 fallback env)。settings 儲存時 invalidateTravelinvoiceCredentialsCache(dbName) 失效快取。
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_error。mode='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)。syncReceiptDeclaration:逐筆(後台 row action 觸發)search 平台 → 回寫 invoice_number / declared_status / last_synced_at。平台狀態與本地不符(平台已作廢本地仍 issued、平台已折讓本地 issued)→ 記 tax_receipt.sync_mismatch audit,不覆寫本地狀態(由人工判)。排程批次 sweep 為 future(見 roadmap)。
GET /admin/travelinvoice/export(invoice.export 閘):CSV(BOM + CRLF),欄位對齊試算表 0304代轉——買受人名稱 / 統編 / 地址 / Email / 手機 / 經辦 / 收據總額 / 自訂編號 / 收據號 / 團名 / 團號 / 出團日 / 申報註記 / 商品明細;只納已開立(issued / allowance)收據,支援 from / to / declared filter。供會計交外部事務所,非送件路徑(送件走 §3.1 API)。
/admin/travelinvoice(invoice.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)。invoice(packages/core/src/permissions-ac.ts catalog)。| 角色 | read | issue / allowance / invalid | export | settings 憑證 |
|---|---|---|---|---|
admin | ✅ | ✅ | ✅ | ✅ |
accountant | ✅ | ✅ | ✅ | ❌ |
op | ✅ | ❌ | ✅ | ❌ |
sales | ✅ | ❌ | ❌ | ❌ |
customer / guide | ❌ | ❌ | ❌ | ❌ |
infra/。tenant_settings 切「自動 / 待審」)、receivable_reduction 退款核准自動折讓、取消自動作廢——現全為後台手動;接線時沿用 §3.3 既有服務層函式。/api/jobs/expire-atm 的 token-gated per-tenant sweep 批次 search;現為逐筆手動。invoice resource 尚未鏡像到 /api/v1 與 @toko-cloud/cli。total_amt_twd 取自訂單應收,與 control-finance 的「外帳可申報發票額」是否需一致性校驗(避免開立額 ≠ 申報成本)?| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 1 | 2026-06-20 | 初版(草案):per-tenant 憑證 + provider 結構比照 ECPay、收據生命週期掛金流事件、申報回寫、報稅 export。未實作。 | — |
| 2 | 2026-06-20 | 對齊金流模型:折讓只由 refund_kind='receivable_reduction' 觸發(overpayment_return 退溢收現金不開折讓);收據開立基準定為全額付清(payment_state='paid',非訂金階段)。 | — |
| 3 | 2026-07-07 | Code-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 |