# SDK платформы лояльности

Клиентские библиотеки для интеграторов — касс, сайтов, CRM и собственных продуктов компании — на PHP и
TypeScript (ADR-0011). Они берут на себя то, что иначе каждый интегратор пишет сам: аутентификацию и токены
партнёра, ключи идемпотентности, безопасные повторы, разбор ошибок, перебор страниц и проверку подписей вебхуков.
Оба SDK покрывают Runtime API (`docs/api/runtime-v1.yaml`) и Management API (`docs/api/management-v1.yaml`) и
ведут себя одинаково — по контракту ниже.

| Каталог | Что внутри |
|---|---|
| [`generator/`](generator/README.md) | генератор кода SDK из спецификаций (закрытый мир) |
| `php/` | PHP SDK `loyal/sdk`: PHP 8.2+, `ext-curl`, `ext-json`, без зависимостей Composer во время выполнения |
| `typescript/` | TypeScript SDK: ESM, Node.js 22+, Deno, Bun, Cloudflare Workers, без зависимостей во время выполнения |
| [`conformance/`](conformance/README.md) | эталонные векторы поведения, общие для обоих SDK |
| `VERSION` | версия обоих SDK (0.x до заморозки API перед пилотом) |

## Генерация

Методы операций, типы, метаданные операций, константы кодов ошибок и версии генерируются из спецификаций:

```powershell
composer sdk:generate                    # после любого изменения docs/api/*.yaml
php sdk/generator/bin/generate --check   # только проверить, что сгенерированное актуально
```

- **Каталоги `sdk/php/src/Generated` и `sdk/typescript/src/generated` руками не правятся никогда.** Тест сравнивает
  их со свежей генерацией и падает при расхождении. Не хватает конструкции схемы — сначала расширяется генератор с
  тестами ([generator/README.md](generator/README.md)).
- Сгенерированный код коммитится вместе с изменением спецификации и проходит ревью.
- Метаданные операции (`<Api>Spec::OPERATIONS` в PHP, `<api>Operations` в TS): `method`, `path` (шаблон),
  `body` (`none`, `required`, `optional`), `idempotency` (`none`, `required`, `optional`), `merchant` (отправлять
  `Loyal-Merchant` клиенту, выбравшему мерчанта), `safe` (`x-safe-to-retry`), `statuses`, `content` (`false` — ответ
  без тела, 204), `paginated`.
- Всё остальное пишется руками: ядро `ClientBase` (PHP `Loyal\Sdk\Internal\ClientBase`, TS `src/client-base.ts`)
  отправляет запросы по метаданным и реализует контракт ниже; сигнатуры `call()` и `paginate()`, которые вызывает
  сгенерированный код, описаны в README генератора. Выдача токена (`issueToken`, RFC 6749) не генерируется и
  написана руками.

## Контракт поведения

Контракт одинаков для обоих SDK и закреплён векторами [`conformance/`](conformance/README.md): изменение поведения
начинается с вектора, затем меняются оба SDK. Новый механизм транспорта без вектора не принимается.

### Учётные данные и окружение

| Клиент | Учётные данные | Формат (рабочее окружение) |
|---|---|---|
| `RuntimeClient` | ключ кассы | `lk_[a-z0-9]{12}\.[A-Za-z0-9_-]{43}` |
| `ManagementClient` мерчанта | ключ мерчанта | `lm_[a-z0-9]{12}\.[A-Za-z0-9_-]{43}` |
| `ManagementClient` партнёра | id и секрет клиента OAuth → токен доступа | `lc_[a-z0-9]{24}`, `lcs_[A-Za-z0-9_-]{43}` → `lat_[A-Za-z0-9_-]{43}` |

- В песочнице после первого `_` стоит `test_`: `lk_test_…`, `lm_test_…`, `lc_test_…`, `lcs_test_…`, `lat_test_…`.
  По этому маркеру SDK определяет окружение клиента (`environment()`: `Live` или `Sandbox`). У id и секрета клиента
  OAuth окружение должно совпадать.
- Формат проверяется при создании клиента, иначе — ошибка `configuration`. Это отсекает пробелы и переводы строк
  (внедрение заголовков) и перепутанные id и секрет.
- Заголовок ответа `Loyal-Environment` (`live` или `sandbox`) SDK показывает в метаданных ответа, но не сверяет с
  учётными данными: учётные данные другого окружения отвергает сервер (401 `environment_mismatch`, у выдачи токена —
  `invalid_client`).
