Finance OperationsBillingRevenue Recognition

Multi-Currency Quote-to-Cash: The FX Operations Handbook for Finance Teams

Every foreign-currency transaction touches five systems between quote and cash: quoting, contracting, invoicing, payment processing, and revenue recognition. Each system needs a rate. If those rates do not align — if the quote used a different rate than the invoice, or the invoice rate does not match the settlement rate — the finance team spends hours each month tracking down discrepancies. This guide covers how to wire exchange rate data through the entire quote-to-cash cycle so every conversion is traceable, auditable, and consistent.

5
QTC Stages
1
Central Rate Service
3
Rate Policy Types
150+
Currencies Covered
10+ yr
Historical Depth

The Multi-Currency Reconciliation Problem

Finance teams at companies selling across borders face the same sequence of headaches every month. Sales quoted a deal in GBP using one rate. The invoice was generated using a different rate — either because the system pulled a fresh rate, or because someone manually entered the wrong value. The customer paid in GBP on a different date, so the bank settled at yet another rate. Revenue recognition needs the rate on the delivery date, which is different from all three.

Each mismatch between these rates creates a line item that someone has to investigate, explain, and adjust. At companies processing even a few hundred foreign-currency invoices per month, this reconciliation work consumes hours per close cycle. The root cause is almost always the same: no single source of truth for which rate to use at which stage, and no automated pipeline that applies the right rate policy consistently.

The 5 Stages of Multi-Currency Quote-to-Cash

Each stage in the quote-to-cash cycle has a different rate requirement. The table below maps each stage to its rate policy, timing, and data source.

StageRate to UseTimingData Source
Customer QuotingLive market rateAt quote creationReal-time API
Contract SigningLocked or re-fetchedAt contract executionStored or live
Invoice GenerationLocked quote rateAt invoice dateQuote record
Payment ReceiptSettlement rateAt payment dateBank/bank statement
Revenue RecognitionSpot rateAt delivery/ completionHistorical API
Month-End ClosePeriod-end close rateLast business dayHistorical API
1. Quote

Generate Customer-Facing Quotes

Sales creates a quote in the customer's local currency using live exchange rates. The rate and timestamp are stored on the quote record for downstream traceability.

Rate policy: Live rate at time of quote generation
2. Contract

Lock Rate on Signed Agreement

When the customer signs, the quoted rate can be locked for a defined validity window (24-72 hours). This protects both parties from rate movement during contract finalization.

Rate policy: Locked rate from quote or re-fetched live rate
3. Invoice

Convert Line Items for Billing

Invoice amounts are converted using the locked quote rate. If no rate lock exists, the live rate at invoice generation time is used. Both the rate and timestamp are recorded on the invoice.

Rate policy: Locked quote rate or live rate at invoice date
4. Payment

Match and Reconcile Receipts

Incoming payments in foreign currency are matched against invoices. If the settlement rate differs from the invoice rate, the FX variance is recorded in a dedicated gain/loss account.

Rate policy: Invoice rate for matching; settlement rate for variance
5. Revenue Recognition

Recognize Revenue with Historical Rates

Under ASC 606 / IFRS 15, revenue is recognized using the spot rate on the date the performance obligation was satisfied. Historical rate data provides the exact rate for each reporting date.

Rate policy: Historical rate on the performance obligation date

Building the Central Conversion Service

The first technical step is building a shared conversion service that all quote-to-cash systems call instead of hitting the API directly. This service handles three concerns: fetching the right rate type (live, locked, or historical), logging every conversion for audit purposes, and normalizing the response format so downstream systems do not need provider-specific logic.

// TypeScript — Multi-Currency Conversion Service
// Centralizes rate fetching, caching, and audit logging
// for all quote-to-cash operations.

interface ConversionResult {
  from: string;
  to: string;
  originalAmount: number;
  convertedAmount: number;
  exchangeRate: number;
  rateTime: string;
  rateSource: string;
  convertedAt: string;
}

interface RatePolicy {
  type: 'live' | 'locked' | 'historical';
  date?: string; // For historical lookups
  lockedRate?: number; // From a quote record
}

const API_BASE = 'https://currency-exchange.app/api';

async function fetchLiveRate(
  from: string,
  to: string,
): Promise<{ rate: number; time: string }> {
  const url = new URL(API_BASE + '/v1-get-currency-exchange-rate');
  url.searchParams.set('from', from);
  url.searchParams.set('to', to);
  url.searchParams.set('x-api-key', process.env.FX_API_KEY ?? '');

  const response = await fetch(url.toString());
  if (!response.ok) throw new Error(`API error: ${response.status}`);
  const data = await response.json();

  return { rate: data.exchangeRate, time: data.rateTime };
}

