Finance OS / API

Sandbox

Песочница - изолированное окружение для разработки, автотестов и приёмки. Реальных операций в ней не происходит: денег нет, транзакций в блокчейне нет, платные вызовы не тарифицируются. При этом контракт API, коды ошибок, проверки и вебхуки - те же самые, что в бою, поэтому собранная в песочнице интеграция работает на боевом ключе без переделок.

Как включить

Окружение определяется префиксом ключа - отдельного заголовка или параметра нет.

Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Тестовый ключ создаётся в личном кабинете: API-ключи → переключатель MAIN / SANDBOX → Sandbox → Сгенерировать.

Полная изоляция окружений
  • Клиенты, созданные ключом sk_test_, получают env='sandbox'.
  • Боевой ключ таких клиентов не видит - 404, и наоборот.
  • Балансы, история, подписки на вебхуки, журнал AML-проверок - отдельные в каждом окружении.
  • Мастер-счёт в песочнице синтетический: реальных денег тестовый ключ не касается вовсе.

Чем песочница отличается от боевого окружения

Адреса пополнения optional
Возвращается синтетический адрес с префиксом SBX и признаком sandbox: true в ответе. Кастоди за ним нет - не отправляйте на него реальные средства. Адрес детерминирован: один и тот же клиент в одной сети всегда получает один и тот же адрес.
Депозиты optional
Приходят только по вызову simulate-deposit (см. ниже). Принудительная сверка POST /wallet/sync в песочнице всегда возвращает new_deposits: 0 - сверять нечего.
Выводы optional
Заявка проходит все те же проверки, а затем автоматически завершается синтетическим хешем вида sbx:…: стадии processing → sending → sent → confirmed проигрываются сразу, вместе с вебхуками customer.withdrawal.sent и customer.withdrawal.confirmed. Ждать подтверждений сети не нужно.
Комплаенс (AML) optional
Отчёт вычисляется детерминированно по самому адресу: один и тот же адрес всегда даёт один и тот же risk_score. Это позволяет писать стабильные автотесты на реакцию «чистый / рисковый». Внешние источники данных не опрашиваются.
Тарификация optional
Платные вызовы в песочнице бесплатны: с мастер-счёта ничего не списывается, в ответе AML-проверки поле price равно null. Ошибку 402 service_balance_insufficient тестовым ключом воспроизвести нельзя.
Мастер-счёт optional
Все эндпоинты раздела отвечают синтетикой с признаком sandbox: true: баланс нулевой, леджер пуст, внутренние переводы не двигают денег, но отвечают той же формой, что и в бою (с wallet_balance и duplicate). Витрина тарифа (service-pricing) при этом отдаёт настоящие действующие условия.
Расчёты charge / payout optional
Работают на синтетических остатках клиента: сделайте simulate-deposit, чтобы было что списывать.
Вебхуки optional
Работают полноценно: подписка, подпись, лестница повторов. Подписка, созданная тестовым ключом, получает только sandbox-события, поле env в теле равно sandbox.
Проверки и лимиты optional
Действуют те же: минимальные суммы, лимиты на операцию и суточные, формат адреса, блокировка клиента, ожидание комплаенс-вердикта, лимиты частоты запросов. Граничные случаи проверяются в песочнице точно так же, как в бою.
Курсы optional
Настоящие: математика пересчёта тестируется на живых значениях.
Что песочницей не проверить
Часть сервисов не имеет тестового окружения и отвечает тестовому ключу отказом 403 sandbox_unsupported или 404: приём платежей в рублях и анонимный своп работают только с ключом sk_live_. Ошибку нехватки средств на мастер-счёте (402) и реальную скорость подтверждений сети песочница тоже не воспроизводит.

Симуляция депозита

POST /api/v1/test/customers/{uuid}/simulate-deposit
Public

Сымитировать поступление на кошелёк клиента