- Секреты не попадают в сообщения ошибок, дампы (`__debugInfo`, `toJSON`, `inspect`) и сериализацию; SDK ничего
  не пишет в логи.

### Адрес запроса

- `baseUrl` — абсолютный `https://`-адрес без запроса и фрагмента, можно с префиксом пути; `http://` — только для
  `localhost`, `127.0.0.1` и `[::1]`. Завершающий `/` отбрасывается. Иначе — `configuration`.
- Адрес = `baseUrl` + шаблон пути операции с подставленными значениями + `?` + параметры запроса.
- **Значения пути** кодируются по RFC 3986, как `rawurlencode` в PHP: все символы, кроме `A–Z a–z 0–9 - . _ ~`,
  заменяются на `%XX` от байтов UTF-8 (шестнадцатеричные цифры заглавные). В TS — `encodeURIComponent` и
  дополнительно `! ' ( ) *`. Двоеточие действия (`/receipts/{receipt}:confirm`) — часть шаблона и не кодируется;
  двоеточие внутри значения становится `%3A`, а `/` — `%2F`.
- **Пустое значение пути** — ошибка `configuration` до отправки и до получения токена: иначе
  `/receipts/{receipt}:confirm` превратился бы в другой адрес. Строка `"0"` — не пустая.
- **Параметры запроса** — в порядке, в котором их передаёт сгенерированный метод (порядок спецификации):
  - `null` (в TS и `undefined`) не отправляется; пустая строка отправляется как `name=`;
  - логические — `true` и `false`, целые — десятичной записью;
  - строки кодируются так же, как значения пути: пробел — `%20`, `+` — `%2B`, `=` — `%3D`;
  - перебор страниц (`*All`) добавляет `cursor` последним.

### Заголовки запроса

SDK отправляет только эти заголовки (и заголовки вызывающего из `RequestOptions`, которые не могут заменить ни
один из них — иначе `configuration`):

| Заголовок | Когда | Значение |
|---|---|---|
| `Authorization` | всегда | `Bearer <ключ кассы, ключ мерчанта или токен партнёра>`; у запроса токена — `Basic …` |
| `Accept` | всегда | `application/json` |
| `Content-Type` | только когда есть тело | `application/json`; у запроса токена — `application/x-www-form-urlencoded` |
| `User-Agent` | всегда | `loyal-sdk-php/<VERSION>` или `loyal-sdk-js/<VERSION>`; через пробел — `appInfo` интегратора (`MyPOS/2.1`) |
| `Idempotency-Key` | операции с `idempotency` = `required` или `optional` | см. «Идемпотентность» |
| `Loyal-Merchant` | операции с `merchant` = `true`, если клиент выбрал мерчанта | id мерчанта |

- Учётные данные — только в `Authorization`: никогда в адресе, параметрах запроса или теле.
- `forMerchant($id)` возвращает новый клиент партнёра (исходный не меняется). `Loyal-Merchant` уходит только у
  операций, которые его объявляют; операциям партнёра вне мерчантов (мерчанты, каталог событий) он не отправляется
  даже у выбравшего мерчанта клиента. Клиент без мерчанта не отправляет его никогда — сервер ответит 400
  `merchant_required`. Ключ мерчанта уже задаёт мерчанта: `forMerchant` у такого клиента — `configuration`.

### Тело запроса

- `body` = `none` — тела и `Content-Type` нет.
- `required` — тело JSON.
- `optional` — не передано (`null`, `undefined`) — тела и `Content-Type` нет; пустой объект — тело `{}`.
- Все тела запросов спецификаций — объекты, поэтому в PHP пустой массив верхнего уровня кодируется как `{}`. Пустой
  вложенный объект в PHP передаётся как `new \stdClass` (пустой массив — это `[]`).
- Деньги, баллы и количества — целые в минимальных единицах; дробных чисел SDK не создаёт.

### Ответ

- **2xx** — успех. Если у операции есть тело ответа (`content` = `true`), тип содержимого должен быть
  `application/json` (параметры вроде `charset` не важны), а тело — разбираться как JSON; результат — раскодированное
  значение (PHP — ассоциативные массивы, TS — объекты). Иначе — `unexpected_response`. У операций без содержимого
  (204) результат — `null` (`void`), тело не читается.
