Если в 2026 году вы разрабатываете ПО для расчёта зарплаты, вы больше не строите инструмент для одной страны. Deel, Remote, Rippling и длинный хвост вертикальных HRIS-платформ ежемесячно отправляют зарплаты подрядчикам и сотрудникам в более чем 90 странах, и в каждой такой выплате прячется один и тот же скучно-дорогой вопрос: какой курс мы используем, когда фиксируем его и как доказываем сотруднику (и аудитору), что сделали правильно? Конвертация валют в трансграничной зарплате звучит как бэк-офисная мелочь, пока вы не осознаёте, что ошибка в 50 базисных пунктов на месячной зарплате в $4 000 стоит сотруднику $20 в месяц — каждый месяц — а ваш саппорт-инбокс к пятнице переполнен.
Это руководство для инженеров, строящих системы расчёта зарплаты, выплат подрядчикам, EOR или HRIS, которые рассчитываются в нескольких валютах. Мы пройдём по архитектурным решениям, которые имеют значение — источник курса, даты фиксации, mid-market vs спред, идемпотентность, аудит-след, округление — и напишем продакшен-код для каждого на Node.js, Python и PHP с помощью API Finexly. К концу у вас будет FX-слой зарплаты, выдерживающий и SOC 2-ревью, и сообщение в Slack в 2 часа ночи после того, как USD/JPY скакнул на 1,5% за ночь.
Почему FX в зарплате сложнее, чем кажется
Наивный подход к зарплатной конвертации — одна строка кода: amount_local = amount_usd * rate. Для конвертера на маркетинговой странице это нормально. Для зарплаты — нет, по шести причинам, которые кусают одновременно:
- Курс должен быть воспроизводимым. Когда сотрудник или аудитор спросит, почему мартовская зарплата вышла ¥608 243 вместо ¥609 118, нужно указать на конкретный курс, временную метку и источник. «Что Stripe котировал в момент выплаты» — это ответ, который не переживёт аудит.
- Дата фиксации — решение политики, а не баг. Дата запуска? Конец периода? 25-е число для месячной зарплаты? Каждое решение по-разному влияет на FX-риск, предсказуемость для сотрудника и налоговую отчётность. Ваш код должен закодировать политику, которую выбрал CFO — и дать менять её без деплоя.
- Mid-market и «курс платежа» — разные вещи. Mid-market курс — это середина между bid и ask, то, что показывают Google или Bloomberg. Курс, который реально двигает деньги через SWIFT, локальный рельс или стейблкоин-мост, всегда несёт спред. Показывайте mid-market как референс и отдельно трекайте курс, который фактически использовал платёжный провайдер, чтобы сходилась сверка.
- Идемпотентность важна. Зарплатные раны ретраются — таймауты задач, повторная доставка очереди, оператор кликнул дважды. Если FX-лукап не идемпотентен для (сотрудник, период), ретрай вернёт другой курс и даст другой gross-to-net.
- Выходные, праздники и правила юрисдикций. FX-рынки закрыты с вечера пятницы Нью-Йорка до вечера воскресенья Сиднея; зарплатные раны — нет. Наивный код тихо подсовывает старый кэш. И некоторые юрисдикции (Бразилия PTAX, Аргентина BCRA, Индия RBI для нерезидентов) требуют референс-курсов ЦБ, переопределяющих mid-market — слой курсов должен поддерживать переопределения по юрисдикции.
Если эти шесть пунктов сделаны правильно — у вас есть FX-слой зарплаты. Любая ошибка — это инцидент, который только ждёт своего часа.
Эталонная архитектура
Примеры ниже предполагают три слоя со строгими границами:
Слой 1 — Слой курсов. Тянет mid-market курсы у провайдера реального времени (Finexly), кэширует, раз в сутки делает снапшот для аудит-следа. Никто другой в платформе не общается с FX-провайдером напрямую.
Слой 2 — FX-политика. Чистые функции, принимающие (сотрудник, период, исходная сумма, исходная валюта, целевая валюта, политика) и возвращающие (конвертированная сумма, курс, timestamp, источник). Кодирует «фиксация 25-го» или «использовать ЦБ-референс для BRL». Вызывает Слой 1; никогда — провайдера.
Слой 3 — Исполнение платежа. Что бы ни двигало деньги (Stripe Connect, Wise Platform, банковский рельс, стейблкоин-мост). Репортит курс, по которому провайдер реально рассчитался — в тот же аудит-след рядом с референсным курсом Слоя 2.
Это разделение — самое важное решение, которое держит код поддерживаемым по мере добавления стран. И делает тестирование реальным — Слой 1 можно стабить фиксированными курсами и исчерпывающе тестировать политики без сети.
Выбор источника курса: mid-market с аудит-следом
Для Слоя 1 нужен mid-market — середина между bid и ask, обновляется минимум раз в минуту, исторические курсы можно опрашивать по дате. Это даёт чистый референс для всего остального.
Finexly отдаёт mid-market курсы, агрегированные у крупных провайдеров ликвидности, с лайв- и историческими эндпоинтами. Первый вызов, чтобы проверить связь:
curl "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY,INR,BRL,PHP,MXN" \
-H "Authorization: Bearer YOUR_API_KEY"Получите JSON с rates, base и timestamp. Для зарплаты важны два поля: timestamp (момент UTC, в который сделан снапшот) и значения валют. Всегда логируйте оба — не только курс.
Подробнее о выборе провайдера: сравнение бесплатных и платных currency API и сравнение Finexly vs Open Exchange Rates vs Fixer.
Фиксация курса: три политики, которые стоит реализовать
Решение «когда фиксируем» — сердце FX в зарплате. Три политики покрывают почти любого реального клиента:
Политика A — Фиксация на дату запуска зарплаты. Просто, защитимо, легко объяснить на расчётке. Курс того же дня; что рынок делает в момент нажатия Run — то и видит сотрудник. Лучший дефолт для выплат подрядчикам.
Политика B — Фиксация на фиксированный день месяца. Клиент с месячной зарплатой может хотеть фиксировать 25-го — расчётки готовятся к 27-му, а платёж уходит 1-го. Убирает волатильность дня запуска из опыта сотрудника.
Политика C — Среднее по периоду. Для длинных периодов (полумесяц, месяц) клиенты иногда предпочитают среднее значение mid-market курсов за период. Сглаживает волатильность, требует исторических запросов на каждый рабочий день окна.
Реализация всех трёх на TypeScript. Вызовы Слоя 1 застаблены как rateService.getRate(...), чтобы было ясно видно логику политики:
// Layer 2: payroll FX policy
type Policy = "run_date" | "fixed_day" | "period_avg";
interface LockedRate {
rate: number;
source: "finexly_mid";
policy: Policy;
policyInputs: Record<string, string | number>;
lockedAt: string; // ISO8601 UTC
rateTimestamp: string; // ISO8601 UTC, from provider
}
async function lockPayrollRate(
base: string,
quote: string,
payPeriodStart: Date,
payPeriodEnd: Date,
payrollRunAt: Date,
policy: Policy,
fixedDay: number = 25
): Promise<LockedRate> {
switch (policy) {
case "run_date": {
const r = await rateService.getRate(base, quote, payrollRunAt);
return {
rate: r.rate,
source: "finexly_mid",
policy,
policyInputs: { runAt: payrollRunAt.toISOString() },
lockedAt: new Date().toISOString(),
rateTimestamp: r.timestamp,
};
}
case "fixed_day": {
const lockDate = new Date(payPeriodEnd);
lockDate.setUTCDate(fixedDay);
// If the fixed day is a weekend, snap back to Friday
const snapped = snapToBusinessDay(lockDate);
const r = await rateService.getRate(base, quote, snapped);
return {
rate: r.rate,
source: "finexly_mid",
policy,
policyInputs: { fixedDay, snappedTo: snapped.toISOString() },
lockedAt: new Date().toISOString(),
rateTimestamp: r.timestamp,
};
}
case "period_avg": {
const days = businessDaysBetween(payPeriodStart, payPeriodEnd);
const rates = await Promise.all(
days.map(d => rateService.getRate(base, quote, d))
);
const avg = rates.reduce((s, r) => s + r.rate, 0) / rates.length;
return {
rate: avg,
source: "finexly_mid",
policy,
policyInputs: {
start: payPeriodStart.toISOString(),
end: payPeriodEnd.toISOString(),
dayCount: rates.length,
},
lockedAt: new Date().toISOString(),
rateTimestamp: rates[rates.length - 1].timestamp,
};
}
}
}Форма возвращаемого LockedRate — контракт с остальной зарплатной системой. Каждый расчёт дальше — gross-to-net, удержания, отображаемая сумма в расчётке, экспортированный платёжный файл — ссылается на этот единственный зафиксированный курс. Никаких повторных котировок.
Запрос исторических курсов из Finexly (Python)
Политикам B и C нужны исторические курсы — эндпоинт Finexly /historical принимает ISO-дату. Python для политики C с ретраями, бэкоффом и идемпотентным кэшем:
import os
import time
import json
import hashlib
import requests
from datetime import date, timedelta
from typing import List
import redis
API_KEY = os.environ["FINEXLY_API_KEY"]
BASE_URL = "https://api.finexly.com/v1"
r = redis.from_url(os.environ["REDIS_URL"])
def _cache_key(base: str, quote: str, on: date) -> str:
return f"fx:{base}:{quote}:{on.isoformat()}"
def get_historical_rate(base: str, quote: str, on: date) -> dict:
"""Return mid-market rate for a base/quote pair on a given UTC date."""
key = _cache_key(base, quote, on)
cached = r.get(key)
if cached:
return json.loads(cached)
url = f"{BASE_URL}/historical"
params = {"base": base, "symbols": quote, "date": on.isoformat()}
headers = {"Authorization": f"Bearer {API_KEY}"}
for attempt in range(4):
try:
resp = requests.get(url, params=params, headers=headers, timeout=10)
resp.raise_for_status()
data = resp.json()
payload = {
"base": data["base"],
"quote": quote,
"rate": data["rates"][quote],
"timestamp": data["timestamp"],
"date": on.isoformat(),
}
# Historical rates are immutable — cache them for 30 days
r.setex(key, 30 * 24 * 3600, json.dumps(payload))
return payload
except (requests.HTTPError, requests.ConnectionError, requests.Timeout):
if attempt == 3:
raise
time.sleep(2 ** attempt)
def business_days(start: date, end: date) -> List[date]:
days, cur = [], start
while cur <= end:
if cur.weekday() < 5: # Mon-Fri
days.append(cur)
cur += timedelta(days=1)
return days
def period_average_rate(base: str, quote: str, start: date, end: date) -> dict:
days = business_days(start, end)
if not days:
raise ValueError("No business days in period")
rates = [get_historical_rate(base, quote, d)["rate"] for d in days]
avg = sum(rates) / len(rates)
return {
"base": base,
"quote": quote,
"rate": avg,
"policy": "period_avg",
"day_count": len(days),
"first_day": days[0].isoformat(),
"last_day": days[-1].isoformat(),
}В этом коде два неочевидных, но важных момента. Первое: исторические курсы неизменны — курс USD/EUR Finexly на 2026-04-03 навсегда один и тот же — значит 30-дневный кэш безопасен и режет объём вызовов на 95%+ для любой зарплатной системы с регулярными подрядчиками. Второе: ретрай-петля использует экспоненциальный бэкофф, потому что зарплатные батчи у тысяч клиентов обычно идут в одно и то же воскресное окно, а FX-провайдер — общий ресурс.
Подробнее о паттернах кэширования и обработки ошибок — гайд по кэшированию и обработке ошибок в currency API.
Идемпотентность и аудит-след (PHP)
Самая недооценённая вещь, которую FX-слой зарплаты может сделать — хранить зафиксированный курс по ключу идемпотентности, чтобы ретрай зарплатного рана переиспользовал тот же курс. PHP-реализация, оборачивающая Finexly в идемпотентный сервис на Postgres:
<?php
declare(strict_types=1);
class PayrollFx {
public function __construct(
private \PDO $db,
private string $apiKey,
private string $baseUrl = "https://api.finexly.com/v1"
) {}
public function lockOnRunDate(
string $idempotencyKey,
string $base,
string $quote,
\DateTimeImmutable $runAt
): array {
// 1. Have we already locked this key?
$stmt = $this->db->prepare(
"SELECT rate, rate_timestamp, source FROM payroll_fx_locks
WHERE idempotency_key = :k"
);
$stmt->execute([":k" => $idempotencyKey]);
$existing = $stmt->fetch(\PDO::FETCH_ASSOC);
if ($existing) {
return $existing + ["replay" => true];
}
// 2. Fetch fresh mid-market from Finexly
$url = sprintf(
"%s/latest?base=%s&symbols=%s",
$this->baseUrl, urlencode($base), urlencode($quote)
);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . $this->apiKey],
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
throw new \RuntimeException("Finexly returned $code");
}
$data = json_decode($body, true);
$rate = $data["rates"][$quote] ?? null;
if ($rate === null) {
throw new \RuntimeException("Missing $quote in response");
}
// 3. Persist atomically (UNIQUE on idempotency_key)
$ins = $this->db->prepare(
"INSERT INTO payroll_fx_locks
(idempotency_key, base_currency, quote_currency, rate,
rate_timestamp, source, locked_at, run_at)
VALUES (:k, :b, :q, :r, :rt, 'finexly_mid', NOW(), :ra)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING rate, rate_timestamp, source"
);
$ins->execute([
":k" => $idempotencyKey,
":b" => $base,
":q" => $quote,
":r" => $rate,
":rt" => $data["timestamp"],
":ra" => $runAt->format("c"),
]);
$row = $ins->fetch(\PDO::FETCH_ASSOC);
if (!$row) {
// Race: another worker won. Re-read.
$stmt->execute([":k" => $idempotencyKey]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
}
return $row + ["replay" => false];
}
}Естественный ключ идемпотентности — payroll_run_id:employee_id:base:quote — внутри одного рана коллизии невозможны, а ретраи (повторная доставка очереди или двойной клик оператора) получают ровно тот же курс без второго вызова API.
Больше паттернов PHP-интеграции — в гайде по PHP-интеграции currency API.
Выходные, праздники и закрытые рынки
FX-рынки закрыты с 17:00 пятницы Нью-Йорка до 17:00 воскресенья Сиднея. Три разумные политики внутри этого окна: откат к последнему close (правильный дефолт для зарплаты — стабильно и объяснимо), roll forward к следующему open (превью), или отказ котировать (разовые крупные переводы).
Исторический эндпоинт Finexly автоматически снапит к последнему рабочему дню — запрос на субботу вернёт close пятницы, timestamp указывает на пятницу. Всегда доверяйте возвращённому timestamp, а не запрошенной дате. Локальные банковские праздники (бразильский карнавал, индийский Дивали, китайский Новый год) требуют отдельной таблицы — FX-рынок может быть открыт, но целевой рельс — нет, так что флагируйте дату зачисления отдельно.
Округление, отображение и арифметика расчётки
Когда курс есть, арифметика всё ещё должна быть верной. Три правила, спасающие от тикетов саппорта:
- Умножать в полной точности, округлять один раз. Считайте
amount_local = amount_usd * ratedecimal-типом с минимум 10 значащими цифрами, затем округляйте до младших единиц ISO 4217 целевой валюты. JPY — 0 знаков; USD/EUR — 2; KWD/BHD — 3; CLF — 4. - Округление half-to-even (банковское). Уменьшает накопленный bias на тысячах расчёток. По умолчанию
Math.roundв JavaScript — half-away-from-zero; используйте decimal-библиотеку (decimal.js,bignumber.js). - Показывайте курс с достаточной точностью. На расчётке — минимум 6 значащих цифр (
0,911234, не0,91). Сотрудник, скопировавший курс в калькулятор, должен воспроизвести локальную сумму до копейки.
Сквозной пример
Американская финтех-компания делает ежемесячные выплаты подрядчикам. Maria в Мехико, контракт на USD 4 800/мес. Политика клиента: «фиксация 25-го, выравнивание на конец периода, mid-market референс, расчёт 1-го через Stripe Connect».
2026-04-25 FX-слой зарплаты вызывается с idempotency_key="run_2026_04:contractor_847:USD:MXN". Он запрашивает Finexly historical для USD→MXN на эту дату, получает 17,8642, фиксирует. Расчётка показывает «USD 4 800,00 → MXN 85 748,16 по 17,8642 USD/MXN (mid-market, Finexly, 2026-04-25 21:00 UTC)».
2026-05-01 Stripe Connect рассчитывается. API payout Stripe возвращает свой exchange_rate — например, 17,8201 (mid-market минус 25 bps спреда). Оба курса идут в аудит-таблицу. Экспорт 1099 использует зафиксированный курс; сверка GL — расчётный; разница уходит на FX-счёт. Вот так выглядит хорошо.
Распространённые ошибки
Шаблоны, которые мы видим раз за разом в код-ревью зарплатных систем:
- Перекотировка при ретрае. Разные курсы при ретрае = разные gross-to-net при неизменном ране. Всегда кэшируйте по ключу идемпотентности.
Date.now()как timestamp курса. Это ваши часы, а не провайдера. Логируйтеtimestampпровайдера.- Тихий фолбэк на устаревший кэш. Если в простое падаете на кэш — пометьте это на расчётке; никогда не подавайте старое как живое.
- Деньги в плавающей точке.
4800 * 17.8642неодинаково на разных машинах. Decimal-библиотека для всего, что касается денег. - Один глобальный курс на ран. Разным сотрудникам могут нужны разные политики (бразильский подрядчик на PTAX, индийский на RBI). Резолвить пер-сотрудник.
Часто задаваемые вопросы
Какой курс лучше для трансграничной зарплаты? Mid-market — середина между bid и ask — стандартный референс. Сотрудник проверит в Google или Bloomberg, и именно это «честный рыночный курс» большинства договоров. Реальный рельс возьмёт mid-market плюс спред; логируйте оба, показывайте mid-market на расчётке.
Фиксировать курс на дату запуска или на дату платежа? Что бы ни выбрал клиент — оба варианта защитимы. Фиксация на дату запуска даёт сотруднику фиксированный preview; фиксация на дату платежа совпадает с поведением рельса. Главное — кодировать политику явно, а не имплицитно через дату запуска джобы.
Как обрабатывать раны на выходных или праздниках?
Использовать последний close. Исторический эндпоинт Finexly автоматически снапит к последнему рабочему дню; доверяйте timestamp. Для банковских праздников страны-получателя флагируйте дату зачисления на расчётке, но FX-курс используйте как обычно.
Нужны ли референс-курсы ЦБ для каких-то стран? Для некоторых — да: Бразилия (PTAX), Индия для отчётности по нерезидентам (RBI), Аргентина (BCRA). Слой курсов должен принимать переопределение по юрисдикции и фолбечиться на mid-market для всего остального.
Какая точность курса нужна на расчётке?
Минимум 6 значащих цифр при отображении — 17,8642, не 17,86. При расчёте — decimal с 10+ цифрами, округлять только в конце. Сотрудники точно вобьют курс в калькулятор.
Можно ли использовать бесплатное currency API в проде? Free-тариф работает при очень малом объёме, но у большинства есть лимиты (только дневные курсы, нет истории, 1 000 запросов в месяц), которые ломаются на первом международном найме. Сравнение — в гайде по бесплатным vs платным currency API.
Заключение
Конвертация валют в трансграничной зарплате выглядит как одно умножение, а оказывается шестью переплетёнными решениями: политика источника, дата фиксации, идемпотентность, округление, обработка выходных и аудит-след. Поставьте правильную трёхслойную архитектуру — Слой курсов, FX-политика, Исполнение платежа — и каждое из этих решений превращается в маленькую тестируемую функцию вместо хранимой процедуры, которую никто не хочет трогать.
Готовы подключить mid-market курсы в реальном времени к своему движку зарплаты? Получите бесплатный ключ Finexly API — без кредитной карты. 1 000 запросов в месяц хватит, чтобы прогнать все примеры выше, а платные тарифы масштабируются под любого подрядчика, которого вы будете онбордить. Загляните в документацию API Finexly или сравните Finexly с другими провайдерами.
Explore More
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 →