Назад к блогу

Конвертация валют в трансграничном расчёте заработной платы: руководство разработчика по FX в реальном времени (2026)

V
Vlado Grigirov
May 14, 2026
Currency API Exchange Rates Payroll Cross-Border Payments Developer Guide Fintech Finexly

Если в 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. Для конвертера на маркетинговой странице это нормально. Для зарплаты — нет, по шести причинам, которые кусают одновременно:

  1. Курс должен быть воспроизводимым. Когда сотрудник или аудитор спросит, почему мартовская зарплата вышла ¥608 243 вместо ¥609 118, нужно указать на конкретный курс, временную метку и источник. «Что Stripe котировал в момент выплаты» — это ответ, который не переживёт аудит.
  2. Дата фиксации — решение политики, а не баг. Дата запуска? Конец периода? 25-е число для месячной зарплаты? Каждое решение по-разному влияет на FX-риск, предсказуемость для сотрудника и налоговую отчётность. Ваш код должен закодировать политику, которую выбрал CFO — и дать менять её без деплоя.
  3. Mid-market и «курс платежа» — разные вещи. Mid-market курс — это середина между bid и ask, то, что показывают Google или Bloomberg. Курс, который реально двигает деньги через SWIFT, локальный рельс или стейблкоин-мост, всегда несёт спред. Показывайте mid-market как референс и отдельно трекайте курс, который фактически использовал платёжный провайдер, чтобы сходилась сверка.
  4. Идемпотентность важна. Зарплатные раны ретраются — таймауты задач, повторная доставка очереди, оператор кликнул дважды. Если FX-лукап не идемпотентен для (сотрудник, период), ретрай вернёт другой курс и даст другой gross-to-net.
  5. Выходные, праздники и правила юрисдикций. 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-рынок может быть открыт, но целевой рельс — нет, так что флагируйте дату зачисления отдельно.

Округление, отображение и арифметика расчётки

Когда курс есть, арифметика всё ещё должна быть верной. Три правила, спасающие от тикетов саппорта:

  1. Умножать в полной точности, округлять один раз. Считайте amount_local = amount_usd * rate decimal-типом с минимум 10 значащими цифрами, затем округляйте до младших единиц ISO 4217 целевой валюты. JPY — 0 знаков; USD/EUR — 2; KWD/BHD — 3; CLF — 4.
  2. Округление half-to-even (банковское). Уменьшает накопленный bias на тысячах расчёток. По умолчанию Math.round в JavaScript — half-away-from-zero; используйте decimal-библиотеку (decimal.js, bignumber.js).
  3. Показывайте курс с достаточной точностью. На расчётке — минимум 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 с другими провайдерами.

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 →