帳務視角的多幣別問題

電商收外幣的流程通常是:

用戶下單(USD 100)→ 建訂單(匯率 30.5,折合 TWD 3,050)
       ↓
幾天後收款實際入帳(當天匯率 30.2)
       ↓
入帳金額 TWD 3,020,比訂單少了 TWD 30

這 TWD 30 是匯兌損失,財務上要記,稅務上要申報。後端如果沒有設計好,帳對不上是家常便飯。

三個根本問題:

  1. Invoice rate:建單時用哪個匯率、這個 rate 要 snapshot 存下來
  2. Settlement rate:實際收付款時的匯率(與 invoice rate 的差 = 匯兌損益)
  3. 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?不知道

延伸閱讀