返回博客

如何使用汇率 API 构建多币种开票(2026 指南)

V
Vlado Grigirov
July 30, 2026
Multi-Currency Currency API Exchange Rates Invoicing Developer Guide Accounting

多币种开票听起来像是一个已解决的问题——选一种货币,乘以汇率,打印出总额。但在实践中,它是任何计费系统中最容易出错的环节之一,而且这些错误代价高昂,因为它们会出现在你的应收账款、税务申报以及客户的收件箱里。如果你是一名开发者,正在为 SaaS 产品、自由职业者工具、代理平台或 B2B 市场集成多币种开票,难点并不在于乘法运算。难点在于决定使用哪个汇率、何时锁定它,以及如何存储它,以便你三月开出的发票在七月被支付时仍能正确对账。

本指南将梳理那些真正重要的工程决策,并提供可运行的代码,使用汇率 API 来获取、锁定和存储汇率。重点专门放在外汇(FX)层——这正是大多数开票教程略过的部分。

为什么多币种开票不只是货币换算

单一币种的发票是一张快照:数量乘以价格,再加税。多币种发票则是一份关于某个时间点的合约。当你以 EUR 向客户开票而账簿以 USD 记账时,你记录的是一笔应收账款,其 USD 价值在你开票当天就已固定——尽管市场汇率会一直波动,直到客户真正付款。

这个时间差带来了简单换算所忽略的三个具体问题:

  1. 适用哪个汇率。 开票日、付款日,还是"今天"的汇率?它们几乎从不相同,而会计准则(GAAP 与 IFRS)对答案都有明确规定。
  2. 可审计性。 你需要在几个月后证明你到底用了哪个汇率以及它从何而来。如果你无法复现这个数字,"我们从 API 取的"是不够的。
  3. 外汇损益。 开票日价值与付款日价值之间的差额是一笔真实的损益,必须记入你总账中的某个地方。

把这些做对,多币种开票会以最好的方式变得枯燥。做错了,你的财务团队就会在每个季度的最后一周去追讨零头。

黄金法则:在开票日锁定汇率

多币种开票中最重要的一条法则是:在发票开具的那一刻冻结汇率,永远不要重新计算它。

GAAPIFRS 都要求使用交易日当天生效的即期汇率来记录外币交易。对发票而言,交易日就是开票日。如果你在 7 月 1 日给客户开票,但系统直到 7 月 5 日才处理,你仍然使用 7 月 1 日的汇率。这不是 Finexly 的规定或偏好——这是应收账款以你的功能(本位)货币计价的价值在法律上被确立的方式。

一个常见的反模式是显示一个"实时"换算总额,每当客户刷新发票时它就变化。绝不要这样做。发票是对某一特定金额的固定索款。客户以发票币种欠款,而你的账簿以锁定汇率记入一笔分录。之后市场爱怎么变就怎么变。

实际结论是:用于开票的货币换算是一次性写入操作。 你获取一次汇率,将它与发票一起存储,并在该文档的整个生命周期内将其视为不可变。

为开票选择汇率 API

并非每个 FX 数据源都适合开票。对于计费,你需要:

  • 覆盖你开票所用的每一种货币(Finexly 覆盖 170 多种货币)。
  • 每个汇率上都有可靠的"截至"时间戳,以便你能证明使用了哪个报价。
  • 按日期的历史汇率,因为回溯开票和更正开票不可避免。
  • 可预测的速率限制与价格,这样发票量激增不会破坏计费。在把依赖引入你的收款路径之前,请查看价格方案

一个基本的最新汇率请求如下所示:

curl "https://api.finexly.com/v1/latest?base=USD&symbols=EUR&access_key=YOUR_API_KEY"

响应会给你一个汇率,以及你将与之一起存储的时间戳:

{
  "success": true,
  "base": "USD",
  "timestamp": 1753660800,
  "rates": {
    "EUR": 0.9213
  }
}

如果你想在接入任何东西之前以交互方式浏览汇率,货币转换器是一次快速的验证。若想更全面地了解 Finexly 与其他供应商的对比,请参阅比较汇率 API

获取并锁定发票汇率

下面是一个小的 Python 辅助函数,它获取发票的汇率并返回你需要持久化的一切:汇率、来源时间戳和换算后的总额。请注意它返回的是汇率元数据——而不仅仅是一个数字。

import os
import time
import requests
from decimal import Decimal, ROUND_HALF_UP

API_KEY = os.environ["FINEXLY_API_KEY"]
BASE_URL = "https://api.finexly.com/v1/latest"

