線稿索引所有 spec最後更新 2026-07-22
specs/currency-exchange.md

多幣別與匯率

一句話:成本側(地接 / 嚮導報帳 / 內外帳)支援以外幣登記,系統用匯率自動換成台幣——內帳用真實取得成本(外幣帳戶相對匯率 WAC 或當日匯率),外帳用台銀牌告匯率;外幣以「帶幣別的收款帳戶」承載(延用 payment-accounts-reconciliation),換匯是帳戶間轉換。只進成本側,營收側鐵守 TWD。

畫面(UI = wireframe):多帳戶對帳(含換匯 / 各幣別餘額) · 匯率設定 tab · 成本登記 / 內外帳的外幣欄見 internal-external-ledger

模組歸屬:匯率 / 換匯 / 外幣帳戶 WAC 精算屬進階會計模組(受 module_accounting gate);成本以外幣登記的金額留核心掛帳(accounting-module §4)。

1. 目標 / 問題

荒野海外團的真實成本大量發生在外幣(日本地接日幣、印尼盾、未來美金),現行 GS 兩種模式並存:

  • 日本型(pay-as-you-go):地接逐筆以日幣支出,事後逐筆 / 逐日用當日匯率補回台幣(GS「0321熊野古道五日地接明細」:日幣金額 × 當日匯率 = 台幣金額,加總 = 真實地接成本)。
  • 印尼型(預先換匯一池):行前一次大額把台幣換成外幣(GS「0130爪哇雙火山」換匯紀錄:350,000 TWD × 匯率 525 − 手續費 → RP 實拿 182,000,000),持有一個外幣池跨梯次共用;花外幣的真實台幣成本用該池的相對匯率(加權平均成本 WAC)回算。

現行系統金額一律整數 TWD(order-money-model §2「拒非 TWD」),成本表 departure_costs.amount_twd 只能填台幣,逼線控 / 財務在 GS 心算換匯、結果不可稽核(內帳 254,511 vs 外帳 168,942 連經手人都說不清)。本檔讓成本原生記外幣 + 匯率,台幣由系統派生。

設計原則:不寫死「日本型 / 印尼型」。成本列上是通用的「內帳匯率來源」(帳戶 WAC / 當日牌告 / 手填),使用者自由選;荒野的日本、印尼只是兩種選法,其他租戶可任意組合。

範圍:匯率牌告表(平台抓台銀 + 租戶覆蓋)+ 帳戶幣別維度 + 換匯紀錄 + 外幣帳戶相對匯率(WAC)+ 各幣別對帳呈現。成本列的外幣欄與雙率換算在 internal-external-ledger。 不做:營收側多幣別(orders / 收款 / payment_schedules / refunds 一律 TWD,客人付台幣);正式總帳 / 合併報表(未來會計模組);自動串銀行 API 拉換匯實際成交(先人工登記換匯);三大帳戶補款 / 通路 routing / 退款執行對帳(子系統 B,獨立檔 account-settlement;換匯表已併入其統一 account_transfers)。

2. 資料模型

2.1 exchange_rates(牌告匯率表,每租戶 DB · 平台共寫 + 租戶覆蓋)

逐日逐幣別的台銀牌告,一列存台銀 4 報價(現金 / 即期 × 買入 / 賣出),對齊台銀牌告頁與 GS「出團帳務範本 · 填入匯率」工作表(掛牌日期 × 幣別 × 本行買入 / 賣出 × 2)。每個報價 canonical 存「1 外幣單位 = ? 台幣」(numeric)。這是全系統唯一允許非整數的金額相關欄——是匯率不是金額;換算後的台幣一律 round 成整數。

欄位型別說明
idtext PK(xr_<ulid>)主鍵。
rate_datedate NOT NULL掛牌日期(Asia/Taipei)。
currencytext NOT NULLISO 4217(JPY / IDR / USD…)。
cash_buynumeric nullable現金買入(1 外幣 = ? 台幣)。台銀對部分幣別(如 IDR)無現金匯率 → NULL。
cash_sellnumeric nullable現金賣出。
spot_buynumeric nullable即期買入。
spot_sellnumeric nullable即期賣出。成本回補 / 外帳報稅預設取此(§3.1)。
sourcetext NOT NULLbot(台灣銀行牌告,平台每日寫入)/ manual(租戶手動覆蓋 / 自訂)。泛化:來源是欄值、不寫死台銀。
created_at / updated_attimestamptz—

