# TypeScript SDK платформы лояльности (`@loyal/sdk`)

Клиент Runtime API (кассы, сайты, приложения) и Management API (партнёры и мерчанты). ESM, Node.js 22+, Deno, Bun,
Cloudflare Workers; без зависимостей во время выполнения — только веб-API (`fetch`, Web Crypto). Поведение — общий
для PHP и TypeScript контракт [sdk/README.md](../README.md) (повторы, идемпотентность, токены, ошибки, кодирование
адресов), закреплённый векторами [sdk/conformance](../conformance/README.md).

Пакет пока `private`: публикация в npm включится после выбора имени, лицензии и реестра. До тех пор он собирается
из репозитория (`npm run build` → `dist/`).

## Касса: Runtime API

```ts
import { RuntimeClient, ConflictError, ProblemCode, type runtime } from '@loyal/sdk';

const till = new RuntimeClient({
  apiKey: process.env.LOYAL_TERMINAL_KEY!, // lk_… (в песочнице lk_test_…)
  baseUrl: 'https://api.example.com',
  appInfo: 'MyPOS/2.1',                    // попадёт в User-Agent
});

const body: runtime.ReceiptInput = {
  number: '1042',
  business_date: '2026-10-11',
  purchased_at: '2026-10-11T14:59:58+03:00',
  member: { phone: '+79000000001' },
  lines: [{ id: '1', sku: 'LATTE-M', quantity: 1000, amount: 25000 }],
};

// Ключ идемпотентности: свой детерминированный или UUIDv4 от SDK — один на все попытки вызова.
const receipt = await till.registerReceipt({ body }, { idempotencyKey: `receipt:${terminal}:2026-10-11:1042` });
await till.confirmReceipt({ receipt: receipt.id, body: { fiscal: { drive: '9999078900001234', document: '1042', sign: '3522438401' } } });

for await (const entry of till.getPointsHistoryAll({ member: receipt.member!.id, limit: 100 })) {
  console.log(entry.type, entry.member_delta);
}
```

## Партнёр и мерчант: Management API

```ts
import { ManagementClient, ConflictError, ProblemCode } from '@loyal/sdk';

const partner = ManagementClient.forPartner({
  baseUrl: 'https://api.example.com',
  clientId: process.env.LOYAL_CLIENT_ID!,         // lc_…
  clientSecret: process.env.LOYAL_CLIENT_SECRET!, // lcs_…
  scopes: ['merchants', 'programs'],              // необязательно: по умолчанию все скоупы клиента
});

let merchantId: string;

try {
  merchantId = (await partner.createMerchant({ body: { name: 'Кофейня', external_id: 'crm-1' } })).id;
} catch (error) {
  if (!(error instanceof ConflictError && error.is(ProblemCode.MERCHANT_EXISTS))) throw error;
  merchantId = error.extension('merchant_id') as string;
}

// Новый клиент, исходный не меняется; Loyal-Merchant уходит только у операций, которые его объявляют.
const shop = partner.forMerchant(merchantId);
const { data: programs } = await shop.listPrograms();

// Мерчант со своим ключом: мерчант уже задан ключом, forMerchant() не нужен (и запрещён).
const own = ManagementClient.forMerchantKey({ apiKey: process.env.LOYAL_MERCHANT_KEY!, baseUrl: 'https://api.example.com' });
```

Полный пример настройки мерчанта до ключа кассы — [examples/quickstart.ts](examples/quickstart.ts)
(`loyalQuickstart(partner, externalId)`).

## Вызовы

- Метод назван по `operationId` спецификации: `method(args, options?)`. В `args` — параметры пути и запроса под
  именами из спецификации и `body`; у операций, где всё необязательно, `args` можно не передавать.
- У операций со страницами `{data, next_cursor}` есть `<operationId>All(args)` — `AsyncGenerator` по всем
  элементам всех страниц; следующая страница запрашивается, только когда перебор до неё дошёл.
- Результат — раскодированный JSON ответа; у операций без содержимого (204) — `undefined`.
- Сгенерированные метаданные операций доступны как `runtimeOperations` и `managementOperations`.

`options` вызова (`RequestOptions`) действуют только на попытки операции, но не на запрос токена партнёра (см.
«Токены партнёра»):