def lock_invoice_rate(home_currency, invoice_currency):
    """Fetch and lock the FX rate to convert an invoice total
    (in invoice_currency) back into home_currency for the books."""
    resp = requests.get(BASE_URL, params={
        "base": invoice_currency,
        "symbols": home_currency,
        "access_key": API_KEY,
    }, timeout=10)
    resp.raise_for_status()
    data = resp.json()
    if not data.get("success"):
        raise RuntimeError("Rate lookup failed")

    rate = Decimal(str(data["rates"][home_currency]))
    return {
        "rate": rate,                       # invoice_currency -> home_currency
        "rate_base": invoice_currency,
        "rate_quote": home_currency,
        "source": "finexly",
        "as_of": data["timestamp"],         # store the source timestamp
        "locked_at": int(time.time()),      # when WE locked it
    }

def home_value(amount_invoice_ccy, rate):
    """Convert an invoice-currency amount into home currency."""
    return (Decimal(str(amount_invoice_ccy)) * rate).quantize(
        Decimal("0.01"), rounding=ROUND_HALF_UP
    )

关键细节在于 lock_invoice_rate 在创建发票时只运行一次,其输出被写入发票记录。对于缓存、重试以及优雅处理 429 响应等生产问题,请遵循我们缓存与错误处理指南中的模式——你可不希望一次短暂的 API 故障阻断发票开具。

将汇率与发票一起存储

由于每张发票的汇率是不可变的,请把它存储在发票上,而不是存储在一个你可能覆盖的共享"当前汇率"表里。一个最简模式如下:

CREATE TABLE invoices (
  id              BIGSERIAL PRIMARY KEY,
  issue_date      DATE        NOT NULL,
  invoice_ccy     CHAR(3)     NOT NULL,   -- what the customer is billed in
  home_ccy        CHAR(3)     NOT NULL,   -- your functional currency
  total_invoice   NUMERIC(18,2) NOT NULL, -- total in invoice_ccy
  fx_rate         NUMERIC(18,8) NOT NULL, -- invoice_ccy -> home_ccy, LOCKED
  fx_source       TEXT        NOT NULL,   -- e.g. 'finexly'
  fx_as_of        TIMESTAMPTZ NOT NULL,   -- the rate's source timestamp
  total_home      NUMERIC(18,2) NOT NULL  -- total_invoice * fx_rate, at issue
);

有三点让这个模式便于审计。第一,fx_rate 使用八位小数——汇率需要远高于金额两位小数的精度,过早截断会引入舍入漂移。第二,fx_as_of 记录了这个数字来自哪里以及何时,以便任何审计人员都能对照历史数据复现它。第三,total_home 在开票时就已计算并存储,因此六个月后运行的报表永远无需猜测。

为了对客户保持透明,请在发票本身上打印锁定汇率:应付金额、币种、所用汇率以及适用日期。这是一项被广泛推荐的最佳实践,恰恰因为当付款以不同汇率到账时它消除了歧义。

回溯开票:使用历史汇率,而非今天的汇率

迟早你会开出一张日期在过去的发票——一次更正、一笔迟入的分录,或一份规定了更早生效日的合同。对一张三月的发票使用今天的汇率完全错误,而且会通不过审计。请改从历史端点获取发票实际日期的汇率:

def lock_historical_rate(home_currency, invoice_currency, invoice_date):
    """invoice_date as 'YYYY-MM-DD'. Returns the locked rate for a
    backdated or corrected invoice."""
    resp = requests.get("https://api.finexly.com/v1/historical", params={
        "date": invoice_date,
        "base": invoice_currency,
        "symbols": home_currency,
        "access_key": API_KEY,
    }, timeout=10)
    resp.raise_for_status()
    data = resp.json()
    rate = Decimal(str(data["rates"][home_currency]))
    return {"rate": rate, "as_of": invoice_date, "source": "finexly"}

规则与实时情况完全相同——锁定一次,永久存储——但你查询的日期是发票的开具日,而不是当天。若想更深入地了解如何处理带日期的汇率,请参阅历史汇率 API 指南

正确处理舍入与精度

与钱有关的 bug 几乎都是舍入 bug。两条规则能让你远离麻烦。

绝不要用浮点数处理货币。 在浮点运算中 0.1 + 0.2 并不等于 0.3,而这些微小误差会在发票各行间累积。请使用十进制类型:Python 中的 Decimal、Java 中的 BigDecimal、C# 中的 decimal,或 JavaScript 中的整数分表示。