約束:unique (rate_date, currency, source);各報價 > 0(NULL 允許,表該幣別無此報價)。

4 報價的取捨:現金 = 帶現鈔買賣(價差大);即期 = 帳戶電匯買賣(價差小)。成本回補與報稅是「把已花的外幣折回台幣」,性質接近賣出,預設取即期賣出(spot_sell);要改現金 / 買入由 §3.1 解析參數調。顯示可換算成倒數(如 IDR 即期賣出 0.00208 → 480 RP/TWD)。

平台共寫機制:匯率解析跑在 tenant 請求(db: ScopedDb),故 exchange_rates 住每租戶 DB(讀本地零跨庫)。「共用」靠平台層每日排程(control plane cron)掃所有 active 租戶 DB、把台銀當日牌告 upsert 成 source='bot' 列(比照 ECPay webhook 的 cross-DB 列舉模式,見 roadmap Phase 2)。租戶覆蓋寫 source='manual' 同表。不寫死台銀:來源是 source 欄的值,未來租戶可換不同銀行牌告(呼應多租戶開放原則)。

2.2 receiving_accounts.currency(加欄)

payment-accounts-reconciliation §2.1 的收款帳戶目錄加幣別維度——外幣池 = 帶外幣幣別的帳戶,跨梯次共用。

欄位型別說明
currencytext NOT NULL default 'TWD'帳戶幣別(ISO 4217)。既有帳戶皆 TWD;新增外幣帳戶如 印尼盾(印尼帳戶)=IDR、美金(永豐)=USD。
  • 外幣帳戶(currency ≠ 'TWD')才有**相對匯率(WAC)**與外幣餘額(§3.2)。
  • TWD 帳戶行為完全不變。

2.3 換匯紀錄 = account_transfers kind='fx_conversion'

一筆換匯 = 台幣帳戶出錢、外幣帳戶進外幣,在多帳戶對帳頁登記。換匯不是獨立表:併入子系統 B 的統一帳戶間移轉分類帳 account_transfers(account-settlement §2.4)——換匯 = kind='fx_conversion'、外幣期初 = kind='opening'、沖正 = kind='reversal'。該表 SSOT 在 account-settlement;本檔只描述其在多幣別 / WAC 語意下的用法。

換匯相關欄位對應(account-settlement §2.4 表):from_account_id(出台幣帳戶,opening 為 NULL)、amount_twd(換出台幣 / 期初台幣成本基礎)、to_account_id(進外幣帳戶)、currency(= to_account.currency)、foreign_amount(外幣實拿淨額)、gross_rate(牌價 provenance)、fee_foreign(換匯外幣手續費)、occurred_at(換匯日期)。

約束:kind='fx_conversion' ⟹ to_account.currency = currency ≠ 'TWD'、foreign_amount NOT NULL;kind='opening' ⟹ from_account_id IS NULL。

Append-only:account_transfers 是 WAC 與外幣餘額的事實來源,不可 UPDATE / DELETE(DB-level trigger,比照 payment_transactions);改錯開 kind='reversal'(反向對沖)。

3. 邏輯 / 存取層

3.1 匯率解析 resolveExchangeRate(db, currency, date, quote='spot_sell')

回該幣別該日指定報價的 canonical「1 外幣主單位 = ? 台幣」:

  1. 日期優先、manual 只當同日 tiebreaker:取 rate_date <= 目標日 且該報價欄非 NULL 的列,ORDER BY rate_date DESC, (source='manual') DESC, created_at DESC LIMIT 1——同一天租戶 manual 覆蓋壓過 bot 牌告,但一筆舊 manual 不會永久遮蔽其後的每日 bot 牌告(source 不得凌駕日期)。
  2. 該日無列即由上述排序天然 carry-forward 到最近較早日期(週末 / 假日 / 尚未抓到當日)。
  3. 取 quote 對應欄(預設 spot_sell 即期賣出 = 成本回補 / 外帳報稅率;可指定 cash_sell / spot_buy / cash_buy)。該報價為 NULL(台銀無此幣別現金匯率)→ fallback spot_sell。
  4. 仍查無 → 回 null(呼叫端 fail-safe:UI 提示「該幣別當日無匯率,請先補匯率或手填」,不靜默用 0 或 1)。