Прогоняет полный конвейер зачисления: баланс клиента, строка журнала со статусом pending, вебхук customer.deposit.detected, комплаенс-проверка. Боевым ключом маршрут отвечает 404.

Body

coin string required
Тикер монеты, например USDT.
network string optional
Сеть, если тикер существует в нескольких.
amount number required
Сумма поступления, больше нуля.

Responses

{
  "data": {
    "id": "1e7b6a54-0c2f-4b39-9d81-6f2a4c8e0b17",
    "network": "TRON",
    "asset": "USDT",
    "direction": "in",
    "amount": "100.000000000000000000",
    "address": "SBXTRON4F2A9C1E7B6A540C2F4B399D816F2A4C",
    "counterparty": "SBXSENDER",
    "tx_hash": "sbx:1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d",
    "status": "pending",
    "confirmations": 0,
    "aml_status": "pending",
    "frozen": false,
    "fee": { "platform": "0.000000000000000000", "merchant": "0.000000000000000000" },
    "confirmed_at": null,
    "created_at": "2026-08-17T12:45:00+00:00"
  },
  "sandbox": true
}
{
  "error": {
    "code": "not_found",
    "message": "Ресурс не найден.",
    "request_id": "req_Zx8Cv6Bn4Mk2Lj0Hg9Fd7Sa5"
  }
}

Узнаваемые значения песочницы

Адрес кошелька optional
Начинается с SBX, далее имя сети и детерминированный хвост: SBXTRON4F2A9C…. Ни в одном блокчейн-обозревателе не существует.
Хеш транзакции optional
Начинается с sbx:. Тоже недействителен вне песочницы.
Отправитель optional
У симулированного поступления counterparty равен SBXSENDER.
Признак ответа optional
Синтетические ответы содержат поле sandbox: true на верхнем уровне (или в объекте адреса) - удобная страховка от случайного запуска тестов боевым ключом.
SMS-код KYC optional
000000 - единственный код, который проходит проверку.

Сквозной сценарий

Минимальная приёмка контура кошельков: клиент → адрес → депозит → баланс → вывод → расчёт. Занимает несколько секунд и не требует ожиданий - подтверждения в песочнице мгновенные.

KEY="sk_test_..."
B="https://fin-os.io/api/v1"
H="Authorization: Bearer $KEY"

# 1. Клиент (телефон обязателен)
UUID=$(curl -s -X POST $B/customers -H "$H" -H "Content-Type: application/json" \
  -d '{"name":"Sandbox Test","phone":"+79001112233"}' | jq -r .data.uuid)

# 2. Адрес пополнения - синтетический, SBX...
curl -s -X POST $B/customers/$UUID/wallet/addresses -H "$H" \
  -H "Content-Type: application/json" -d '{"chain":"TRON"}'

# 3. Депозит без блокчейна
curl -s -X POST $B/test/customers/$UUID/simulate-deposit -H "$H" \
  -H "Content-Type: application/json" -d '{"coin":"USDT","network":"TRON","amount":"100"}'

# 4. Баланс: 100 USDT_TRON
curl -s -X GET $B/customers/$UUID/wallet -H "$H"

# 5. Предпросмотр и вывод: завершится сразу, status=confirmed.
#    Скрин депозита асинхронный даже в песочнице: ответ 409
#    aml_screening_in_progress = подождите секунду и повторите
#    вывод с тем же Idempotency-Key
curl -s -G $B/customers/$UUID/wallet/withdraw/quote -H "$H" \
  --data-urlencode "coin=USDT" --data-urlencode "network=TRON" \
  --data-urlencode "address=TWd4WrZ9wn84f5x1hZhL4DHvk738ns5jwb" \
  --data-urlencode "amount=20"

curl -s -X POST $B/customers/$UUID/wallet/withdraw -H "$H" \
  -H "Content-Type: application/json" -H "Idempotency-Key: sbx-wd-1" \
  -d '{"coin":"USDT","network":"TRON","address":"TWd4WrZ9wn84f5x1hZhL4DHvk738ns5jwb","amount":"20"}'