| Поле | Что делает |
|---|---|
| `idempotencyKey` | ключ операции с `Idempotency-Key`: 1–255 видимых символов ASCII; у операции без него — `ConfigurationError` |
| `timeoutMs` | таймаут каждой попытки (по умолчанию 10 000 у Runtime API, 30 000 у Management API) |
| `maxRetries` | число повторов этого вызова |
| `signal` | `AbortSignal`: отмена вызова без дальнейших попыток (`TransportError`) |
| `headers` | дополнительные заголовки; заменить заголовки SDK нельзя |

Настройки клиента (`ClientOptions`): `baseUrl` (обязательно; `https://`, `http://` — только для `localhost`,
`127.0.0.1`, `[::1]`), `fetch`, `timeoutMs`, `maxRetries` (2), `maxRetryAfter` (60 с), `onResponse`, `appInfo`,
`dangerouslyAllowBrowser`, а для тестов — `sleep` и `random`.

## Типы

```ts
import type { runtime, management } from '@loyal/sdk';

function total(receipt: runtime.Receipt): number { return receipt.payable; }
```

Типы сгенерированы из спецификаций. Время — строки ISO 8601 с микросекундами (`Date` их бы обрезал). Деньги,
баллы и количества — целые в минимальных единицах; перевод из строк — `units`, без дробных чисел:

```ts
import { units } from '@loyal/sdk';

units.money('250.50');      // 25050 копеек
units.quantity('1.5');      // 1500 тысячных
units.points('12.5', 1);    // 125
units.toDecimal(25050, 2);  // '250.50'
units.money('0.001');       // ConfigurationError: лишний знак не округляется
```

## Ошибки

Все ошибки наследуют `LoyalError`. Класс ответа API выбирается по статусу, тело problem+json только добавляет
подробности:

| Класс | Когда |
|---|---|
| `BadRequestError`, `AuthenticationError`, `PermissionDeniedError`, `NotFoundError`, `ConflictError`, `ValidationError` | 400, 401, 403, 404, 409, 422 |
| `RateLimitError` | 429 без повтора (`retryAfter` — секунды или `null`) |
| `ServerError` | 500–599 после повторов |
| `ApiError` | базовый класс всех выше и другие 4xx (например, 410 `verification_expired`) |
| `OAuthError` | 400/401 выдачи токена (`error`, `errorDescription`) |
| `TransportError` | ответа нет: сбой соединения, таймаут, обрыв, отмена; `mayHaveBeenProcessed` — по всему вызову (ниже) |
| `UnexpectedResponseError` | 2xx без JSON, перенаправление, повторённый курсор, неверный ответ с токеном |
| `ConfigurationError` | неверные настройки или аргументы — до отправки запроса |
| `WebhookVerificationError` | вебхук не прошёл проверку (`reason`) |

У `ApiError`: `status`, `code` (сравнивайте с `ProblemCode.*` через `is()`), `title`, `detail`, `errors`,
`requestId`, `idempotencyKey`, `problem` и `extension(name)`. Ключ идемпотентности есть у каждой ошибки вызова:
касса сохраняет его и повторяет вызов позже с тем же ключом. Сообщения, `toJSON` и `inspect` ошибок не содержат
секретов, адресов с параметрами, заголовков и тел запросов: `cause` у `TransportError` — копия ошибки `fetch` только
с `name`, `message`, `code`, `syscall` и `errno` (по цепочке `cause` и `errors`), адреса в которых обрезаны до origin
(Deno пишет адрес запроса в сообщение, Bun — в свойство `path`); при отмене — причина отмены (`signal.reason` или
таймаут SDK).