3.1.1 外幣金額單位:ISO 4217 minor units(成本側)

成本側外幣金額(departure_costs.foreign_amount / expense_proofs 外幣快照)存 ISO 4217 最小單位整數(USD 12.50 = 1250 cents;JPY / IDR / TWD exponent 0,整數即主單位——IDR exp 0 為專案刻意偏差)。單位換算集中在 packages/core/src/currency.ts exponent 模組(純函式、client-safe):currencyExponent / minorToTwd / toMajorUnits / formatForeignAmount / parseForeignInputToMinor(輸入端擋超出該幣別小數位的分數輸入)。台幣派生 = round(minor / 10^exp × rate)(BigInt round-half-up;exp=0 幣別與舊 round(foreign × rate) 逐位元相同)。

交接註記(遷移待辦):accounts 側(account_transfers.foreign_amount / WAC 推導)仍是外幣主單位整數,尚未遷移 minor units——換匯 / 外幣池目前只用 JPY / IDR(exp 0)故數值恰好等價,但引入 exp 2 幣別(USD 池)前必須先做 accounts 側 minor-units 遷移(見 2026-07-22 審查處置結果)。

3.2 外幣帳戶相對匯率(WAC,加權平均成本)

外幣帳戶的「持有外幣 / 已付台幣」加權平均,每次換匯重算、花外幣不變(已用 GS 爪哇 520.64 驗證):

held_foreign  = Σ account_transfers.foreign_amount  (kind ∈ {opening, fx_conversion, reversal},to=該帳戶)
twd_basis     = Σ account_transfers.amount_twd       (同上)
balance_foreign = held_foreign − Σ 外幣成本支出(該帳戶, foreign)        -- 外幣現額
relative_rate (外幣/台幣顯示) = held_foreign / twd_basis               -- 如 520.64 RP/TWD
wac_twd_per_unit             = twd_basis / held_foreign                -- canonical(內帳換算用)
  • 花外幣不改 WAC:成本以外幣支出時,內帳台幣 = round(foreign × wac_twd_per_unit),外幣餘額與剩餘台幣基礎同比例下降,WAC 恆定(庫存加權平均的標準性質)。
  • 快照不漂移:成本列登記當下把 wac_twd_per_unit 快照進該列(internal-external-ledger §2 的 internal_rate);之後再換匯改變 WAC 不回頭改既有成本列(庫存「發出時點均價」語意)。
  • 期初跨期自動結轉(純派生,不落列):外幣帳戶某月的期初 / 期末 WAC 基礎由 computeForeignAccountPeriodMovement(db, account, period) 派生——期初 = occurred_at < 本月月初的累計 WAC 基礎、期末 = < 次月月初。因 account_transfers append-only 且累計,本期期初恆等於上期期末(相同 cutoff 對同一 immutable 表求和),故不再需要人工開 kind='opening' 列、也不寫入任何合成 opening 列(比照 settlement 跨期結轉「只入顯示不寫入」,防雙重計入)。租戶若仍手動開 opening 列,與此派生天然共存不雙計(派生只是對含該 opening 列在內的同批移轉求和的視圖)。WAC 基礎純由帳戶間移轉決定(不含成本支出扣減;花外幣不改 WAC)。
  • 純讀派生(packages/core helper,比照對帳 / money helper 不 inline 算式);外幣帳戶餘額與 GS 手填現額對不上時,由人工核對(系統不假設真值,沿用對帳頁慣例)。

3.3 成本的內帳匯率來源(通用,不寫死團型)

成本列(internal-external-ledger §2.1 的 departure_costs / expense_proofs)登記外幣時,內帳匯率來源三選一(internal_rate_source):

internal_rate_source內帳 internal_rate 解析對應 GS 用法
fx_account取 fx_account_id 外幣帳戶當下 WAC(§3.2)快照。印尼(從預換池支出)
published取 resolveExchangeRate(currency, 成本日) 台銀牌告快照。日本(pay-as-you-go,預設)
manual使用者手填實際成交率(刷卡 / 現鈔換匯與牌告有落差時)。任意
  • 外帳 external_rate 一律 resolveExchangeRate(currency, 成本日)(台銀牌告)快照,與內帳來源無關。
  • 三者皆在登記 / 編輯當下解析並快照進成本列;台幣派生 amount_twd / invoice_amount_twd 見 internal-external-ledger §3。
  • TWD 成本(currency='TWD')不走本機制,amount_twd 直接填、rate 欄留空(零行為改變)。