- **3xx** — перенаправления не выполняются никогда (учётные данные не уходят на другой хост): `unexpected_response`.
- **4xx и 5xx** — ошибка класса по статусу (см. «Ошибки»). Если тело — JSON-объект (`application/problem+json` или
  `application/json`), из него берутся `code`, `title`, `detail`, `errors`, `request_id` и поля расширений; иначе
  они пустые.
- Прочие статусы — `unexpected_response`.
- Имена заголовков ответа сравниваются без учёта регистра.

### Идемпотентность

- У операций с `Idempotency-Key` (обязательным в Runtime API, необязательным у `adjustMemberPoints` в Management API)
  ключ — `options.idempotencyKey` вызывающего или UUIDv4 в нижнем регистре, созданный SDK из криптостойкого
  источника (`random_bytes`, `crypto.randomUUID`).
- **Один ключ на вызов:** тот же во всех попытках, повторах и в повторе после обновления токена; у каждого нового
  вызова — новый ключ.
- Ключ есть у каждой ошибки вызова (`idempotencyKey()`): касса сохраняет его и повторяет вызов позже с тем же ключом.
  Рекомендуемые ключи — детерминированные, например `receipt:{касса}:{бизнес-дата}:{номер}`.
- Ключ вызывающего — от 1 до 255 видимых символов ASCII (`0x21`–`0x7E`), иначе `configuration` (Management API
  принимает не длиннее 200). Ключ у операции без `Idempotency-Key` — тоже `configuration`: он ничего бы не защитил,
  но сделал бы повтор похожим на безопасный.
- Сервер: повтор с тем же ключом и телом возвращает сохранённый ответ с `Idempotent-Replayed: true` (виден в
  метаданных ответа); тот же ключ с другим телом — 422 `idempotency_key_reused`; параллельный повтор — 409.

### Повторы

`maxRetries` — число повторов (попыток — на одну больше): 2 по умолчанию, задаётся клиенту и отдельному вызову.
`maxRetryAfter` — 60 с. Запрос токена партнёра повторяется только по настройкам клиента, а не вызова (см. «Токены
партнёра»).

| Что случилось | Какие запросы повторяются |
|---|---|
| соединение не установлено, запрос не ушёл (DNS, отказ, таймаут соединения, TLS) | любые |
| таймаут или обрыв после отправки; ответ 502, 503, 504 | только безопасные для повтора |
| 429 | любые (ограничитель отказывает до обработки), если `Retry-After` не больше `maxRetryAfter` |
| любой другой статус: 400, 401, 403, 404, 409, 410, 422, 500… | никакие |

- **Безопасный для повтора** запрос: `GET`; запрос с `Idempotency-Key`; операция с `safe` = `true`
  (`x-safe-to-retry`: `calculateReceipt`, `lookupMember`). `PUT` и остальные `POST` безопасными не считаются.
- **Пауза** перед повтором номер `n` (с 0): `Retry-After` × 1000 мс, если ответ его несёт целым числом секунд;
  иначе полный разброс — случайное целое от 0 до `min(8000, 500 × 2^n)` мс включительно. `Retry-After` в другом
  формате (дата HTTP) считается отсутствующим, 429 без него ждёт по формуле.
- `Retry-After` больше `maxRetryAfter` прекращает повторы: 429 — ошибка `rate_limit` с `retryAfter()` (например,
  `verification_throttled` на 600 с), 503 — `server`.
- Повторы исчерпаны — ошибка последней попытки (у `transport` — с `mayHaveBeenProcessed` всего вызова, см. ниже).
- Запись Management API без ключа (все, кроме `adjustMemberPoints`) после возможной отправки не повторяется: ошибка
  `transport` с `mayHaveBeenProcessed()` = `true` или `server`. Вызывающий проверяет состояние (например, мерчанта по
  `external_id`) перед тем, как повторить.
- Повтор после обновления токена (см. ниже) — не повтор: без паузы и не расходует `maxRetries`.

### Мог ли вызов быть обработан

`mayHaveBeenProcessed` ошибки `transport` — свойство всего **вызова**, а не его последней попытки. Оно `true`, если
хотя бы одна попытка операции в этом вызове:

- оборвалась после отправки запроса (таймаут ответа, обрыв соединения), или
- получила HTTP-ответ с любым статусом, кроме 401 и 429 (например, 502, 503 или 504: прокси мог ответить после того,
  как API обработал запрос).