`mayHaveBeenProcessed` — свойство всего вызова, а не последней попытки ([контракт](../README.md#мог-ли-вызов-быть-обработан)):
`true`, если хоть одна попытка операции оборвалась после отправки (таймаут, обрыв) или получила любой HTTP-ответ,
кроме 401 и 429 (их API возвращает до обработки). Запрос токена партнёра — не попытка операции: если токен получить
не удалось, ошибка несёт флаг более ранних попыток вызова. При `true` касса повторяет вызов позже с тем же ключом, а
запись без ключа сначала проверяет состояние.

## Повторы и идемпотентность

Коротко (полностью — [контракт](../README.md#повторы)):

- запрос не ушёл (DNS, отказ в соединении, TLS) — повторяется любой;
- таймаут или обрыв после отправки, 502/503/504 — только безопасные: `GET`, запрос с `Idempotency-Key`,
  `calculateReceipt` и `lookupMember`;
- 429 — любой, если `Retry-After` не больше `maxRetryAfter`; 500, 4xx, `PUT` и записи Management API без ключа
  после отправки — никогда;
- пауза — `Retry-After` или случайная от 0 до `min(8 с, 0,5 с × 2^n)`.

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

Токен запрашивается при первом вызове (RFC 6749: форма и HTTP Basic), кэшируется и обновляется за 60 секунд до
истечения; параллельные вызовы ждут один общий запрос токена. 401 на токене из кэша, полученном не в этом вызове, —
токен удаляется, и запрос один раз повторяется с другим токеном и тем же ключом, без паузы и не расходуя
`maxRetries` (отвергнутый токен не используется, даже если хранилище не смогло его удалить); 401 на токене, который
вызов получил сам (или дождался общего запроса), — сразу `AuthenticationError`. Обновление — не больше одного за
вызов.

Запрос токена принадлежит клиенту, а не вызову: один запрос может обслуживать несколько вызовов. Он повторяется
со своим счётчиком по `maxRetries`, `maxRetryAfter` и `timeoutMs` клиента; `maxRetries`, `timeoutMs` и `headers`
вызова на него не действуют, а `signal` вызова прекращает только ожидание токена этим вызовом, но не сам запрос.

Сбой `TokenStore` не ломает вызов: исключение `get` — промах (вызов запрашивает токен сам), исключения `set` и
`delete` игнорируются (полученный токен всё равно используется, отвергнутый 401 вызов снова не берёт). Пока общее
хранилище недоступно, каждый вызов запрашивает токен сам и может упереться в ограничение выдачи (429).

По умолчанию кэш в памяти (общий для клиента и его `forMerchant()`); несколько
процессов или воркеров могут делить токен через свой `TokenStore` — выдача токенов ограничена 60 запросами в минуту
с адреса:

```ts
import { ManagementClient, type TokenStore } from '@loyal/sdk';

const tokenStore: TokenStore = {
  get: async (key) => JSON.parse((await kv.get(key)) ?? 'null'),
  set: async (key, token, ttlSeconds) => kv.put(key, JSON.stringify(token), { expirationTtl: ttlSeconds }),
  delete: async (key) => kv.delete(key),
};

const partner = ManagementClient.forPartner({ baseUrl, clientId, clientSecret, tokenStore });
```

Ключ кэша — `sha256(baseUrl|client_id|скоупы по алфавиту)`; хранятся только токен и срок.

## Вебхуки

```ts
import { WebhookVerifier, WebhookVerificationError } from '@loyal/sdk';

const verifier = new WebhookVerifier({ secrets: [env.LOYAL_WEBHOOK_SECRET] }); // после ротации — [новый, прежний]

export default {
  async fetch(request: Request): Promise<Response> {
    try {
      const event = await verifier.verify(await request.text(), request.headers); // сырое тело, не JSON
      await handle(event.id, event.type, event.data); // event.id одинаков у всех попыток: отсекайте повторы
      return new Response(null, { status: 204 });
    } catch (error) {
      if (error instanceof WebhookVerificationError) return new Response(null, { status: 400 });
      throw error;
    }
  },
};
```

Тело — `string`, `Uint8Array` (в том числе `Buffer`) или `ArrayBuffer`; разобранный объект — `TypeError`. Заголовки
— `Headers` (любой объект с методом `get()`, в том числе полифил) или объект вроде `req.headers` Node.js. Несколько
значений `webhook-id` или `webhook-timestamp` — `missing_headers`; несколько значений `webhook-signature`
складываются в один список подписей. Причины отказа: `missing_headers`, `stale_timestamp` (допуск 300 с в обе
стороны), `signature_mismatch`, `malformed_body`.

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

`onResponse` получает метаданные каждого HTTP-ответа (и промежуточных перед повтором, и выдачи токена):
`operationId`, `method`, `path` (шаблон без значений), `status`, `durationMs`, `requestId`, `idempotentReplayed`,
`rateLimitRemaining`, `environment` (`Loyal-Environment`). Исключения обработчика и отказ промиса, который он
вернул (асинхронный обработчик), игнорируются; вызов этот промис не ждёт.

```ts
const till = new RuntimeClient({
  apiKey, baseUrl,
  onResponse: (meta) => {
    if (meta.environment === 'sandbox') showBanner('ТЕСТОВЫЙ РЕЖИМ');
    metrics.observe(meta.operationId, meta.status, meta.durationMs);
  },
});
till.environment(); // 'live' или 'sandbox' — по маркеру test_ в учётных данных
```

## Безопасность

- Формат учётных данных проверяется при создании клиента (пробелы, переводы строк, перепутанные id и секрет,
  разные окружения id и секрета — `ConfigurationError` без самих значений в сообщении).
- Секреты хранятся в `#private`-полях; `JSON.stringify` и `console.log` клиента показывают маску (`lk_test_k7m2…`).
- Учётные данные — только в `Authorization`; перенаправления не выполняются (`redirect: 'manual'`), только
  `https://` (кроме loopback). Cookies не отправляются: в браузере и React Native — `credentials: 'omit'`; серверные
  среды cookies не хранят, и поле не передаётся (Cloudflare Workers со старой датой совместимости его отвергает).
- В браузере (страница с `document`, Web Worker или Service Worker браузера) и в React Native ключ мерчанта и
  учётные данные партнёра запрещены всегда; ключ кассы — только с `dangerouslyAllowBrowser: true` (выделенное
  устройство кассы).

## Песочница и тесты

Учётные данные песочницы — `lk_test_…`, `lm_test_…`, `lc_test_…`/`lcs_test_…` ([docs/api/sandbox.md](../../docs/api/sandbox.md)).
Помощники для тестов интеграции — `@loyal/sdk/testing`:

```ts
import { RuntimeClient } from '@loyal/sdk';
import { createMockFetch, sandbox, webhookTestHeaders } from '@loyal/sdk/testing';

const fetch = createMockFetch({ status: 201, json: member }, { failure: 'before_send' }, { status: 503 });
const till = new RuntimeClient({ apiKey, baseUrl: 'https://api.example.com', fetch, sleep: async () => {} });

await till.registerMember({ body: { phone: sandbox.phone(1), consents: ['program_rules', 'personal_data'] } });
fetch.requests[0].json(); // что ушло
fetch.assertDone();       // все ответы использованы

sandbox.CODE; // '000000' — код подтверждения тестовых телефонов в песочнице
const headers = await webhookTestHeaders('whsec_…', body); // заголовки подписанного вебхука
```

## Разработка

```powershell
. .\scripts\dev\env.ps1
cd sdk\typescript
npm ci          # единственная зависимость — typescript (devDependency, точная версия)
npm test        # node --test: модули, векторы, транспорт по настоящему HTTP, сбои, повторы, токены, вебхуки
npm run check   # tsc --noEmit
npm run build   # dist/: .js и .d.ts
node --test test/e2e/journey.test.mjs   # против запущенного API, если заданы LOYAL_E2E_BASE_URL, LOYAL_E2E_CLIENT_ID, LOYAL_E2E_CLIENT_SECRET
```

- `src/generated/` генерируется из спецификаций (`composer sdk:generate`) и руками не правится.
- Исходники — только стираемый синтаксис TypeScript (без `enum`, `namespace`, свойств-параметров; относительные
  импорты с `.ts`): Node.js 22 запускает их напрямую, без сборки, поэтому тесты импортируют `src/*.ts`.
- Изменение поведения начинается с вектора в `sdk/conformance`; `test/conformance.test.mjs` прогоняет их все.

| Файл | Что внутри |
|---|---|
| `src/client-base.ts` | ядро: адрес, заголовки, тело, ключи идемпотентности, повторы, ошибки, перебор страниц, выдача токена |
| `src/runtime-client.ts`, `src/management-client.ts` | клиенты поверх сгенерированных методов |
| `src/transport.ts`, `src/retry.ts`, `src/url.ts` | одна попытка через `fetch`, правила повторов, кодирование RFC 3986 |
| `src/auth.ts` | учётные данные, окружение, `TokenStore`, токены партнёра (single flight) |
| `src/errors.ts`, `src/pagination.ts`, `src/webhooks.ts`, `src/units.ts` | ошибки, страницы, вебхуки, единицы |
| `src/testing.ts` | `@loyal/sdk/testing` |
| `src/index.ts` | публичный API |
