線稿索引所有 spec最後更新 2026-07-04
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-onlyaccount_transfers 是 WAC 與外幣餘額的事實來源,不可 UPDATE / DELETE(DB-level trigger,比照 payment_transactions);改錯開 kind='reversal'(反向對沖)。

3. 邏輯 / 存取層

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

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

  1. 優先 source='manual'(租戶覆蓋),無則 source='bot'(台銀)。
  2. 該日無列 → 沿用最近一個較早日期的同來源列(週末 / 假日 / 尚未抓到當日時 carry-forward)。
  3. quote 對應欄(預設 spot_sell 即期賣出 = 成本回補 / 外帳報稅率;可指定 cash_sell / spot_buy / cash_buy)。該報價為 NULL(台銀無此幣別現金匯率)→ fallback spot_sell
  4. 仍查無 → 回 null(呼叫端 fail-safe:UI 提示「該幣別當日無匯率,請先補匯率或手填」,不靜默用 0 或 1)。

TWD = round(foreign_amount × rate)(一律 round 成整數;金額不浮點)。

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 §2internal_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.1departure_costs / expense_proofs)登記外幣時,內帳匯率來源三選一(internal_rate_source):

internal_rate_source內帳 internal_rate 解析對應 GS 用法
fx_accountfx_account_id 外幣帳戶當下 WAC(§3.2)快照。印尼(從預換池支出)
publishedresolveExchangeRate(currency, 成本日) 台銀牌告快照。日本(pay-as-you-go,預設)
manual使用者手填實際成交率(刷卡 / 現鈔換匯與牌告有落差時)。任意
  • 外帳 external_rate 一律 resolveExchangeRate(currency, 成本日)(台銀牌告)快照,與內帳來源無關。
  • 三者皆在登記 / 編輯當下解析並快照進成本列;台幣派生 amount_twd / invoice_amount_twdinternal-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 覆蓋)。
  • 換匯涉及資金,manageadmin / accountantop 可讀(對帳頁與成本登記需看 WAC)。op 是否可登記換匯見 §5。
  • 資源收斂(2026-06-25):換匯 = account_transfers kind='fx_conversion',權限收斂為單一 account_transfer.read/manageaccount-settlement §4.3)——同一張表只一套權限,上表 fx_exchange.*account_transfer.* 的舊名,不另立資源。
  • 成本以外幣登記走既有 departure_cost.createinternal-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 庫存均價不漂移語意)。

Changelog

版本日期變更Commit
62026-07-04G2-3 外幣帳戶期初跨期自動結轉(§3.2 / §5):新增 computeForeignAccountWacAsOf + computeForeignAccountPeriodMovement 純派生期初 / 期末 WAC 基礎(occurred_at cutoff),本期期初 = 上期期末由 append-only account_transfers 累計和天然結轉,不寫合成 opening 列(防雙計,比照 settlement 只入顯示)。手動 opening 列與自動派生共存不雙計;第一期仍以人工 opening 列種子。
52026-07-03§5 末條更新:cost-template 外幣範本行落地(範本行帶幣別 + 外幣單價 + 內帳來源 fx_account/published,套用當下解析雙率快照,重算沿用快照不漂移;範本層不支援 manual)。匯率解析 / WAC / round 機制不變。
42026-07-02F2 全鏈落地 + 台銀牌告每日 cronexchange_rates(台銀四報價 + 租戶 manual 覆蓋)+ 換匯 account_transfers fx kind(append-only + 沖正)+ 外幣帳戶 WAC + departure_costs/expense_proofs 外幣雙率派生 + 對帳頁各幣別總覽 + 匯率獨立頁 /admin/exchange-rates + fx_exchange(=account_transfer)權限全部落地(e2e 35)。平台每日抓台銀 cron 落地:parseBotRatesCsv 純函式 + syncBotRates per-tenant upsert + /api/jobs/sync-bot-rates route(X-Job-Token 閘、parse fail → 502 零租戶半套)+ Cloud Scheduler 30 10 * * * Asia/Taipei;parser 經 live 台銀 CSV 樣本核實(cash/spot = col 2/3/12/13)+ 欄位漂移 label 守衛(NO_ROWS fail-closed,絕不靜默寫錯值)+ 讀取端 carry-forward(假日/未更新沿用最近牌告)。
12026-06-24初版:成本側多幣別 + 匯率管理。exchange_rates(每租戶 DB·平台每日寫台銀 bot + 租戶 manual 覆蓋,canonical twd_per_unit)+ receiving_accounts.currency(外幣池=帶幣別帳戶,跨梯次共用)+ fx_exchanges(換匯紀錄,append-only + 沖正)+ 外幣帳戶相對匯率 WAC(持有外幣/已付台幣,花外幣不變、登記快照不漂移)。成本內帳匯率來源通用三選一(fx_account WAC / published 台銀 / manual 手填,不寫死團型),外帳一律台銀牌告。各幣別 Dashboard = 多帳戶對帳頁擴充。新 resource fx_exchange(read op/admin/accountant、manage admin/accountant)。營收側鐵守 TWD。配合 internal-external-ledger 成本外幣欄與雙率換算。未實作(spec/wireframe 先行)。
32026-06-25換匯表併入子系統 B 的統一 account_transfers(新檔 account-settlement):§2.3 原獨立 fx_exchanges 表移除,改為 account_transfers kind ∈ {fx_conversion, opening, reversal}(換匯 / 期初 / 沖正),SSOT 移至 account-settlement §2.4;§3.2 WAC 推導欄名同步(amount_twd / foreign_amount,kind 篩選)。§1「不做」的三大帳戶補款 / routing / 退款執行對帳改指向已落 spec 的 account-settlement。語意 / WAC 機制不變。未實作。
22026-06-25§2.1 牌告改台銀 4 報價cash_buy/cash_sell/spot_buy/spot_sell,對齊 GS「填入匯率」掛牌日期×幣別×本行買入賣出×2;移除 twd_per_unit/quote_kind 單值);成本回補/外帳報稅預設取 spot_sell(§3.1 resolveExchangeRatequote 參數)。§4.1 匯率改獨立 nav 頁 /admin/exchange-rates(月曆式逐日、幣別/月份切換,非 settings tab)。收款帳戶目錄維護移至設定payment-accounts-reconciliation §4)。日幣無池(pay-as-you-go、按支出當日匯率回補,published)——外幣池只有事先大額換匯的幣別(印尼盾)。wireframe 已對齊。未實作。