# 6. Расчёт: списать с клиента в свою пользу
curl -s -X POST $B/customers/$UUID/wallet/charge -H "$H" \
  -H "Content-Type: application/json" -H "Idempotency-Key: sbx-chg-1" \
  -d '{"coin":"USDT","network":"TRON","amount":"5","reason":"Комиссия сервиса"}'

# 7. Бесплатная AML-проверка (price=null)
curl -s -X POST $B/aml/checks -H "$H" \
  -H "Content-Type: application/json" \
  -d '{"address":"TXYZ1234567890abcdefGHIJKLmnop","network":"tron"}'

Чек-лист перед боевым запуском

  • Обработчик вебхуков проверяет подпись и отвечает 2xx быстрее 10 секунд - проверьте вызовом POST /api/v1/webhooks/{id}/test.
  • Повторная доставка события не приводит к двойной обработке (дедуп по id).
  • Денежные вызовы отправляются с Idempotency-Key, и ретрай использует тот же ключ.
  • Разбор ошибок ветвится по error.code, а не по тексту message.
  • Стадия вывода читается из поля state, завершение - из status.
  • Мастер-счёт пополнен: в бою первая же AML-проверка на пустом счёте вернёт 402.
  • Подписки на вебхуки созданы боевым ключом отдельно: sandbox-подписки в бою не работают.

Очистка данных

Sandbox-клиенты и их операции автоматически не удаляются. Удалить клиента можно вызовом DELETE /api/v1/customers/{uuid}, но помните об ограничениях: клиент с выданными адресами, историей движений или ненулевым остатком не удаляется (см. Customers). Для полной очистки окружения напишите на admin@fin-os.io.

Примеры кода

# Минимальная приёмка контура кошельков в песочнице
KEY="sk_test_..."
B="https://fin-os.io/api/v1"
H="Authorization: Bearer $KEY"

UUID=$(curl -s -X POST $B/customers -H "$H" -H "Content-Type: application/json" \
  -d '{"name":"Sandbox Test","phone":"+79001112233"}' | jq -r .data.uuid)

curl -s -X POST $B/customers/$UUID/wallet/addresses -H "$H" \
  -H "Content-Type: application/json" -d '{"chain":"TRON"}' | jq .data

curl -s -X POST $B/test/customers/$UUID/simulate-deposit -H "$H" \
  -H "Content-Type: application/json" -d '{"coin":"USDT","network":"TRON","amount":"100"}' | jq .data.status

curl -s -X GET $B/customers/$UUID/wallet -H "$H" | jq '.data[] | select(.currency=="USDT_TRON")'
// CI-прогон контура кошельков на тестовом ключе
const B   = 'https://fin-os.io/api/v1';
const KEY = process.env.FINOS_SANDBOX_KEY;      // sk_test_...
const H   = { 'Authorization': `Bearer ${KEY}`, 'Content-Type': 'application/json' };

async function call(path, opts = {}) {
  const res  = await fetch(`${B}${path}`, { ...opts, headers: { ...H, ...(opts.headers || {}) } });
  const body = await res.json();
  if (!res.ok) throw Object.assign(new Error(body.error.message), body.error);
  return body;
}