async function fetchHistoricalRate(
  from: string,
  to: string,
  date: string,
): Promise<{ rate: number; time: string }> {
  const url = new URL(API_BASE + '/v1-historical');
  url.searchParams.set('from', from);
  url.searchParams.set('to', to);
  url.searchParams.set('date', date);
  url.searchParams.set('x-api-key', process.env.FX_API_KEY ?? '');

  const response = await fetch(url.toString());
  if (!response.ok) throw new Error(`API error: ${response.status}`);
  const data = await response.json();

  return { rate: data.exchangeRate, time: data.date };
}

async function convert(
  from: string,
  to: string,
  amount: number,
  policy: RatePolicy,
): Promise<ConversionResult> {
  let rate: number;
  let rateTime: string;
  let rateSource: string;

  switch (policy.type) {
    case 'locked':
      if (!policy.lockedRate) throw new Error('No locked rate provided');
      rate = policy.lockedRate;
      rateTime = 'locked';
      rateSource = 'quote-record';
      break;
    case 'historical':
      if (!policy.date) throw new Error('No date provided for historical rate');
      const historical = await fetchHistoricalRate(from, to, policy.date);
      rate = historical.rate;
      rateTime = historical.time;
      rateSource = 'historical-api';
      break;
    case 'live':
    default:
      const live = await fetchLiveRate(from, to);
      rate = live.rate;
      rateTime = live.time;
      rateSource = 'live-api';
  }

  return {
    from,
    to,
    originalAmount: amount,
    convertedAmount: Number((amount * rate).toFixed(2)),
    exchangeRate: rate,
    rateTime,
    rateSource,
    convertedAt: new Date().toISOString(),
  };
}

Why this pattern works: By centralizing rate policy logic in one service, you eliminate the most common source of reconciliation errors — different systems independently fetching rates at different times and getting different values. The service also creates an audit log: every conversion records which rate was used, when it was fetched, and which policy determined the rate selection.

Invoice Reconciliation with FX Variance Tracking

Even with a centralized rate service, FX variance still occurs: the customer pays on a different date than the invoice, and the settlement rate differs from the invoice rate. The reconciliation code below compares invoice amounts against payment receipts and flags variances that exceed your threshold.

// TypeScript — Invoice Reconciliation with FX Variance
// Compares invoice rate against payment settlement rate

interface InvoiceRecord {
  id: string;
  currency: string;
  baseCurrency: string;
  amount: number;
  convertedAmount: number;
  invoiceRate: number;
  invoiceDate: string;
}

interface PaymentRecord {
  invoiceId: string;
  receivedAmount: number;
  settlementRate: number;
  paymentDate: string;
}

interface ReconciliationResult {
  invoiceId: string;
  invoiceAmount: number;
  receivedAmount: number;
  fxVariance: number;
  fxVariancePercent: number;
  status: 'matched' | 'variance' | 'overdue';
}

function reconcileInvoice(
  invoice: InvoiceRecord,
  payment: PaymentRecord,
): ReconciliationResult {
  // What the customer should have paid at the invoice rate
  const expectedPayment = invoice.amount * payment.settlementRate;

  // What the customer actually paid (converted to base currency)
  const actualReceivedBase = payment.receivedAmount * payment.settlementRate;

  // FX variance: difference caused by rate movement
  const fxVariance = Number(
    (actualReceivedBase - invoice.convertedAmount).toFixed(2),
  );
  const fxVariancePercent = Number(
    ((fxVariance / invoice.convertedAmount) * 100).toFixed(4),
  );

  return {
    invoiceId: invoice.id,
    invoiceAmount: invoice.convertedAmount,
    receivedAmount: actualReceivedBase,
    fxVariance,
    fxVariancePercent,
    status: Math.abs(fxVariancePercent) < 0.1
      ? 'matched'
      : 'variance',
  };
}

// Batch reconciliation for month-end close
async function reconcileMonth(
  invoices: InvoiceRecord[],
  payments: PaymentRecord[],
  varianceThreshold = 0.5,
) {
  const results: ReconciliationResult[] = [];

  for (const invoice of invoices) {
    const payment = payments.find(
      (p) => p.invoiceId === invoice.id,
    );

    if (!payment) {
      // Overdue: no payment received
      results.push({
        invoiceId: invoice.id,
        invoiceAmount: invoice.convertedAmount,
        receivedAmount: 0,
        fxVariance: 0,
        fxVariancePercent: 0,
        status: 'overdue',
      });
      continue;
    }

    const result = reconcileInvoice(invoice, payment);
    results.push(result);

    if (result.status === 'variance') {
      console.warn(
        `Invoice ${invoice.id}: FX variance ${result.fxVariancePercent}%` +
          ` (threshold: ${varianceThreshold}%)`,
      );
    }
  }

  return results;
}