Иначе — `false`: каждая попытка не установила соединение (запрос не ушёл) или получила 401 или 429 — их API
возвращает до любой обработки (аутентификация, ограничитель запросов). Флаг не сбрасывается: попытка с ключом
оборвалась после отправки, а оба повтора не смогли соединиться — `true`, и касса повторяет вызов позже с тем же
ключом; 429, а затем два сбоя соединения — `false`.

Запрос токена партнёра — не попытка операции: его обрыв, таймаут или ответ 5xx сами по себе флаг не ставят. Если
вызов завершился ошибкой `transport`, потому что токен получить не удалось, ошибка несёт флаг вызова и его ключ
идемпотентности: `false`, если ни одна более ранняя попытка операции в этом вызове не могла быть обработана (запрос
операции так и не ушёл), и `true`, если могла (первая попытка оборвалась после отправки, повтор получил 401, а
новый токен получить не удалось).

### Токены партнёра

- **Запрос токена:** `POST {baseUrl}/api/management/v1/oauth/token`, аутентификация клиента HTTP Basic —
  `Authorization: Basic base64(client_id ":" client_secret)` (RFC 6749, 2.3.1: части кодируются формой, у наших
  форматов это ничего не меняет); `Content-Type: application/x-www-form-urlencoded`, `Accept: application/json`; тело
  `grant_type=client_credentials` и, если клиенту заданы скоупы, `scope` — скоупы через пробел в заданном порядке.
  Без `Idempotency-Key` и `Loyal-Merchant`; секрет в тело не попадает.
- **Ответ** 200 `{access_token, token_type: "Bearer", expires_in, scope}`. Непустой `access_token` из видимых символов
  ASCII, `token_type` `Bearer` (без учёта регистра) и целый `expires_in` ≥ 1 — иначе `unexpected_response`.
- **Запрос токена принадлежит клиенту, а не вызову** (в TS один запрос токена может обслуживать несколько вызовов).
  Он повторяется по правилам безопасного запроса со своим счётчиком повторов и всегда с настройками клиента:
  `maxRetries` и `maxRetryAfter` клиента и его тайм-ауты. `RequestOptions` вызова, который запросил токен или ждёт
  его, на запрос токена не действуют — ни `maxRetries`, ни тайм-аут, ни заголовки: вызов с `maxRetries` = 0 получает
  токен с повторами клиента, а вызов с `maxRetries` больше клиентского не добавляет повторов запросу токена. В TS
  `signal` вызова прекращает только ожидание токена этим вызовом, но не сам запрос. Попытки операции, в том числе
  повтор после обновления токена (ниже), идут по настройкам вызова.
- **Кэш.** Токен хранится в `TokenStore` (по умолчанию в памяти; под PHP-FPM — PSR-16, потому что выдача токенов
  ограничена 60 запросами в минуту с адреса) под ключом `sha256(baseUrl|client_id|скоупы по алфавиту)` — только
  токен и срок. Токен используется, пока до истечения больше 60 с; затем запрашивается новый. В TS параллельные
  вызовы ждут одного запроса токена (общий промис).
- **Сбой хранилища не ломает вызов.** Исключение чтения (`get`) считается промахом: токен запрашивается, как если бы
  его в кэше не было, и считается полученным этим вызовом. Исключения записи и удаления (`put` и `forget` в PHP,
  `set` и `delete` в TS) игнорируются: полученный токен всё равно используется этим вызовом, а токен, отвергнутый
  401, этот вызов не берёт снова, даже если хранилище его не удалило. Так ведёт себя каждое хранилище: в памяти,
  PSR-16 и собственная реализация `TokenStore` в PHP, любое хранилище в TS. Сбой хранилища не становится ошибкой
  вызова; пока общее хранилище недоступно, каждый вызов запрашивает токен сам и может упереться в ограничение выдачи
  (429).
- **401 на токене из кэша.** Обновление токена — только для токена, который вызов взял из кэша, то есть получил
  не сам (его запросил более ранний вызов или другой процесс): на 401 SDK удаляет его из кэша (если там всё ещё он),
  получает новый и один раз повторяет ту же попытку с тем же ключом идемпотентности, без паузы. Аутентификация идёт
  до любой обработки, поэтому такой повтор безопасен.