决定在哪里舍入。汇率舍入到完整精度(8 位以上小数),但将金额舍入到该货币的最小单位——USD 或 EUR 用两位小数,JPY 或 KRW 用零位小数,某些货币用三位。一个常见错误是硬编码两位小数并开出一张 ¥1,234.56 的发票,而这并非有效的日元金额。请根据货币来确定小数位数,使用 ISO 4217 的最小单位数据。

对于含明细行的发票,最好先对每一行舍入再求和,并与舍入后的总额对账,以便打印出的各行加起来等于打印出的总额。无论你选择哪种约定,都要在开票、付款和报表中一致地应用它。

在付款时确认外汇损益

这正是锁定汇率发挥作用之处。当客户付款时,你以付款日汇率将实际到达银行账户的金额换算回你的本位货币。它与开票日本位价值之间的差额就是一笔已实现的外汇损益

// Amounts kept as Decimal-like strings; use a money library in production.
function realizedFxGainLoss(invoice, paymentRate) {
  // invoice.totalInvoice: amount billed, in the invoice currency
  // invoice.fxRate:       LOCKED rate at issue (invoice_ccy -> home_ccy)
  // paymentRate:          rate on the day the payment settled

  const homeAtIssue   = invoice.totalInvoice * invoice.fxRate;
  const homeAtPayment = invoice.totalInvoice * paymentRate;

  const gainLoss = homeAtPayment - homeAtIssue;
  return {
    homeAtIssue:   round2(homeAtIssue),
    homeAtPayment: round2(homeAtPayment),
    fxGainLoss:    round2(gainLoss),        // > 0 gain, < 0 loss
  };
}

function round2(n) { return Math.round(n * 100) / 100; }

你获取 paymentRate 的方式与锁定发票汇率相同——在结算日调用 latest,若事后对账则调用 historical。将 fxGainLoss 记入专门的"外汇损益"科目。当市场在开票与付款之间波动时,正是这一笔分录让你的账簿保持平衡,而这恰恰是手工多币种计费常常做错的对账工作。将其构建到自动化流水线中的企业,可以依托我们多币种 SaaS 计费指南中描述的同一套汇率基础设施。

贷项通知单、退款与周期性发票

三种边缘情况让一个完整的系统更加完备:

  • 贷项通知单和退款必须以原始锁定汇率冲销原始发票——而不是当前汇率。退款是对初始交易的反向操作,因此请复用你正在冲销的那张发票的 fx_rate。实际退款日的任何残差都会成为另一笔小额外汇损益分录。
  • 周期性发票各自在各自的开具日获得自己的锁定汇率。一份按月计费的 12 个月订阅会产生十二张发票、十二个汇率。除非合同明确固定,否则不要为全年锁定单一汇率。
  • 合同固定汇率偶尔会覆盖市场——某些企业合同规定某一时期的固定汇率。请支持手动覆盖字段,但用相同的审计元数据存储它,使其仍可复现。

常见问题

发票上我该用哪个汇率? 使用发票开具日当天生效的即期汇率,然后锁定它。GAAP 与 IFRS 都要求以交易日汇率记录外币交易,而开票日就是那个日期。当客户查看或支付发票时,不要重新计算它。

我应该存储汇率还是只存换算后的金额? 两者都存——外加汇率的来源和时间戳。只存换算后的总额会导致日后无法审计,也无法正确计算外汇损益。汇率、"截至"时间戳和来源三者合在一起,才能让任何人复现这个数字。

我该如何处理一张日期在过去的发票? 查询实际开具日的历史汇率,而不是使用今天的汇率。锁定一次、永久存储的规则不变;只是你查询的日期变了。

是什么造成了发票上的外汇损益? 开票日与付款日之间市场的波动。你的账簿以开票日汇率记录了应收账款,但现金到账时是按付款日汇率计价的。这个差额是一笔已实现的外汇损益,记入专门的总账科目。

构建多币种开票需要付费的货币 API 吗? 你可以从免费套餐开始。Finexly 的免费汇率 API为初期用量提供实时和历史汇率,只有当你的发票吞吐量增长时才需升级。

立即开始

多币种开票归结为一条纪律:在开票日锁定汇率,用完整的审计元数据存储它,并在付款时对账差额。做到这一点,国际计费就不再是季末的救火行动。

准备好构建了吗?获取你的免费 Finexly API 密钥——无需信用卡。在免费套餐上从 170 多种货币的实时和历史汇率起步,并随着发票量的增长而扩展。

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →