帳務視角的多幣別問題
電商收外幣的流程通常是:
用戶下單(USD 100)→ 建訂單(匯率 30.5,折合 TWD 3,050)
↓
幾天後收款實際入帳(當天匯率 30.2)
↓
入帳金額 TWD 3,020,比訂單少了 TWD 30
這 TWD 30 是匯兌損失,財務上要記,稅務上要申報。後端如果沒有設計好,帳對不上是家常便飯。
三個根本問題:
- Invoice rate:建單時用哪個匯率、這個 rate 要 snapshot 存下來
- Settlement rate:實際收付款時的匯率(與 invoice rate 的差 = 匯兌損益)
- Display rate:首頁「約 NT$ 1,500」的即時估算,不進帳本
這三個匯率用途完全不同,混在一起就出事。
資料架構
匯率表(exchange_rates)
CREATE TABLE exchange_rates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
from_currency VARCHAR(3) NOT NULL, -- 'USD'
to_currency VARCHAR(3) NOT NULL, -- 'TWD'
rate NUMERIC(18, 6) NOT NULL, -- 30.520000(用 NUMERIC,不用 FLOAT)
rate_type VARCHAR(20) NOT NULL DEFAULT 'mid', -- 'mid' / 'buy' / 'sell'
source VARCHAR(50) NOT NULL, -- 'ecb' / 'open_exchange_rates' / 'manual'
effective_at TIMESTAMPTZ NOT NULL, -- 這個匯率的有效時間
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (from_currency, to_currency, rate_type, effective_at)
);
CREATE INDEX idx_exchange_rates_pair_time
ON exchange_rates(from_currency, to_currency, effective_at DESC);為什麼用 NUMERIC 不用 FLOAT:
// float 的精度問題在匯率乘法時會放大
const rate = 30.52;
const amount = 100.00; // USD
console.log(rate * amount); // 3052.0000000000005,不是 3052
// NUMERIC 在 DB 存精確值,應用層用 Decimal.js 計算
import Decimal from 'decimal.js';
const result = new Decimal('30.52').mul('100.00'); // '3052.00',精確訂單記錄 snapshot rate
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
-- 原始金額(永遠存,永遠不改)
amount INTEGER NOT NULL, -- 最小貨幣單位(分)
currency VARCHAR(3) NOT NULL, -- 'USD'
-- 建單時的 snapshot rate(一旦建立就不更新)
base_currency VARCHAR(3) NOT NULL DEFAULT 'TWD', -- 本位幣
exchange_rate NUMERIC(18, 6), -- 30.520000(建單時的匯率)
base_amount INTEGER, -- 按 snapshot rate 換算的本位幣金額
-- 實際收款(settlement)
-- 注意:settled_currency 可能和 base_currency 不同
-- Stripe / PayPal 的結算幣別取決於商戶帳戶設定
settled_amount INTEGER, -- 實際入帳金額(settled_currency 的最小單位)
settled_currency VARCHAR(3), -- 實際入帳幣別('TWD' / 'USD',可能和 base_currency 不同)
settled_at TIMESTAMPTZ,
settlement_rate NUMERIC(18, 6), -- 收款時市場匯率(mid rate)
gateway_rate NUMERIC(18, 6), -- Gateway 實際使用的匯率(含 FX markup)
gateway_fee INTEGER, -- Gateway 收取的 FX 手續費(最小單位)
-- 匯兌損益(只在 settled_currency = base_currency 時可以直接算)
-- 跨幣別需先將 settled_amount 換算回 base_currency 再相減
fx_gain_loss INTEGER, -- 正 = 匯兌利益,負 = 損失
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);建單時 snapshot:
async function createOrder(userId: string, amountUsd: number) {
// 拿建單時的匯率(mid rate)
const rate = await exchangeRateService.getRate('USD', 'TWD', 'mid');
const amountInCents = Math.round(amountUsd * 100); // USD 分
const baseAmountInCents = new Decimal(amountInCents)
.mul(rate.rate)
.toDecimalPlaces(0, Decimal.ROUND_HALF_UP)
.toNumber();
return Order.create({
userId,
amount: amountInCents,
currency: 'USD',
baseCurrency: 'TWD',
exchangeRate: rate.rate, // snapshot,永遠不改
baseAmount: baseAmountInCents, // snapshot,永遠不改
});
}收款時記錄損益:
interface SettlementPayload {
settledAmount: number; // 實際入帳金額(最小單位)
settledCurrency: string; // 實際入帳幣別(可能和 base_currency 不同)
gatewayRate?: number; // Gateway 使用的匯率(從 webhook 拿)
gatewayFee?: number; // Gateway 手續費
}
async function settleOrder(orderId: string, payload: SettlementPayload) {
const order = await Order.findByPk(orderId);
const marketRate = await exchangeRateService.getRate(order.currency, order.baseCurrency, 'mid');
// 如果結算幣別和本位幣不同,先換算成本位幣再計算損益
let baseEquivalent: number;
if (payload.settledCurrency === order.baseCurrency) {
baseEquivalent = payload.settledAmount;
} else {
const crossRate = await exchangeRateService.getRate(payload.settledCurrency, order.baseCurrency, 'mid');
baseEquivalent = new Decimal(payload.settledAmount)
.mul(crossRate.rate)
.toDecimalPlaces(0, Decimal.ROUND_HALF_UP)
.toNumber();
}
const fxGainLoss = baseEquivalent - order.baseAmount;
await order.update({
settledAmount: payload.settledAmount,
settledCurrency: payload.settledCurrency,
settledAt: new Date(),
settlementRate: marketRate.rate,
gatewayRate: payload.gatewayRate,
gatewayFee: payload.gatewayFee,
fxGainLoss,
});
}匯率 API 選型與 Cache 策略
選型
| 來源 | 費用 | 更新頻率 | 適合 |
|---|---|---|---|
| ECB(歐洲央行) | 免費 | 每日一次(歐洲盤結束後) | 非即時場景、記帳用匯率 |
| Open Exchange Rates | 免費 1000 req/月;$12/月 unlimited | 每小時 | 電商、一般應用 |
| Wise Rates API | 免費(有 rate limit) | 幾乎即時 | 比較接近實際兌換成本 |
| Fixer.io | 免費 100 req/月 | 每小時 | 小量需求 |
記帳目的(invoice rate)用 ECB 免費即可,不需要秒級更新;顯示用(首頁估算)用 Open Exchange Rates 每小時更新。
Cache 設計
class ExchangeRateService {
private cache = new Map<string, { rate: Decimal; fetchedAt: Date }>();
async getRate(from: string, to: string, type: 'mid' | 'display' = 'mid'): Promise<{ rate: Decimal }> {
const cacheKey = `${from}:${to}:${type}`;
const ttlMs = type === 'display' ? 5 * 60 * 1000 : 60 * 60 * 1000; // display: 5min, mid: 1hr
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt.getTime() < ttlMs) {
return { rate: cached.rate };
}
// Cache miss:從 DB 拿最新的(DB 由獨立的 cron job 更新)
const dbRate = await ExchangeRate.findOne({
where: { fromCurrency: from, toCurrency: to, rateType: type },
order: [['effectiveAt', 'DESC']],
});
if (!dbRate) {
// Fallback:用最後一筆有效 rate(寧可用舊的,不要讓服務掛掉)
const fallback = await ExchangeRate.findOne({
where: { fromCurrency: from, toCurrency: to },
order: [['effectiveAt', 'DESC']],
});
if (!fallback) throw new Error(`No exchange rate available for ${from}/${to}`);
logger.warn('Using stale exchange rate', { from, to, age: Date.now() - fallback.effectiveAt.getTime() });
return { rate: new Decimal(fallback.rate) };
}
this.cache.set(cacheKey, { rate: new Decimal(dbRate.rate), fetchedAt: new Date() });
return { rate: new Decimal(dbRate.rate) };
}
}多 pod 環境用 Redis cache(不用 Map,否則每個 pod 各自 cache 值不一致):
class ExchangeRateService {
constructor(private redis: Redis) {}
async getRate(from: string, to: string, type: 'mid' | 'display' = 'mid'): Promise<{ rate: Decimal }> {
const cacheKey = `exchange_rate:${from}:${to}:${type}`;
const ttlSec = type === 'display' ? 300 : 3600;
const cached = await this.redis.get(cacheKey);
if (cached) return { rate: new Decimal(cached) };
const dbRate = await ExchangeRate.findOne({
where: { fromCurrency: from, toCurrency: to, rateType: type },
order: [['effectiveAt', 'DESC']],
});
if (!dbRate) throw new Error(`No exchange rate for ${from}/${to}`);
await this.redis.setex(cacheKey, ttlSec, dbRate.rate.toString());
return { rate: new Decimal(dbRate.rate) };
}
}匯率更新 Cron Job(獨立跑,不在 request path 上):
// 每小時從 ECB / Open Exchange Rates 拉最新匯率存入 DB
cron.schedule('0 * * * *', async () => {
const rates = await fetchFromOpenExchangeRates();
await ExchangeRate.bulkCreate(
rates.map(r => ({
fromCurrency: r.from,
toCurrency: r.to,
rate: r.rate,
source: 'open_exchange_rates',
effectiveAt: new Date(),
})),
{ updateOnDuplicate: ['rate', 'fetchedAt'] }
);
});顯示換算 vs 計費換算的分離
這兩個永遠要分開,code 上不能共用:
// ✅ 顯示換算:估算,不寫 DB,用即時匯率
async function getDisplayPrice(amountUsd: number, userCurrency: string): Promise<string> {
const rate = await exchangeRateService.getRate('USD', userCurrency, 'display');
const converted = new Decimal(amountUsd).mul(rate.rate).toDecimalPlaces(2);
return new Intl.NumberFormat('zh-TW', {
style: 'currency',
currency: userCurrency,
}).format(converted.toNumber());
}
// ✅ 計費換算:snapshot,寫入 DB,用 mid rate
async function recordOrderAmount(order: Order): Promise<void> {
const rate = await exchangeRateService.getRate(order.currency, 'TWD', 'mid');
const baseAmount = new Decimal(order.amount)
.mul(rate.rate)
.toDecimalPlaces(0, Decimal.ROUND_HALF_UP)
.toNumber();
await order.update({
exchangeRate: rate.rate.toString(),
baseAmount,
});
}顯示換算的免責聲明:首頁的「約 NT$ XXX」旁邊要標注「*匯率僅供參考,實際以結帳時為準」。法律和財務都要這個。
退款的匯率政策
退款要明確決定用哪個 rate——不是技術問題,是業務決策,但後端要在設計時就把它固定下來:
方案一:用原始 transaction rate(推薦)
退款 USD 100,用當初建單的 snapshot rate(30.5),退回 TWD 3,050。
- 對商家:不產生新的 FX exposure,帳務簡單
- 對客戶:不受匯率波動影響(不論退款當天匯率如何)
- 大多數 payment gateway(Stripe、PayPal)預設也是這樣
async function refundOrder(orderId: string) {
const order = await Order.findByPk(orderId);
// 用原始 snapshot rate,不查新匯率
const refundBaseAmount = new Decimal(order.amount)
.mul(order.exchangeRate)
.toDecimalPlaces(0, Decimal.ROUND_HALF_UP)
.toNumber();
await Refund.create({
orderId,
amount: order.amount,
currency: order.currency,
refundRate: order.exchangeRate, // 用原始 rate,不是當下 rate
refundBaseAmount,
});
}方案二:用退款當下 rate
會產生額外的 FX gain/loss——建單時 30.5,退款時 31.0,同樣退 USD 100 但要給 TWD 3,100,商家多付 TWD 50。這個差額要另外記帳。適合特殊業務需求(如:保證客戶收到等值本地幣),但複雜度高。
Unrealized FX(月底關帳)
已開票(invoice sent)但還沒收款的 AR,月底要用當月底匯率重估,記 unrealized gain/loss:
10/15:開 USD 100 發票,匯率 30.5 → AR 記 TWD 3,050
10/31 月底:匯率變 31.0 → AR 重估為 TWD 3,100
10/31 帳:記 unrealized FX gain TWD 50
11/20 實際收款:匯率 30.8 → realized TWD 3,080
11/20 帳:reversal unrealized gain TWD 50,記 realized FX gain TWD 30
後端不一定要完整實作這整套(這是 ERP / 財務系統的範疇),但 schema 要能支援:AR 表記 invoice rate、月底批次計算 unrealized 差額寫入獨立的 fx_adjustments 表。
歷史匯率查詢
對帳、財報、稅務申報都需要查「某時間點的匯率」:
// 查特定時間點的匯率(比如對帳用)
async function getRateAt(from: string, to: string, at: Date): Promise<Decimal> {
const rate = await ExchangeRate.findOne({
where: {
fromCurrency: from,
toCurrency: to,
effectiveAt: { [Op.lte]: at },
},
order: [['effectiveAt', 'DESC']],
});
if (!rate) throw new Error(`No exchange rate found for ${from}/${to} at ${at.toISOString()}`);
return new Decimal(rate.rate);
}
// 查某月的平均匯率(某些國家稅務申報用月平均)
async function getMonthlyAvgRate(from: string, to: string, year: number, month: number): Promise<Decimal> {
const [result] = await ExchangeRate.findAll({
attributes: [[Sequelize.fn('AVG', Sequelize.col('rate')), 'avgRate']],
where: {
fromCurrency: from,
toCurrency: to,
effectiveAt: {
[Op.gte]: new Date(year, month - 1, 1),
[Op.lt]: new Date(year, month, 1),
},
},
raw: true,
});
return new Decimal((result as any)?.avgRate ?? '0');
}常見設計錯誤
不 snapshot 匯率:
// ❌ 查詢時才換算,匯率會隨時間改變
async function getOrderTotal(orderId: string) {
const order = await Order.findByPk(orderId);
const currentRate = await exchangeRateService.getRate('USD', 'TWD');
return order.amount * currentRate.rate; // 每次算都不一樣
}三個月後回頭對帳,金額對不上,誰都說不清楚當時是什麼匯率。
用 float 做匯率計算:
// ❌ 多次乘除後精度崩潰
const rate = 30.52;
const amount = 1000.99;
const result = amount * rate; // 30550.194800000002在 display 和計費共用同一個匯率 call:
// ❌ 計費用的匯率跑到 display 或反過來
const rate = await getLatestRate('USD', 'TWD'); // 是哪種 rate?不知道