Multi-Currency Payroll: Handling Exchange Rates
A technical guide to implementing multi-currency payroll processing in TypeScript, covering exchange rate management, currency conversion pipelines, and the accounting patterns that keep multi-currency books balanced.
Operating payroll across multiple currencies introduces a dimension of complexity that fundamentally changes how calculations, reporting, and accounting work. An employee in Cairo paid in Egyptian pounds, another in Dubai paid in dirhams, and a third in London paid in sterling all need to flow through the same payroll engine, comply with local regulations, and consolidate into a single reporting currency for the parent entity.
In this article, we explore how to build multi-currency payroll processing in TypeScript. We cover exchange rate management, currency-safe arithmetic, conversion pipelines, and the accounting patterns that keep multi-currency books balanced.
Currency-Safe Arithmetic
The first and most fundamental requirement is ensuring that currency values are never accidentally mixed. Adding Egyptian pounds to US dollars must be a compile-time error, not a runtime surprise.
interface Money {
amount: number;
currency: CurrencyCode;
}
type CurrencyCode = "USD" | "EUR" | "GBP" | "EGP" | "AED" | "SAR" | "NGN";
class MoneyOperations {
static add(a: Money, b: Money): Money {
if (a.currency !== b.currency) {
throw new CurrencyMismatchError(a.currency, b.currency);
}
return {
amount: Math.round((a.amount + b.amount) * 100) / 100,
currency: a.currency,
};
}
static subtract(a: Money, b: Money): Money {
if (a.currency !== b.currency) {
throw new CurrencyMismatchError(a.currency, b.currency);
}
return {
amount: Math.round((a.amount - b.amount) * 100) / 100,
currency: a.currency,
};
}
static multiply(money: Money, factor: number): Money {
return {
amount: Math.round(money.amount * factor * 100) / 100,
currency: money.currency,
};
}
static zero(currency: CurrencyCode): Money {
return { amount: 0, currency };
}
static isZero(money: Money): boolean {
return money.amount === 0;
}
static format(money: Money): string {
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: money.currency,
});
return formatter.format(money.amount);
}
}
class CurrencyMismatchError extends Error {
constructor(expected: CurrencyCode, actual: CurrencyCode) {
super(`Currency mismatch: cannot operate on ${expected} and ${actual}`);
this.name = "CurrencyMismatchError";
}
}A practical tip: represent monetary amounts as integers in the smallest currency unit (cents, piastres, fils) in your database layer to avoid floating-point precision issues. The Money type in the application layer can use numbers with controlled rounding, but persistence should always use integers.
Exchange Rate Management
Exchange rates are volatile, time-sensitive, and sourced from multiple providers. The exchange rate management system must handle rate retrieval, caching, historical lookups, and fallback strategies.
interface ExchangeRate {
baseCurrency: CurrencyCode;
targetCurrency: CurrencyCode;
rate: number;
inverseRate: number;
source: string;
timestamp: Date;
effectiveDate: Date;
}
interface ExchangeRateProvider {
name: string;
fetchRate(base: CurrencyCode, target: CurrencyCode): Promise<ExchangeRate>;
fetchRates(base: CurrencyCode): Promise<ExchangeRate[]>;
}
class ExchangeRateService {
constructor(
private readonly providers: ExchangeRateProvider[],
private readonly rateStore: ExchangeRateStore,
private readonly cache: RateCache
) {}
async getRate(
base: CurrencyCode,
target: CurrencyCode,
asOfDate: Date
): Promise<ExchangeRate> {
if (base === target) {
return {
baseCurrency: base,
targetCurrency: target,
rate: 1,
inverseRate: 1,
source: "identity",
timestamp: new Date(),
effectiveDate: asOfDate,
};
}
const cached = await this.cache.get(base, target, asOfDate);
if (cached) return cached;
const stored = await this.rateStore.findRate(base, target, asOfDate);
if (stored) {
await this.cache.set(stored);
return stored;
}
return this.fetchAndStoreRate(base, target, asOfDate);
}
private async fetchAndStoreRate(
base: CurrencyCode,
target: CurrencyCode,
asOfDate: Date
): Promise<ExchangeRate> {
for (const provider of this.providers) {
try {
const rate = await provider.fetchRate(base, target);
await this.rateStore.save(rate);
await this.cache.set(rate);
return rate;
} catch (error) {
console.warn(`Provider ${provider.name} failed:`, error);
continue;
}
}
throw new ExchangeRateUnavailableError(base, target, asOfDate);
}
async lockRateForPayRun(
payRunId: string,
base: CurrencyCode,
target: CurrencyCode,
asOfDate: Date
): Promise<ExchangeRate> {
const rate = await this.getRate(base, target, asOfDate);
await this.rateStore.lockRate(payRunId, rate);
return rate;
}
}
class ExchangeRateUnavailableError extends Error {
constructor(base: CurrencyCode, target: CurrencyCode, asOfDate: Date) {
super(
`No exchange rate available for ${base}/${target} as of ${asOfDate.toISOString()}`
);
}
}Rate locking is critical for payroll. When a pay run begins, the exchange rates used for that run must be locked and recorded. If rates were fetched dynamically during calculation, different employees in the same pay run could be calculated at different rates, leading to inconsistent reporting.
Currency Conversion Pipeline
The conversion pipeline handles the transformation of payroll amounts between currencies. This occurs at multiple points: when consolidating multi-currency payrolls into a reporting currency, when an employee is paid in a different currency than their contract currency, and when generating cross-border tax reports.
interface CurrencyConversion {
originalAmount: Money;
convertedAmount: Money;
exchangeRate: ExchangeRate;
conversionDate: Date;
purpose: ConversionPurpose;
}
type ConversionPurpose = "payroll_calculation" | "reporting_consolidation" | "tax_reporting" | "bank_transfer";
class CurrencyConversionService {
constructor(private readonly rateService: ExchangeRateService) {}
async convert(
amount: Money,
targetCurrency: CurrencyCode,
asOfDate: Date,
purpose: ConversionPurpose
): Promise<CurrencyConversion> {
const rate = await this.rateService.getRate(
amount.currency,
targetCurrency,
asOfDate
);
const convertedAmount: Money = {
amount: Math.round(amount.amount * rate.rate * 100) / 100,
currency: targetCurrency,
};
return {
originalAmount: amount,
convertedAmount,
exchangeRate: rate,
conversionDate: asOfDate,
purpose,
};
}
async convertPayStub(
payStub: MultiCurrencyPayStub,
reportingCurrency: CurrencyCode,
asOfDate: Date
): Promise<ConsolidatedPayStub> {
const grossConversion = await this.convert(
payStub.grossPay,
reportingCurrency,
asOfDate,
"reporting_consolidation"
);
const netConversion = await this.convert(
payStub.netPay,
reportingCurrency,
asOfDate,
"reporting_consolidation"
);
const deductionConversions = await Promise.all(
payStub.deductions.map(async (d) => ({
...d,
reportingAmount: await this.convert(
d.amount,
reportingCurrency,
asOfDate,
"reporting_consolidation"
),
}))
);
return {
payStubId: payStub.id,
localCurrency: payStub.grossPay.currency,
reportingCurrency,
localGrossPay: payStub.grossPay,
reportingGrossPay: grossConversion.convertedAmount,
localNetPay: payStub.netPay,
reportingNetPay: netConversion.convertedAmount,
exchangeRate: grossConversion.exchangeRate,
deductions: deductionConversions,
conversionDate: asOfDate,
};
}
}
interface MultiCurrencyPayStub {
id: string;
employeeId: string;
grossPay: Money;
netPay: Money;
deductions: Array<{ code: string; name: string; amount: Money }>;
}
interface ConsolidatedPayStub {
payStubId: string;
localCurrency: CurrencyCode;
reportingCurrency: CurrencyCode;
localGrossPay: Money;
reportingGrossPay: Money;
localNetPay: Money;
reportingNetPay: Money;
exchangeRate: ExchangeRate;
deductions: Array<{
code: string;
name: string;
amount: Money;
reportingAmount: CurrencyConversion;
}>;
conversionDate: Date;
}A practical tip: always store both the local currency amount and the converted reporting currency amount on every pay stub line. This dual recording eliminates the need to re-convert historical data when exchange rates change and provides a clear audit trail of the rate used for each conversion.
Multi-Currency Payroll Engine
The multi-currency payroll engine orchestrates calculations across entities, currencies, and jurisdictions.
class MultiCurrencyPayrollEngine {
constructor(
private readonly calculationEngine: PayrollCalculationEngine,
private readonly conversionService: CurrencyConversionService,
private readonly rateService: ExchangeRateService,
private readonly entityService: LegalEntityService
) {}
async processMultiEntityPayRun(
entities: string[],
payPeriod: PayPeriodDefinition,
reportingCurrency: CurrencyCode
): Promise<MultiEntityPayRunResult> {
const entityResults: EntityPayRunResult[] = [];
for (const entityId of entities) {
const entity = await this.entityService.findById(entityId);
const localCurrency = entity.payrollSettings.defaultCurrency as CurrencyCode;
await this.rateService.lockRateForPayRun(
`${entityId}-${payPeriod.periodNumber}`,
localCurrency,
reportingCurrency,
payPeriod.payDate
);
const localResult = await this.calculationEngine.processEntity(
entityId,
payPeriod
);
const consolidatedStubs = await Promise.all(
localResult.payStubs.map((stub) =>
this.conversionService.convertPayStub(
this.toMultiCurrencyStub(stub, localCurrency),
reportingCurrency,
payPeriod.payDate
)
)
);
entityResults.push({
entityId,
entityName: entity.name,
localCurrency,
localTotalGross: localResult.totalGross,
localTotalNet: localResult.totalNet,
reportingTotalGross: consolidatedStubs.reduce(
(sum, s) => MoneyOperations.add(sum, s.reportingGrossPay),
MoneyOperations.zero(reportingCurrency)
),
reportingTotalNet: consolidatedStubs.reduce(
(sum, s) => MoneyOperations.add(sum, s.reportingNetPay),
MoneyOperations.zero(reportingCurrency)
),
employeeCount: consolidatedStubs.length,
consolidatedStubs,
});
}
return {
payPeriod,
reportingCurrency,
entities: entityResults,
grandTotalGross: entityResults.reduce(
(sum, e) => MoneyOperations.add(sum, e.reportingTotalGross),
MoneyOperations.zero(reportingCurrency)
),
grandTotalNet: entityResults.reduce(
(sum, e) => MoneyOperations.add(sum, e.reportingTotalNet),
MoneyOperations.zero(reportingCurrency)
),
processedAt: new Date(),
};
}
private toMultiCurrencyStub(stub: PayStub, currency: CurrencyCode): MultiCurrencyPayStub {
return {
id: stub.id,
employeeId: stub.employeeId,
grossPay: { amount: stub.grossPay, currency },
netPay: { amount: stub.netPay, currency },
deductions: stub.deductions.map((d) => ({
code: d.code,
name: d.description,
amount: { amount: d.amount, currency },
})),
};
}
}
interface MultiEntityPayRunResult {
payPeriod: PayPeriodDefinition;
reportingCurrency: CurrencyCode;
entities: EntityPayRunResult[];
grandTotalGross: Money;
grandTotalNet: Money;
processedAt: Date;
}
interface EntityPayRunResult {
entityId: string;
entityName: string;
localCurrency: CurrencyCode;
localTotalGross: number;
localTotalNet: number;
reportingTotalGross: Money;
reportingTotalNet: Money;
employeeCount: number;
consolidatedStubs: ConsolidatedPayStub[];
}Handling Exchange Rate Differences and Reconciliation
Currency conversions inevitably produce rounding differences. When the sum of individually converted line items does not match the conversion of the total, a reconciliation adjustment is needed.
class ExchangeRateReconciler {
reconcile(
consolidatedStubs: ConsolidatedPayStub[],
expectedTotal: Money,
reportingCurrency: CurrencyCode
): ReconciliationResult {
const sumOfConversions = consolidatedStubs.reduce(
(sum, s) => MoneyOperations.add(sum, s.reportingGrossPay),
MoneyOperations.zero(reportingCurrency)
);
const difference = MoneyOperations.subtract(expectedTotal, sumOfConversions);
if (Math.abs(difference.amount) < 0.01) {
return { balanced: true, adjustment: MoneyOperations.zero(reportingCurrency) };
}
return {
balanced: false,
adjustment: difference,
adjustmentAppliedTo: consolidatedStubs[consolidatedStubs.length - 1].payStubId,
};
}
}
interface ReconciliationResult {
balanced: boolean;
adjustment: Money;
adjustmentAppliedTo?: string;
}A practical tip: apply rounding adjustments to the last line item in a set rather than distributing them. This is a standard accounting convention and makes the adjustment easy to identify in reconciliation reports.
Conclusion
Multi-currency payroll is a multiplier of complexity. Every calculation, every deduction, and every report must account for currency conversion, rate locking, and cross-currency reconciliation. The currency-safe arithmetic layer prevents accidental mixing of currencies. The exchange rate service provides reliable, auditable rate management. The conversion pipeline transforms local currency payrolls into consolidated reporting views.
TypeScript's type system is essential for multi-currency safety. The Money type with its mandatory CurrencyCode field makes it impossible to accidentally add pounds to dollars. Interface-driven conversion services and strongly typed exchange rate records create a system where currency handling is explicit and auditable at every step.
The patterns described here---rate locking for pay runs, dual-currency recording, and rounding reconciliation---are the same patterns used by major multinational payroll providers. They provide the foundation for accurate, auditable multi-currency payroll processing at any scale.
Related Articles
Scaling Payroll Processing for Growing Organizations
A strategic and technical guide to scaling payroll systems as organizations grow, covering batch processing optimization, infrastructure scaling patterns, and the operational strategies that keep payroll reliable at scale.
Security Best Practices for Payroll Systems
A comprehensive guide to securing payroll systems, covering data encryption, access controls, PII protection, threat modeling, and the security architecture that protects sensitive employee financial data.
Digital Payroll Transformation: Strategy Guide
A strategic guide to modernizing payroll operations through digital transformation, covering technology selection, change management, compliance continuity, and the business case for building custom payroll infrastructure.