Handling FX gains and losses

When the reconciliation shows a variance, record it in a dedicated FX gain/loss account in your general ledger. Most ERP systems (NetSuite, SAP, Oracle) have built-in FX variance accounts — the key is feeding them accurate rate data via API rather than relying on manual entry.

FX Gain

Customer paid more in base currency than the invoice amount (favorable rate movement).

FX Loss

Customer paid less in base currency than the invoice amount (unfavorable rate movement).

Within Threshold

Variance is immaterial (e.g., under 0.1%). Auto-close and do not create an adjustment entry.

Revenue Recognition with Historical Rates

Under ASC 606 (US GAAP) and IFRS 15 (international), multi-currency revenue recognition requires the exchange rate on the date the performance obligation is satisfied — typically the delivery date, service completion date, or milestone achievement date. This is often different from both the invoice date and the payment date.

For companies with subscription revenue or multi-delivery contracts, this means each revenue event may need a different rate. A 12-month SaaS contract billed annually in JPY but recognized monthly in USD needs 12 separate historical rate lookups.

Historical rate APIs make this manageable. Instead of maintaining a rate database yourself, request the specific rate for each performance obligation date. The API returns the exchange rate for that date along with a timestamp, giving you an auditable record of which rate was used for each revenue recognition entry.

Practical tip: For subscription businesses, batch your historical rate requests at month-end. Pull rates for every delivery/completion date in the reporting period in a single bulk request, then feed those rates into your revenue recognition system. This is significantly faster than looking up rates one at a time.

Month-End Close: Automating the FX Reconciliation Workflow

Month-end close is where FX errors accumulate. Every mismatch between quote rate, invoice rate, payment settlement rate, and revenue recognition rate creates a line item in the reconciliation report. The manual approach — exporting spreadsheets, looking up rates, copy-pasting into the ERP — takes hours and introduces errors.

The automated approach replaces the manual workflow with a three-step pipeline:

  1. Pull historical rates for the reporting period. Request rates for every date in the month that had a foreign-currency transaction. Use the historical rate endpoint with date parameters.
  2. Run reconciliation against invoice and payment records. Compare the rate stored on each invoice against the settlement rate on the corresponding payment. Flag variances above your threshold.
  3. Generate the FX variance report and post adjustments. For material variances, create journal entries in the ERP. For immaterial variances, auto-close. The report should include the rate used, the date, the source (live API, historical API, or quote record), and the variance amount for each line item.

Frequently Asked Questions

What exchange rate should I use for quoting customers?

Most B2B companies use live market rates with an added margin that accounts for volatility and processing time. The key is consistency: pick a rate source, document the policy, and apply it uniformly. Some teams lock the rate for 24-72 hours on formal proposals, while others use live rates for informal estimates. Either approach works as long as sales, finance, and billing all reference the same standard.

How do I handle FX gains and losses on paid invoices?

When a customer pays in a different currency than the invoice, the difference between the invoice rate and the payment settlement rate creates an FX gain or loss. Record this in a dedicated FX variance account. Automate the calculation by storing the invoice rate on the invoice record and comparing it to the rate on the payment date. Most ERP systems can handle this if you feed them the right rate data via API.

What rate do I use for revenue recognition under ASC 606?

Under ASC 606 (and IFRS 15), you generally use the spot rate on the date the performance obligation was satisfied — typically the delivery date or service completion date, not the billing date or payment date. If the transaction spans multiple dates, you can use an average rate for the period. Historical rate APIs make this straightforward: request the rate for the specific date and use it in your revenue recognition entry.

How often should I refresh rates in a billing system?

For billing and invoicing, daily rate pulls are standard for most B2B operations. For high-volume transactional systems (e-commerce, payment processing), sub-minute refreshes may be necessary. The key question is: what is the cost of a stale rate? If you process $100K in foreign invoices daily and rates shift 0.3% between refreshes, that is a $300 exposure per day. Weigh that against your API call volume and plan limits.

Build Your Multi-Currency Pipeline

Test live rate conversion, historical lookups, and bulk processing in the interactive demo. No account required for initial exploration.

Related Reading

Resources

API Documentation

Full endpoint reference including historical rates and bulk conversion

View Docs

Interactive Demo

Test rate conversion, historical lookups, and bulk processing live

Try It Now

Pricing

Transparent per-conversion pricing with no free-tier limitations

View Plans