export async function walletsSmokeTest() {
  const { data: customer } = await call('/customers', {
    method: 'POST', body: JSON.stringify({ name: 'CI Smoke', phone: `+7900${Date.now() % 10000000}` }),
  });

  const { data: address } = await call(`/customers/${customer.uuid}/wallet/addresses`, {
    method: 'POST', body: JSON.stringify({ chain: 'TRON' }),
  });
  console.assert(address.address.startsWith('SBX'), 'ожидался синтетический адрес');

  await call(`/test/customers/${customer.uuid}/simulate-deposit`, {
    method: 'POST', body: JSON.stringify({ coin: 'USDT', network: 'TRON', amount: '100' }),
  });

  // В песочнице вывод завершается сразу: status=confirmed, state=confirmed.
  // Скрин депозита асинхронный: 409 aml_screening_in_progress - это не ошибка,
  // а «подождите» - повторяем с тем же Idempotency-Key.
  let wd;
  for (let attempt = 0; ; attempt++) {
    try {
      ({ data: wd } = await call(`/customers/${customer.uuid}/wallet/withdraw`, {
        method: 'POST',
        headers: { 'Idempotency-Key': `ci-${customer.uuid}` },
        body: JSON.stringify({ coin: 'USDT', network: 'TRON', address: 'TWd4WrZ9wn84f5x1hZhL4DHvk738ns5jwb', amount: '20' }),
      }));
      break;
    } catch (e) {
      if (e.code !== 'aml_screening_in_progress' || attempt >= 10) throw e;
      await new Promise(r => setTimeout(r, 1000));
    }
  }
  console.assert(wd.status === 'confirmed', 'вывод должен закрыться сразу');
}
// Pest / PHPUnit: смоук контура кошельков
test('sandbox wallets flow completes', function () {
    $api = Http::withToken(config('services.finos.sandbox_key'))   // sk_test_
        ->acceptJson()
        ->baseUrl('https://fin-os.io/api/v1');

    $customer = $api->post('/customers', [
        'name'  => 'CI Smoke',
        'phone' => '+7900' . random_int(1000000, 9999999),
    ])->json('data');

    $uuid = $customer['uuid'];

    $address = $api->post("/customers/{$uuid}/wallet/addresses", ['chain' => 'TRON'])->json('data');
    expect($address['address'])->toStartWith('SBX');
    expect($address['sandbox'])->toBeTrue();

    $api->post("/test/customers/{$uuid}/simulate-deposit", [
        'coin' => 'USDT', 'network' => 'TRON', 'amount' => 100,
    ])->assertStatus(201);

    $balance = collect($api->get("/customers/{$uuid}/wallet")->json('data'))
        ->firstWhere('currency', 'USDT_TRON');

    expect(bccomp($balance['available'], '100', 10))->toBe(0);

    // Бесплатно: в песочнице price всегда null
    $check = $api->post('/aml/checks', ['address' => 'TXYZ1234567890abcdefGHIJKLmnop', 'network' => 'tron'])->json('data');
    expect($check['price'])->toBeNull();
});
# pytest: смоук контура кошельков
import os, random, requests

B   = 'https://fin-os.io/api/v1'
KEY = os.environ['FINOS_SANDBOX_KEY']   # sk_test_...
H   = {'Authorization': f'Bearer {KEY}'}


def test_sandbox_wallets_flow():
    phone = f'+7900{random.randint(1000000, 9999999)}'

    customer = requests.post(f'{B}/customers', headers=H,
                             json={'name': 'CI Smoke', 'phone': phone}).json()['data']
    uuid = customer['uuid']

    address = requests.post(f'{B}/customers/{uuid}/wallet/addresses',
                            headers=H, json={'chain': 'TRON'}).json()['data']
    assert address['address'].startswith('SBX')

    r = requests.post(f'{B}/test/customers/{uuid}/simulate-deposit', headers=H,
                      json={'coin': 'USDT', 'network': 'TRON', 'amount': 100})
    assert r.status_code == 201
    assert r.json()['sandbox'] is True

    # Вывод в песочнице закрывается сразу
    wd = requests.post(f'{B}/customers/{uuid}/wallet/withdraw',
                       headers={**H, 'Idempotency-Key': f'ci-{uuid}'},
                       json={'coin': 'USDT', 'network': 'TRON',
                             'address': 'TWd4WrZ9wn84f5x1hZhL4DHvk738ns5jwb',
                             'amount': '20'}).json()['data']
    assert wd['status'] == 'confirmed'
    assert wd['state'] == 'confirmed'