- **401 на токене, полученном этим же вызовом,** — окончательная ошибка `authentication`, без второго запроса
  токена: только что выданный токен отвергнут, значит, дело не в сроке, а в учётных данных или окружении. Токен
  остаётся полученным этим вызовом и в его следующих попытках: повтор после 503 берёт его из кэша без нового запроса
  токена — и токен, полученный обновлением, тоже; в TS полученным считается и токен общего запроса, которого вызов
  дождался. Поэтому и 401 на новом токене после обновления — `authentication`: обновление бывает не больше одного
  раза за вызов.
- **Ошибки выдачи:** 400 и 401 в формате RFC 6749 — `oauth` (`error`, `error_description`); 429 — `rate_limit`;
  5xx — `server`; нет ответа — `transport` с флагом вызова (см. «Мог ли вызов быть обработан»). Это ошибки после
  повторов по настройкам клиента (см. выше); `Retry-After` больше `maxRetryAfter` клиента прекращает их. Неудача не
  кэшируется: следующий вызов снова запрашивает токен.

### Ошибки

Каждая ошибка SDK реализует `LoyalException` (PHP) или наследует `LoyalError` (TS). Класс определяется так:

| Класс | PHP | TS | Когда |
|---|---|---|---|
| `bad_request` | `BadRequestException` | `BadRequestError` | 400 |
| `authentication` | `AuthenticationException` | `AuthenticationError` | 401 |
| `permission_denied` | `PermissionDeniedException` | `PermissionDeniedError` | 403 |
| `not_found` | `NotFoundException` | `NotFoundError` | 404 |
| `conflict` | `ConflictException` | `ConflictError` | 409 |
| `validation` | `ValidationException` | `ValidationError` | 422 |
| `rate_limit` | `RateLimitException` | `RateLimitError` | 429 |
| `server` | `ServerException` | `ServerError` | 500–599 |
| `api` | `ApiException` | `ApiError` | другой статус 4xx (например, 410 `verification_expired`) |
| `oauth` | `OAuthException` | `OAuthError` | 400 или 401 точки выдачи токенов (RFC 6749) |
| `transport` | `TransportException` | `TransportError` | ответа нет: сбой соединения, таймаут, обрыв (и у запроса токена) |
| `unexpected_response` | `UnexpectedResponseException` | `UnexpectedResponseError` | ответ не того вида: 2xx без JSON, перенаправление, повторённый курсор |
| `configuration` | `ConfigurationException` | `ConfigurationError` | неверные настройки или аргументы — до отправки запроса |
| `webhook_verification` | `WebhookVerificationException` | `WebhookVerificationError` | проверка вебхука не прошла |

- Класс ошибки ответа выбирается **по статусу**, а не по телу: HTML-страница 502 от прокси — `server`, 404 не от
  API — `not_found` с пустым кодом. Классы от `bad_request` до `server` — подклассы `api` (`ApiException`,
  `ApiError`).
- Ошибка ответа API несёт: `status`, `code` (код problem+json или `null`), `title`, `detail`, `errors`, `requestId`,
  `idempotencyKey`, `problem` (тело problem+json целиком) и `extension(name)` — поле расширения (`merchant_id` у
  `merchant_exists`, `available` у `insufficient_points`). `is(code)` сравнивает код; константы кодов —
  сгенерированный `ProblemCode`.
- `requestId` — заголовок `X-Request-Id`, без него — `request_id` тела problem+json.
- `rate_limit`: `retryAfter` — `Retry-After` в секундах или `null`.
- `oauth`: `error` и `errorDescription` ответа RFC 6749, `status`, `requestId`.
- `transport`: `mayHaveBeenProcessed` — по всему вызову (см. «Мог ли вызов быть обработан») и ключ идемпотентности
  вызова. Исходная ошибка HTTP-слоя прикрепляется, только если она не может нести учётные данные или адрес с
  параметрами запроса. PHP не прикрепляет исключение PSR-18-клиента (оно несёт запрос PSR-7 с `Authorization`, а
  его сообщение — адрес) и называет в сообщении только его класс; у cURL исключения нет — номер и текст ошибки cURL
  входят в сообщение. TS прикрепляет как `cause` обезличенную копию ошибки `fetch`: только `name`, `message`, `code`,
  `syscall` и `errno` по цепочке `cause` и `errors`, каждый адрес в них обрезан до origin, остальные свойства
  отброшены; при отмене вызова `cause` — причина отмены (`signal.reason` вызывающего или таймаут SDK).
- `unexpected_response`: статус и тип содержимого.
- Сообщение — `<статус> <код>: <title>`; ни одна ошибка не содержит адреса с параметрами, заголовков, тел запроса
  или секретов.

