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.
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.
| Stage | Rate to Use | Timing | Data Source |
|---|---|---|---|
| Customer Quoting | Live market rate | At quote creation | Real-time API |
| Contract Signing | Locked or re-fetched | At contract execution | Stored or live |
| Invoice Generation | Locked quote rate | At invoice date | Quote record |
| Payment Receipt | Settlement rate | At payment date | Bank/bank statement |
| Revenue Recognition | Spot rate | At delivery/ completion | Historical API |
| Month-End Close | Period-end close rate | Last business day | Historical API |
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.
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.
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.
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.
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.
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:
- 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.
- 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.
- 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
The Ultimate Guide to Multi-Currency SaaS Billing →
Architecture patterns for building billing systems that handle 150+ currencies.
SaaS Revenue Recognition with Foreign Currency →
ASC 606 / IFRS 15 implementation patterns for multi-currency subscription revenue.
FX Data Automation Workflows →
Google Sheets, Excel, n8n, and BI pipeline workflows for automating FX data.
Multi-Currency Refund Reconciliation →
How e-commerce platforms handle cross-border refunds with historical rate data.