4. UI 與權限

4.1 版面

  • 匯率管理頁(獨立 nav)(/admin/exchange-rates):逐月逐日逐幣別的台銀牌告表(掛牌日期 × 4 報價:現金買入 / 賣出、即期買入 / 賣出),幣別 + 月份切換;bot 唯讀、manual 覆蓋 / 新增(橘底標示、解析優先)。版型對照 GS「出團帳務範本 · 填入匯率」。獨立一頁、非 settings tab(資料量大、需月曆式逐日瀏覽)。
  • 多帳戶對帳頁(/admin/reconciliation)擴充:
    • 各幣別總覽(= 各幣別狀態 Dashboard,不另開頁):每外幣帳戶一張卡 —— 外幣餘額、相對匯率(WAC)、台幣成本基礎、近期換匯;TWD 帳戶維持原 in/out/淨額。
    • 換匯紀錄:列表 + 新增 dialog(出帳台幣帳戶 / 台幣額 → 進帳外幣帳戶 / 外幣實拿 / 牌價 / 手續費 / 日期);沖正走反向列。
  • 收款帳戶目錄維護(含 currency 欄、新增外幣帳戶)移至 設定 · 收款帳戶(租戶配置,與 ECPay 等整合設定同層;對帳頁右上「管理收款帳戶」link 過去)。見 payment-accounts-reconciliation §4。
  • 成本登記 / 內外帳 / 嚮導 portal 的外幣輸入見 internal-external-ledger / guide-portal。

4.2 權限

action用途opadminaccountantguide
fx_exchange.read看換匯紀錄 / 外幣帳戶餘額 / WAC(對帳頁)✅✅✅❌
fx_exchange.manage登記 / 沖正換匯❌✅✅❌
  • 匯率牌告 覆蓋 走設定頁既有 role.manage(admin),不另開 resource;台銀 bot 列由平台 cron 寫、租戶不可改(只能加 manual 覆蓋)。
  • 換匯涉及資金,manage 鎖 admin / accountant;op 可讀(對帳頁與成本登記需看 WAC)。op 是否可登記換匯見 §5。
  • 資源收斂(2026-06-25):換匯 = account_transfers kind='fx_conversion',權限收斂為單一 account_transfer.read/manage(account-settlement §4.3)——同一張表只一套權限,上表 fx_exchange.* 即 account_transfer.* 的舊名,不另立資源。
  • 成本以外幣登記走既有 departure_cost.create(internal-external-ledger §4.2),不在本 resource。

5. 開放議題

  • 台銀報價別:已落 4 報價(現金 / 即期 × 買入 / 賣出),預設取即期賣出;外帳報稅是否要「月平均」或「現金賣出」待會計確認,解析參數 quote 已預留可調。
  • 外幣帳戶子持有人 / 現金 vs 銀行:GS 爪哇把外幣池再拆 翰昇 / 小御 × 現金 / 銀行;第一版以單一外幣帳戶涵蓋,子持有人 / 現金銀行細分留 follow-up(可開多個外幣帳戶或加子維度)。
  • op 是否可登記換匯:第一版鎖 admin / accountant;線控若需現場登記換匯再放寬。
  • 期初持有的台幣成本基礎:跨期自動結轉已落地(§3.2 computeForeignAccountPeriodMovement 純派生:本期期初 = 上期期末,由 append-only account_transfers 累計和天然結轉,不寫合成列)。第一期(系統導入前既有餘額)仍以 kind='opening'(account_transfers)人工填期初外幣 + 對應台幣基礎(GS 爪哇起始 56,462,731 RP @ 522.74)當種子;其後各期期初自動派生、與手動 opening 列共存不雙計。
  • 平台抓台銀失敗 / 來源變更:cron 抓不到當日(台銀未更新 / 假日)走 carry-forward;台銀牌告頁改版的解析維護屬平台維運。
  • 與成本範本:cost-template 外幣範本行已落地——範本行即帶幣別 + 外幣單價 + 內帳匯率來源(fx_account / published,範本層不支援 manual),套用當下解析雙率快照進 departure_costs(同本檔 §3.2 庫存均價不漂移語意)。