### Перебор страниц

- `<operationId>All(...)` есть у операций со страницей `{data, next_cursor}` и параметром `cursor`: те же аргументы,
  кроме `cursor`. Первая страница — без курсора; элементы `data` отдаются по одному (PHP `Generator`, TS
  `AsyncGenerator`); следующая страница запрашивается с `cursor` = `next_cursor`, пока он не `null`.
- `next_cursor`, который уже запрашивался в этом переборе, или не строка (и не `null`), или пустая строка —
  `unexpected_response`: перебор не зацикливается.
- Каждая страница — отдельный `GET` со своими повторами.

### Вебхуки

Запросы платформы подписаны по [Standard Webhooks](https://www.standardwebhooks.com) (`docs/api/webhooks.md`):
`new WebhookVerifier(secrets: [текущий, прежний], toleranceSeconds: 300)`, затем
`verify(сырое тело, заголовки, now?)` → событие `{id, type, version, timestamp, data}` или ошибка
`webhook_verification` с причиной. Проверки по порядку:

1. **Секреты** — `whsec_` и base64 непустого ключа, иначе `configuration` при создании. Секретов может быть
   несколько: после ротации эндпоинт сутки подписывает новым и прежним.
2. **Заголовки** `webhook-id`, `webhook-timestamp`, `webhook-signature` (имена без учёта регистра). Нет заголовка,
   пустое значение или `webhook-timestamp` — не целое число секунд из цифр → `missing_headers`.
3. **Время:** `|now − webhook-timestamp| > toleranceSeconds` в любую сторону → `stale_timestamp`; ровно допуск — ещё
   принимается.
4. **Подпись:** ожидаемое значение — base64 от HMAC-SHA256 строки `{webhook-id}.{webhook-timestamp}.{тело}` с
   ключом — раскодированной частью секрета после `whsec_`. Тело — сырые байты запроса до разбора JSON (PHP —
   строка как есть; TS — `string` в UTF-8 или `Uint8Array`, разобранный объект — `TypeError`). `webhook-signature` —
   подписи `v<версия>,<base64>` через пробел; учитываются только `v1`. Подлинно, если хотя бы одна `v1`-подпись
   совпала с ожидаемой хотя бы для одного секрета; сравнение — за постоянное время (`hash_equals`,
   `crypto.subtle.verify`). Иначе — `signature_mismatch`.
5. **Тело** разбирается только после проверки подписи: JSON-объект со строкой `type`, целым `version`, строкой
   `timestamp` и объектом `data`; иначе `malformed_body`. Неизвестные поля не мешают.

`id` события — `webhook-id`: он одинаков у всех попыток и всех эндпоинтов, по нему отсекаются повторы. Векторы
проверки (`conformance/webhooks.json`) проходят и `Signer` платформы, и пример `docs/examples/webhook-receiver.php`.

### Метаданные ответа

`onResponse(ResponseMeta)` вызывается для каждого HTTP-ответа, включая промежуточные ответы повторов и выдачу
токена (`operationId` = `issueToken`):

| Поле | Значение |
|---|---|
| `operationId`, `method`, `path` | операция, метод и шаблон пути — без значений параметров |
| `status`, `durationMs` | статус и длительность попытки |
| `requestId` | `X-Request-Id` или `null` |
| `idempotentReplayed` | `Idempotent-Replayed: true` |
| `rateLimitRemaining` | `RateLimit-Remaining` целым или `null` |
| `environment` | значение `Loyal-Environment` как есть или `null` |

Адресов, параметров запроса, тел и заголовков в метаданных нет.

### Единицы

`money('250.50')` = 25050, `quantity('1.5')` = 1500, `points('12.5', precision: 1)` = 125 и обратное `toDecimal`:
строковая арифметика без дробных чисел; лишние знаки после запятой — ошибка.

## Выпуск

1. `sdk/VERSION`, `version` в `sdk/typescript/package.json` и запись в CHANGELOG SDK совпадают.
2. `composer sdk:generate` ничего не меняет (`--check`), `composer check` зелёный, в `sdk/typescript` —
   `npm run check`, `npm test`, `npm run build`; PHP SDK проходит векторы на PHP 8.2 (`sdk/php/tests/smoke.php`).
3. Публикация (npm, зеркало `sdk/php` для Composer) включается после выбора имён пакетов, лицензии и реестров.
