@openaisdk/billing-sdk-node 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,619 @@
1
+ # @openaisdk/billing-sdk-node
2
+
3
+ Ручной публичный Node.js SDK для Mega-Billing consumer API.
4
+
5
+ ## За что отвечает
6
+
7
+ - Узкий контракт для backend вашего SaaS: клиенты, checkout, доступ, подписки, usage, чтение счетов, проверка подписи вебхуков.
8
+ - Аутентификация только `Authorization: Bearer <integration key>`. Tenant и project из ключа, не из заголовков.
9
+ - Типы и ошибки, которые видит интегратор. Поведение совпадает с `GET /api/public-json`.
10
+
11
+ ## За что не отвечает
12
+
13
+ - Полный admin/internal OpenAPI (ключи, провайдер, void счёта) — сгенерированный [`billing-sdk`](../billing-sdk/README.md) или Admin UI.
14
+ - MCP для агентов — [`billing-catalog-mcp`](../billing-catalog-mcp/README.md).
15
+ - Доменные пакеты платформы (`*-domain`): SDK только HTTP-клиент.
16
+ - Хранение секретов, ретраи провайдера, фискализация.
17
+
18
+ Пакет намеренно открывает узкую consumer-facing поверхность:
19
+
20
+ | Ресурс | Методы |
21
+ | ------------------- | -------------------------------------------- |
22
+ | `customers` | `create`, `retrieve`, `list`, `update` |
23
+ | `subscriptions` | `retrieve`, `list`, `cancel` |
24
+ | `checkout.sessions` | `create` |
25
+ | `access` | `retrieve` |
26
+ | `meterEvents` | `create`, `summary` |
27
+ | `invoices` | `retrieve`, `listCreditNotes` |
28
+ | `webhooks` | `constructEvent`, `generateTestHeaderString` |
29
+
30
+ Wave 1 (`customers.create`, `checkout.sessions.create`, `access.retrieve`) — реализованный golden path. Customer & Subscription Lifecycle (`customers.retrieve/list/update`, `subscriptions.*`), thin Invoice Read (`invoices.retrieve`, `invoices.listCreditNotes`) и Wave 4 Usage (`meterEvents.create`, `meterEvents.summary`) следуют зафиксированному public contract: customer, invoice и meter остаются публичными идентификаторами, а replay usage-события задаётся body-полем `identifier`, а не внутренними UUID. Void / credit note create остаются admin-only.
31
+
32
+ Аутентификация только через `Authorization: Bearer <integration key>`. В опциях клиента и сигнатурах методов нет `x-tenant-id`, `xTenantId` или `projectId` — tenant и project определяются самим ключом.
33
+
34
+ Требования: Node.js ≥ 20.
35
+
36
+ ## Установка
37
+
38
+ ```bash
39
+ pnpm add @openaisdk/billing-sdk-node
40
+ ```
41
+
42
+ ## Инициализация
43
+
44
+ Для hosted-окружения достаточно integration key вида `bsk_test_...` или `bsk_live_...`. Для local / self-hosted задайте `BILLING_API_URL` в окружении или передайте `baseUrl` явно.
45
+
46
+ ```ts
47
+ import { BillingError, MegaBilling } from '@openaisdk/billing-sdk-node';
48
+
49
+ const billing = new MegaBilling({
50
+ apiKey: process.env.BILLING_API_KEY!,
51
+ // baseUrl: 'http://127.0.0.1:4001', // опционально для local/self-hosted
52
+ // timeoutMs: 30_000, // опционально, по умолчанию 30s
53
+ });
54
+ ```
55
+
56
+ ## Sandbox / live contract
57
+
58
+ Wave 5 Sandbox не требует второго consumer deploy и не вводит дополнительных env headers:
59
+
60
+ - один и тот же `baseUrl` обслуживает и test, и live;
61
+ - режим задаётся самим Bearer key: `bsk_test_...` для sandbox/test и `bsk_live_...` для live;
62
+ - consumer не передаёт `x-tenant-id`, `x-project-id`, `x-env` или аналогичный mode header;
63
+ - все consumer-facing resource responses и outbound webhook events несут `livemode: boolean`, чтобы приложение могло явно различать test и live данные.
64
+
65
+ Практический migration path: для перехода из sandbox в live достаточно подставить live key и повторить smoke flow на связанном live project.
66
+
67
+ ## Golden path
68
+
69
+ Типичный сценарий consumer-backend: создать customer → открыть checkout → прочитать access state.
70
+
71
+ ```ts
72
+ import { BillingError, MegaBilling } from '@openaisdk/billing-sdk-node';
73
+
74
+ const billing = new MegaBilling({
75
+ apiKey: process.env.BILLING_API_KEY!,
76
+ });
77
+
78
+ const workspace = {
79
+ id: 'ws_123',
80
+ name: 'MegaRetro Workspace',
81
+ ownerEmail: 'owner@example.com',
82
+ };
83
+
84
+ async function main() {
85
+ const customer = await billing.customers.create(
86
+ {
87
+ externalType: 'workspace',
88
+ externalId: workspace.id,
89
+ email: workspace.ownerEmail,
90
+ name: workspace.name,
91
+ },
92
+ {
93
+ idempotencyKey: `customer:${workspace.id}`,
94
+ }
95
+ );
96
+
97
+ const checkout = await billing.checkout.sessions.create(
98
+ {
99
+ customer: customer.id,
100
+ price: 'price_pro_monthly',
101
+ successUrl: 'https://app.example.com/billing/success',
102
+ cancelUrl: 'https://app.example.com/billing',
103
+ },
104
+ {
105
+ idempotencyKey: `checkout:${workspace.id}:price_pro_monthly:v1`,
106
+ }
107
+ );
108
+
109
+ const access = await billing.access.retrieve(customer.id);
110
+
111
+ console.log(customer.requestId, checkout.requestId, access.requestId);
112
+ console.log(customer.livemode, checkout.livemode, access.livemode);
113
+ console.log(checkout.confirmationUrl);
114
+ console.log(access.status, access.features, access.limits);
115
+ }
116
+
117
+ main().catch((error) => {
118
+ if (error instanceof BillingError) {
119
+ console.error(error.code, error.status, error.requestId, error.param);
120
+ return;
121
+ }
122
+
123
+ throw error;
124
+ });
125
+ ```
126
+
127
+ Запускаемый пример из репозитория:
128
+
129
+ ```bash
130
+ BILLING_API_KEY=bsk_test_... \
131
+ BILLING_API_URL=http://127.0.0.1:4001 \
132
+ BILLING_PRICE_ID=price_pro_monthly \
133
+ pnpm --filter @openaisdk/billing-sdk-node run quickstart
134
+ ```
135
+
136
+ ## Lifecycle-сценарий
137
+
138
+ Типичный экран «Биллинг» в consumer-продукте: показать текущую подписку и дать отменить её в конце периода.
139
+
140
+ ```ts
141
+ const customer = await billing.customers.retrieve('cus_123');
142
+ const subscriptions = await billing.subscriptions.list({
143
+ customer: customer.id,
144
+ status: 'active',
145
+ });
146
+
147
+ const [current] = subscriptions.data;
148
+
149
+ if (current) {
150
+ await billing.subscriptions.cancel(
151
+ current.id,
152
+ { atPeriodEnd: true },
153
+ { idempotencyKey: `subscription-cancel:${current.id}:v1` }
154
+ );
155
+ }
156
+
157
+ const access = await billing.access.retrieve(customer.id);
158
+ ```
159
+
160
+ Отмена в конце периода не снимает доступ немедленно: до `currentPeriodEnd` `billing.access.retrieve(...)` продолжит возвращать активный доступ.
161
+
162
+ ## Usage-based billing
163
+
164
+ Типичный metered-flow в consumer backend: отправить usage по публичному customer и затем прочитать usage summary по публичному meter.
165
+
166
+ ```ts
167
+ const acceptedEvent = await billing.meterEvents.create({
168
+ eventName: 'ai_tokens',
169
+ customer: 'cus_123',
170
+ value: 1250,
171
+ identifier: 'generation_gen_987',
172
+ timestamp: '2026-08-10T00:00:00.000Z',
173
+ dimensions: { model: 'gpt-5' },
174
+ });
175
+
176
+ const summary = await billing.meterEvents.summary('ai_tokens', {
177
+ customer: 'cus_123',
178
+ startTime: '2026-08-10T00:00:00.000Z',
179
+ endTime: '2026-08-10T01:00:00.000Z',
180
+ valueGroupingWindow: 'hour',
181
+ });
182
+
183
+ console.log(acceptedEvent.id, acceptedEvent.status, acceptedEvent.requestId);
184
+ console.log(summary.data[0]?.aggregatedValue, summary.requestId);
185
+ ```
186
+
187
+ Правила:
188
+
189
+ - `eventName` и аргумент `meterId` в `summary(...)` используют публичный meter code, а не внутренний UUID;
190
+ - `identifier` обязателен и задаёт replay/dedup contract для create: повтор с тем же `identifier` и тем же нормализованным payload (customer, meter, value, dimensions и — только если он был передан явно — `timestamp`) возвращает тот же event, а другой payload — `idempotency_key_conflict`;
191
+ - явный `timestamp` — часть payload fingerprint, поэтому его отсутствие в исходном запросе не мешает retry-safe повтору без `timestamp`: сервер каждый раз подставляет текущее время, но это поле в такой повтор не входит, поэтому ложного конфликта не возникает;
192
+ - открытые события (без `subscriptionId` — SDK его и не принимает) попадают в расчёт нужной подписки по `project + customer + meter` в момент выставления счёта, а не по значению, переданному клиентом;
193
+ - `value` можно передать числом или строкой с неотрицательным целым значением;
194
+ - `valueGroupingWindow` поддерживает `hour` и `day`; для группировки окно должно быть выровнено по UTC-границам;
195
+ - сервер может принять событие асинхронно, поэтому `create(...)` возвращает `status: 'accepted'`.
196
+
197
+ ## Webhooks
198
+
199
+ Wave 3 добавляет consumer-facing helper для проверки webhook signature поверх уже существующей endpoint/delivery/replay infrastructure.
200
+
201
+ Поддерживаемый signature format:
202
+
203
+ ```txt
204
+ x-billing-signature: t=1760000000,v1=<hex-hmac>
205
+ ```
206
+
207
+ - подпись считается по строке `"<timestamp>.<rawBody>"`;
208
+ - по умолчанию SDK использует tolerance window `300` секунд;
209
+ - проверять нужно именно raw body до `JSON.parse(...)`;
210
+ - event envelope содержит `livemode`, совпадающий с mode проекта-источника;
211
+ - дедупликация consumer-side handler должна идти по `event.id`;
212
+ - test event использует тот же `v1` scheme, что и production deliveries.
213
+
214
+ Поддерживаемые event types: `billing.test`, `subscription.*`, `invoice.*`, `payment.failed`, `payment.refunded`, `entitlement.updated`, `entitlement.granted`, `entitlement.revoked`.
215
+
216
+ ### `billing.webhooks.constructEvent(rawBody, signature, secret, options?)`
217
+
218
+ ```ts
219
+ import { BillingWebhookVerificationError, MegaBilling } from '@openaisdk/billing-sdk-node';
220
+
221
+ const billing = new MegaBilling({
222
+ apiKey: process.env.BILLING_API_KEY!,
223
+ });
224
+
225
+ const rawBody = request.rawBody.toString('utf8');
226
+ const signature = request.headers['x-billing-signature'];
227
+
228
+ try {
229
+ const event = billing.webhooks.constructEvent(
230
+ rawBody,
231
+ Array.isArray(signature) ? signature[0] : signature,
232
+ process.env.MEGABILLING_WEBHOOK_SECRET!
233
+ );
234
+
235
+ console.log(event.livemode ? 'live webhook' : 'test webhook');
236
+
237
+ if (event.type === 'entitlement.updated') {
238
+ const access = await billing.access.retrieve(event.data.customer!);
239
+ console.log(access.status, access.features, access.limits);
240
+ }
241
+ } catch (error) {
242
+ if (error instanceof BillingWebhookVerificationError) {
243
+ console.error(error.code, error.message);
244
+ }
245
+ }
246
+ ```
247
+
248
+ `options`:
249
+
250
+ | Поле | По умолчанию | Назначение |
251
+ | ------------------ | ------------ | --------------------------------------------- |
252
+ | `toleranceSeconds` | `300` | Максимальное допустимое расхождение timestamp |
253
+ | `receivedAt` | `Date.now()` | Позволяет стабильно тестировать верификацию |
254
+
255
+ ### `billing.webhooks.generateTestHeaderString(rawBody, secret, timestamp?)`
256
+
257
+ Helper для локальных тестов и unit/integration specs. Генерирует header в формате `t=...,v1=...`, совместимый с `constructEvent(...)`.
258
+
259
+ ## API
260
+
261
+ ### `billing.customers.create(params, options?)`
262
+
263
+ `POST /v1/customers`
264
+
265
+ Создаёт (или идемпотентно возвращает) customer, связанный с сущностью consumer-а через `externalType` + `externalId`.
266
+
267
+ ```ts
268
+ const customer = await billing.customers.create(
269
+ {
270
+ externalType: 'workspace',
271
+ externalId: 'ws_123',
272
+ email: 'owner@example.com',
273
+ name: 'MegaRetro Workspace',
274
+ phone: '+79990000000',
275
+ },
276
+ { idempotencyKey: 'customer:ws_123' }
277
+ );
278
+ ```
279
+
280
+ `externalType`: `'workspace' | 'tenant' | 'company' | 'org'`. Поля `email`, `name` и `phone` опциональны. Ответ содержит `livemode`, чтобы consumer мог не смешивать sandbox/live customers в собственных логах и диагностике.
281
+
282
+ ### `billing.customers.retrieve(customerId, options?)`
283
+
284
+ `GET /v1/customers/{customerId}`
285
+
286
+ Читает текущее состояние customer по публичному `cus_...` идентификатору.
287
+
288
+ ```ts
289
+ const customer = await billing.customers.retrieve('cus_123');
290
+ ```
291
+
292
+ ### `billing.customers.list(params?, options?)`
293
+
294
+ `GET /v1/customers`
295
+
296
+ Возвращает страницу customers проекта, определяемого integration key.
297
+
298
+ ```ts
299
+ const customers = await billing.customers.list({
300
+ limit: 20,
301
+ startingAfter: 'cus_100',
302
+ externalType: 'workspace',
303
+ // externalId: 'ws_123',
304
+ });
305
+
306
+ for (const customer of customers.data) {
307
+ console.log(customer.id, customer.externalId);
308
+ }
309
+
310
+ console.log(customers.hasMore);
311
+ ```
312
+
313
+ Ответ — list envelope: `{ object: 'list', data: Customer[], hasMore: boolean }` плюс `requestId`. Все параметры опциональны; `undefined` в query не отправляется.
314
+
315
+ ### `billing.customers.update(customerId, payload, options?)`
316
+
317
+ `PATCH /v1/customers/{customerId}`
318
+
319
+ Обновляет изменяемые контактные поля customer. `externalType` и `externalId` — иммутабельная связка с сущностью consumer-а и через update не меняются. `null` очищает контактное поле, отсутствие поля оставляет текущее значение без изменений.
320
+
321
+ ```ts
322
+ const customer = await billing.customers.update(
323
+ 'cus_123',
324
+ {
325
+ name: 'MegaRetro Workspace (renamed)',
326
+ email: 'billing@example.com',
327
+ phone: null,
328
+ },
329
+ { idempotencyKey: 'customer-update:ws_123:v2' }
330
+ );
331
+ ```
332
+
333
+ ### `billing.subscriptions.retrieve(subscriptionId, options?)`
334
+
335
+ `GET /v1/subscriptions/{subscriptionId}`
336
+
337
+ Возвращает подписку по публичному `sub_...` идентификатору: `livemode`, `status`, `product`, `price`, границы периода, trial/grace поля и timestamps отмены/завершения.
338
+
339
+ ```ts
340
+ const subscription = await billing.subscriptions.retrieve('sub_123');
341
+ ```
342
+
343
+ `status`: `'pending' | 'trialing' | 'active' | 'past_due' | 'paused' | 'canceled'`.
344
+
345
+ Подписка описывает биллинговый lifecycle, а не право доступа. Для gate фич в продукте по-прежнему используйте `billing.access.retrieve(...)`.
346
+
347
+ ### `billing.subscriptions.list(params, options?)`
348
+
349
+ `GET /v1/subscriptions?customer=...`
350
+
351
+ Возвращает подписки одного customer. Параметр `customer` обязателен: SDK не открывает project-wide перебор подписок.
352
+
353
+ ```ts
354
+ const subscriptions = await billing.subscriptions.list({
355
+ customer: 'cus_123',
356
+ status: 'active',
357
+ limit: 10,
358
+ });
359
+ ```
360
+
361
+ ### `billing.subscriptions.cancel(subscriptionId, payload?, options?)`
362
+
363
+ `POST /v1/subscriptions/{subscriptionId}/cancel`
364
+
365
+ Отменяет подписку. `atPeriodEnd: true` планирует отмену на конец оплаченного периода, `false` (или отсутствие поля) отменяет немедленно.
366
+
367
+ ```ts
368
+ const subscription = await billing.subscriptions.cancel(
369
+ 'sub_123',
370
+ { atPeriodEnd: true },
371
+ { idempotencyKey: 'subscription-cancel:sub_123:v1' }
372
+ );
373
+
374
+ console.log(subscription.status, subscription.cancelAtPeriodEnd);
375
+ ```
376
+
377
+ Отмена — mutating операция, поэтому она использует `POST` с action-суффиксом, а не `DELETE`: отмена не удаляет ресурс и должна возвращать обновлённое состояние подписки.
378
+
379
+ ### `billing.checkout.sessions.create(params, options?)`
380
+
381
+ `POST /v1/checkout/sessions`
382
+
383
+ Создаёт checkout session и возвращает `confirmationUrl` вместе со связанными публичными идентификаторами (`subscription`, `invoice`, `payment`). Redirect сам по себе не подтверждает оплату — источник истины о доступе: `billing.access.retrieve(...)`.
384
+
385
+ ```ts
386
+ const checkout = await billing.checkout.sessions.create(
387
+ {
388
+ customer: 'cus_123',
389
+ price: 'price_pro_monthly',
390
+ successUrl: 'https://app.example.com/billing/success',
391
+ cancelUrl: 'https://app.example.com/billing',
392
+ // promoCode: 'PROMO', // опционально
393
+ },
394
+ { idempotencyKey: 'checkout:ws_123:pro-monthly:v1' }
395
+ );
396
+ ```
397
+
398
+ ### `billing.access.retrieve(customerId, options?)`
399
+
400
+ `GET /v1/customers/{customerId}/access`
401
+
402
+ Возвращает materialized access state: `livemode`, `status`, `features`, `limits`, `currentPeriodEnd`, `graceUntil`.
403
+
404
+ ```ts
405
+ const access = await billing.access.retrieve('cus_123');
406
+ ```
407
+
408
+ Все успешные ответы дополняются полем `requestId` из заголовка `MegaBilling-Request-Id`, если сервер его вернул. Этот header для SDK является единственным authoritative источником correlation ID; возможный `requestId` в JSON body рассматривается только как server echo и не повышается до authoritative client-side значения.
409
+
410
+ ### `billing.meterEvents.create(params, options?)`
411
+
412
+ `POST /v1/billing/meter-events`
413
+
414
+ Принимает usage event по публичному `eventName` и публичному `customer`, не раскрывая внутренние `meterId`, `customerAccountId` или `subscriptionId`.
415
+
416
+ ```ts
417
+ const event = await billing.meterEvents.create({
418
+ eventName: 'ai_tokens',
419
+ customer: 'cus_123',
420
+ value: 1250,
421
+ identifier: 'generation_gen_987',
422
+ timestamp: '2026-08-10T00:00:00.000Z',
423
+ dimensions: { model: 'gpt-5' },
424
+ });
425
+ ```
426
+
427
+ Ответ: `billing.meter_event` с полями `id`, `livemode`, `status`, `eventName`, `customer`, `value`, `identifier`, `timestamp`, `dimensions` и `requestId`.
428
+
429
+ Для безопасного replay повторяйте тот же нормализованный payload с тем же `identifier`. Если исходный запрос не передавал `timestamp`, сервер подставляет текущее время при каждом вызове (включая retry), но само значение `timestamp` не входит в payload fingerprint, когда клиент его не задавал явно — поэтому такой повтор не считается другим payload и не завершается `idempotency_key_conflict`. Если `timestamp` был передан явно в исходном запросе, он становится частью fingerprint, и retry обязан повторить то же самое значение. Public usage contract опирается именно на `identifier`; даже если transport-level `Idempotency-Key` передан вручную, он не заменяет это поле.
430
+
431
+ Событие не привязывается к конкретной подписке клиентом: биллинг сам находит нужную подписку по `project + customer + meter` в момент расчёта счёта.
432
+
433
+ ### `billing.meterEvents.summary(meterId, params, options?)`
434
+
435
+ `GET /v1/billing/meters/{meterId}/event-summaries`
436
+
437
+ Возвращает usage summary по публичному meter code и публичному customer в рамках project, определённого integration key.
438
+
439
+ ```ts
440
+ const summary = await billing.meterEvents.summary('ai_tokens', {
441
+ customer: 'cus_123',
442
+ startTime: '2026-08-10T00:00:00.000Z',
443
+ endTime: '2026-08-10T01:00:00.000Z',
444
+ valueGroupingWindow: 'hour',
445
+ });
446
+
447
+ for (const item of summary.data) {
448
+ console.log(item.meter, item.aggregatedValue, item.startTime, item.endTime);
449
+ }
450
+ ```
451
+
452
+ `meterId` здесь означает публичный meter code. Ответ — list envelope: `{ object: 'list', data: MeterEventSummary[], hasMore: false }` плюс `requestId`.
453
+
454
+ ## Ошибки
455
+
456
+ SDK бросает `BillingError` для ошибок API и транспортных сбоев (включая timeout и network errors).
457
+
458
+ ```ts
459
+ try {
460
+ await billing.checkout.sessions.create(
461
+ {
462
+ customer: 'cus_123',
463
+ price: 'price_pro_monthly',
464
+ successUrl: 'https://app.example.com/billing/success',
465
+ cancelUrl: 'https://app.example.com/billing',
466
+ },
467
+ { idempotencyKey: 'checkout:ws_123:pro-monthly:v1' }
468
+ );
469
+ } catch (error) {
470
+ if (error instanceof BillingError) {
471
+ console.error(error.code);
472
+ console.error(error.message);
473
+ console.error(error.status);
474
+ console.error(error.requestId);
475
+ console.error(error.param);
476
+ }
477
+ }
478
+ ```
479
+
480
+ Для HTTP error-path `BillingError.requestId` читается строго из ответного header `MegaBilling-Request-Id`. Если header отсутствует, `requestId === null` даже если body содержит поле `error.requestId`.
481
+
482
+ Для локальной верификации webhook SDK бросает отдельный `BillingWebhookVerificationError` с кодами:
483
+
484
+ | Код | Когда возникает |
485
+ | --------------------------- | -------------------------------------------------------- |
486
+ | `webhook_header_invalid` | header отсутствует или не содержит валидный `t=` / `v1=` |
487
+ | `webhook_signature_invalid` | payload или secret не совпали с подписью |
488
+ | `webhook_timestamp_expired` | timestamp вышел за tolerance window |
489
+ | `webhook_payload_invalid` | payload не является валидным JSON event envelope |
490
+
491
+ Поля `BillingError`:
492
+
493
+ | Поле | Назначение |
494
+ | ----------- | ------------------------------------------------------------------------------------------------- |
495
+ | `code` | Стабильный код ошибки API или транспортный (`request_timeout`, `network_error`, `http_<status>`) |
496
+ | `status` | HTTP status; `408` для timeout; `0` для network failure |
497
+ | `requestId` | Корреляционный ID только из `MegaBilling-Request-Id`; при отсутствии header SDK возвращает `null` |
498
+ | `param` | Параметр, связанный с ошибкой валидации (если API его вернул) |
499
+
500
+ ### Transport / network errors
501
+
502
+ Для `network_error` SDK намеренно не прокидывает сырой `fetch`/runtime message наружу. Вместо этого возвращается безопасное сообщение:
503
+
504
+ ```txt
505
+ Network request failed. Check connectivity or BILLING_API_URL and retry.
506
+ ```
507
+
508
+ Это поведение защищает consumer logs и UI от случайной утечки низкоуровневых деталей рантайма (`ECONNREFUSED`, DNS internals, stack fragments) и оставляет диагностику в предсказуемом typed error contract.
509
+
510
+ ## Идемпотентность
511
+
512
+ Для mutating-операций передавайте стабильный `idempotencyKey` в `options`. SDK пробрасывает его в заголовок `Idempotency-Key` без изменений.
513
+
514
+ - Повтор с тем же ключом и тем же payload возвращает сохранённый результат.
515
+ - Тот же ключ с другим payload приводит к конфликту на стороне API.
516
+ - Для `meterEvents.create(...)` replay задаётся не header-ом, а обязательным body-полем `identifier`: тот же `identifier` с тем же нормализованным payload возвращает тот же event, а с другим payload приводит к `idempotency_key_conflict`. `timestamp` входит в payload fingerprint только если он был передан явно — retry без `timestamp` не создаёт ложный конфликт.
517
+ - Для read-методов (`customers.retrieve`, `customers.list`, `subscriptions.retrieve`, `subscriptions.list`, `invoices.retrieve`, `invoices.listCreditNotes`, `access.retrieve`) ключ не используется — это GET.
518
+
519
+ | Метод | Идемпотентность |
520
+ | -------------------------- | --------------------------------- |
521
+ | `customers.create` | ключ обязателен на стороне API |
522
+ | `customers.update` | ключ опционален, но рекомендуется |
523
+ | `subscriptions.cancel` | ключ опционален, но рекомендуется |
524
+ | `checkout.sessions.create` | ключ обязателен на стороне API |
525
+ | `meterEvents.create` | replay через `identifier` в body |
526
+
527
+ Рекомендуемый паттерн ключей: привязка к бизнес-сущности consumer-а, например `customer:{externalId}`, `checkout:{externalId}:{price}:v1`, `customer-update:{externalId}:v2`, `subscription-cancel:{subscriptionId}:v1`. Для usage `identifier` тоже стоит привязывать к бизнес-событию consumer-а, например `generation:{generationId}` или `workspace:{workspaceId}:usage:{sequence}`.
528
+
529
+ Подробнее: [`docs/api/idempotency-rules.md`](../../docs/api/idempotency-rules.md).
530
+
531
+ ## Переменные окружения
532
+
533
+ | Переменная | Обязательность | Назначение |
534
+ | --------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------ |
535
+ | `BILLING_API_KEY` | да (для реальных вызовов) | Project-scoped integration key (`bsk_test_...` или `bsk_live_...`) → `Authorization: Bearer ...` |
536
+ | `BILLING_API_URL` | нет | Base URL API; если не задан и не передан `baseUrl`, используется значение по умолчанию пакета |
537
+ | `BILLING_PRICE_ID` | нет | Только для `pnpm run quickstart` (пример) |
538
+ | `APP_URL` | нет | Только для quickstart: базовый URL success/cancel redirect |
539
+ | `WORKSPACE_ID`, `WORKSPACE_EMAIL`, `WORKSPACE_NAME` | нет | Только для quickstart: демо-данные customer |
540
+
541
+ В публичном клиенте Wave 1 **не** используются `BILLING_PROJECT_ID` и `BILLING_HTTP_X_TENANT_ID`: scope задаётся integration key.
542
+
543
+ ## Отличие от `@openaisdk/billing-sdk`
544
+
545
+ | | `@openaisdk/billing-sdk-node` | `@openaisdk/billing-sdk` |
546
+ | ------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
547
+ | Роль | Канонический handwritten SDK для consumer Public API | Generated OpenAPI/axios compatibility-слой |
548
+ | Поверхность | `customers` / `subscriptions` / `checkout.sessions` / `access` / `meterEvents` | Шире: generated client поверх полного Billing API |
549
+ | Auth / headers | Только Bearer integration key | Может опираться на legacy env (`BILLING_PROJECT_ID`, `x-tenant-id`) |
550
+ | Источник контракта | Public API Wave 1 (`/api/public-json`) | Не источник истины для Wave 1 public contract |
551
+
552
+ Для новой consumer-интеграции предпочитайте `@openaisdk/billing-sdk-node`. Пакет `@openaisdk/billing-sdk` допустим как временный compatibility-слой до Wave 2, но не должен диктовать public naming или onboarding.
553
+
554
+ ## Документация Public API
555
+
556
+ - Обзор публичной границы: [`docs/api/public-api-v1.md`](../../docs/api/public-api-v1.md)
557
+ - Сопоставление со Stripe: [`docs/integration/stripe-mapping.md`](../../docs/integration/stripe-mapping.md)
558
+ - Правила идемпотентности: [`docs/api/idempotency-rules.md`](../../docs/api/idempotency-rules.md)
559
+ - Машиночитаемый контракт runtime: `GET /api/public-json` (Swagger UI: `GET /api/public`)
560
+
561
+ ## DX vs Stripe Node SDK
562
+
563
+ Сходства:
564
+
565
+ - resource-oriented форма: `customers.create`, `customers.retrieve`, `subscriptions.list`, `checkout.sessions.create`
566
+ - явная поддержка `Idempotency-Key` на mutating-операциях
567
+ - usage передаётся product-level методом `meterEvents.create` по публичным `eventName` + `customer`
568
+ - типизированные ошибки через `instanceof` и стабильные коды
569
+ - list-ответ как envelope со списком в `data` и признаком продолжения
570
+
571
+ Осознанные отличия:
572
+
573
+ - Mega-Billing использует project-scoped integration key вместо account-wide Stripe secret
574
+ - `externalType` и `externalId` — first-class поля маппинга customer, а не опциональный metadata
575
+ - доступ к продукту читается через `billing.access.retrieve(...)`, а не собирается из subscriptions / invoices / payments
576
+ - для meter events dedup/replay задаётся обязательным `identifier` в body, а не отдельным `Idempotency-Key` header
577
+
578
+ ### Customer & Subscription Lifecycle: сравнение со Stripe
579
+
580
+ | Сценарий | Stripe Node SDK | `@openaisdk/billing-sdk-node` | Почему отличается |
581
+ | ------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
582
+ | Чтение customer | `stripe.customers.retrieve(id)` | `billing.customers.retrieve(id)` | Совпадает по форме; возвращается публичный `cus_...` без внутренних UUID |
583
+ | Список customers | `stripe.customers.list({ limit, starting_after })` | `billing.customers.list({ limit, startingAfter, externalType, externalId })` | camelCase вместо snake_case; фильтр по `externalType`/`externalId` вместо поиска по свободному metadata |
584
+ | Обновление customer | `stripe.customers.update(id, params)` | `billing.customers.update(id, payload, options)` | Обновляются только контактные поля; связка `externalType`/`externalId` иммутабельна |
585
+ | Список подписок | `stripe.subscriptions.list({ customer })`, `customer` опционален | `billing.subscriptions.list({ customer })`, `customer` обязателен | Consumer работает в контексте своей сущности; project-wide перебор подписок не является consumer-сценарием |
586
+ | Отмена подписки | `stripe.subscriptions.cancel(id)` для немедленной и `update(id, { cancel_at_period_end: true })` для отложенной | `billing.subscriptions.cancel(id, { atPeriodEnd })` | Один метод вместо двух разных операций: намерение «отменить» выражается одним вызовом, а момент отмены — флагом |
587
+ | Форма списка | `{ object: 'list', data, has_more }` | `{ object: 'list', data, hasMore }` | Та же mental model, camelCase по конвенции остальной части API |
588
+ | Право доступа | Собирается из subscription status / entitlements | `billing.access.retrieve(customerId)` | Subscription остаётся биллинговым lifecycle; gate фич даёт materialized access state |
589
+
590
+ ## Scope
591
+
592
+ Wave 1 golden path, Customer & Subscription Lifecycle, Wave 3 webhook verification helper и Wave 4 public meter events. Пакет не открывает invoices, payments, refunds, admin API и внутренние заголовки.
593
+
594
+ ## Troubleshooting
595
+
596
+ ### `requestId` в ошибке равен `null`
597
+
598
+ Это ожидаемо, если сервер не вернул `MegaBilling-Request-Id` в ответном header. SDK не доверяет `error.requestId` из body как authoritative источнику correlation ID.
599
+
600
+ ### `BillingError` с `code === 'network_error'`
601
+
602
+ Проверьте:
603
+
604
+ - доступность `BILLING_API_URL`;
605
+ - локальную сеть / VPN / proxy;
606
+ - что backend действительно слушает ожидаемый host и port.
607
+
608
+ Низкоуровневая строка `fetch failed ...` специально не попадает в `BillingError.message`; для глубокой диагностики смотрите server logs и локальный runtime output.
609
+
610
+ ### Scoped локальная пересборка пакета
611
+
612
+ Для локальной проверки используйте узкие команды из корня monorepo:
613
+
614
+ ```bash
615
+ pnpm --filter @openaisdk/billing-sdk-node build
616
+ pnpm --filter @openaisdk/billing-sdk-node test
617
+ ```
618
+
619
+ Если `node_modules/.pnpm` локально оказался частично повреждён и сборка падает на `ENOENT`, сначала повторите именно scoped-команды выше. Только если проблема воспроизводится стабильно, переходите к минимальному восстановлению lockfile-based install, а не к полному destructive reinstall всего workspace.