@openaisdk/billing-sdk-node 1.3.0 → 1.11.1
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 +123 -508
- package/bin/billing-sdk-webhook-forward.mjs +133 -0
- package/dist/index.d.ts +187 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +86 -1
- package/examples/quickstart.ts +51 -8
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -1,268 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@openaisdk/billing-sdk-node`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Библиотека для Node.js: ваш SaaS принимает оплату и открывает фичи через MegaBilling.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Нужны личный кабинет MegaBilling и npm. Node.js ≥ 20.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- Аутентификация только `Authorization: Bearer <integration key>`. Tenant и project из ключа, не из заголовков.
|
|
9
|
-
- Типы и ошибки, которые видит интегратор. Поведение совпадает с `GET /api/public-json`.
|
|
7
|
+
---
|
|
10
8
|
|
|
11
|
-
##
|
|
9
|
+
## Подготовка в кабинете
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
- MCP для агентов — [`billing-catalog-mcp`](../billing-catalog-mcp/README.md).
|
|
15
|
-
- Доменные пакеты платформы (`*-domain`): SDK только HTTP-клиент.
|
|
16
|
-
- Хранение секретов, ретраи провайдера, фискализация.
|
|
11
|
+
Сделайте это до кода. Иначе SDK не к чему подключаться.
|
|
17
12
|
|
|
18
|
-
|
|
13
|
+
1. **Тарифы и цены.** Заведите хотя бы один активный тариф и цену. Добавьте фичи, которые будете проверять в продукте.
|
|
14
|
+
2. **URL сервера для событий.** HTTPS-адрес вашего бэкенда (например `https://api.myapp.com/billing/webhook`). Туда MegaBilling шлёт JSON при оплате и смене подписки.
|
|
15
|
+
3. **Секрет подписи.** Нужен, чтобы через SDK убедиться, что запрос от MegaBilling.
|
|
16
|
+
4. **Ключ API.** Тестовый `bsk_test_…` или боевой `bsk_live_…` — код тот же. Для проверки берите тестовый (`BILLING_API_KEY`). Поле `livemode` в ответах и событиях показывает, тест это или бой.
|
|
19
17
|
|
|
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` |
|
|
18
|
+
---
|
|
29
19
|
|
|
30
|
-
|
|
20
|
+
## Шаги интеграции
|
|
31
21
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
Требования: Node.js ≥ 20.
|
|
35
|
-
|
|
36
|
-
## Установка
|
|
22
|
+
### 1. Установить пакет
|
|
37
23
|
|
|
38
24
|
```bash
|
|
39
25
|
pnpm add @openaisdk/billing-sdk-node
|
|
26
|
+
# или: npm i @openaisdk/billing-sdk-node
|
|
40
27
|
```
|
|
41
28
|
|
|
42
|
-
## Инициализация
|
|
43
|
-
|
|
44
|
-
Для hosted-окружения достаточно integration key вида `bsk_test_...` или `bsk_live_...`. Для local / self-hosted задайте `BILLING_API_URL` в окружении или передайте `baseUrl` явно.
|
|
45
|
-
|
|
46
29
|
```ts
|
|
47
|
-
import {
|
|
30
|
+
import { MegaBilling } from '@openaisdk/billing-sdk-node';
|
|
48
31
|
|
|
49
32
|
const billing = new MegaBilling({
|
|
50
33
|
apiKey: process.env.BILLING_API_KEY!,
|
|
51
|
-
// baseUrl: 'http://127.0.0.1:4001', // опционально для local/self-hosted
|
|
52
|
-
// timeoutMs: 30_000, // опционально, по умолчанию 30s
|
|
53
34
|
});
|
|
54
35
|
```
|
|
55
36
|
|
|
56
|
-
|
|
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 данные.
|
|
37
|
+
Параметр `baseUrl` или `BILLING_API_URL` — только если API на своём хосте.
|
|
64
38
|
|
|
65
|
-
|
|
39
|
+
### 2. Каталог
|
|
66
40
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
Типичный сценарий consumer-backend: создать customer → открыть checkout → прочитать access state.
|
|
41
|
+
Тарифы — `products`, цены — `prices`. Код фичи для `access.check` — `Feature.code`.
|
|
70
42
|
|
|
71
43
|
```ts
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
});
|
|
44
|
+
const products = await billing.products.list({ active: true, limit: 20 });
|
|
45
|
+
const product = products.data[0]!;
|
|
46
|
+
const prices = await billing.prices.list({ product: product.id, active: true });
|
|
47
|
+
const features = await billing.features.list();
|
|
77
48
|
|
|
78
|
-
|
|
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
|
-
});
|
|
49
|
+
console.log(product.name, prices.data[0]?.unitAmountMinor, features.data[0]?.code);
|
|
125
50
|
```
|
|
126
51
|
|
|
127
|
-
|
|
52
|
+
### 3. Создать клиента
|
|
128
53
|
|
|
129
|
-
|
|
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-сценарий
|
|
54
|
+
Свяжите пользователя с MegaBilling через `externalType` + `externalId`. Передайте `idempotencyKey`, чтобы повтор не создал второго клиента.
|
|
137
55
|
|
|
138
|
-
|
|
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`.
|
|
56
|
+
`externalType`: `'workspace' | 'tenant' | 'company' | 'org'`.
|
|
266
57
|
|
|
267
58
|
```ts
|
|
268
59
|
const customer = await billing.customers.create(
|
|
@@ -270,350 +61,174 @@ const customer = await billing.customers.create(
|
|
|
270
61
|
externalType: 'workspace',
|
|
271
62
|
externalId: 'ws_123',
|
|
272
63
|
email: 'owner@example.com',
|
|
273
|
-
name: '
|
|
274
|
-
phone: '+79990000000',
|
|
64
|
+
name: 'Acme Workspace',
|
|
275
65
|
},
|
|
276
66
|
{ idempotencyKey: 'customer:ws_123' }
|
|
277
67
|
);
|
|
278
68
|
```
|
|
279
69
|
|
|
280
|
-
|
|
70
|
+
### 4. Открыть оплату
|
|
281
71
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
`GET /v1/customers/{customerId}`
|
|
285
|
-
|
|
286
|
-
Читает текущее состояние customer по публичному `cus_...` идентификатору.
|
|
72
|
+
`checkout.sessions.create` → отправьте человека на `confirmationUrl`. Для create/checkout передавайте `idempotencyKey`.
|
|
287
73
|
|
|
288
74
|
```ts
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
}
|
|
75
|
+
const checkout = await billing.checkout.sessions.create(
|
|
76
|
+
{
|
|
77
|
+
customer: customer.id,
|
|
78
|
+
price: prices.data[0]!.id,
|
|
79
|
+
successUrl: 'https://app.example.com/billing/success',
|
|
80
|
+
cancelUrl: 'https://app.example.com/billing',
|
|
81
|
+
},
|
|
82
|
+
{ idempotencyKey: `checkout:${customer.id}:${prices.data[0]!.id}:v1` }
|
|
83
|
+
);
|
|
309
84
|
|
|
310
|
-
|
|
85
|
+
redirect(checkout.confirmationUrl);
|
|
311
86
|
```
|
|
312
87
|
|
|
313
|
-
|
|
88
|
+
### 5. Принять событие на своём сервере
|
|
314
89
|
|
|
315
|
-
|
|
90
|
+
`successUrl` — только UX. **Не** открывайте доступ и **не** меняйте подписку по факту возврата на success-страницу. Правда приходит отдельным HTTP POST на URL из кабинета.
|
|
316
91
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
92
|
+
1. Сырое тело запроса (до `JSON.parse`).
|
|
93
|
+
2. `webhooks.constructEvent` + секрет + заголовок `x-billing-signature`.
|
|
94
|
+
3. Дедуп по `event.id`.
|
|
95
|
+
4. Уже потом — бизнес-логика по `event.type`.
|
|
320
96
|
|
|
321
97
|
```ts
|
|
322
|
-
|
|
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
|
-
```
|
|
98
|
+
import { BillingWebhookVerificationError } from '@openaisdk/billing-sdk-node';
|
|
332
99
|
|
|
333
|
-
|
|
100
|
+
async function handleBillingWebhook(request: {
|
|
101
|
+
rawBody: Buffer;
|
|
102
|
+
headers: Record<string, string | string[] | undefined>;
|
|
103
|
+
}) {
|
|
104
|
+
const rawBody = request.rawBody.toString('utf8');
|
|
105
|
+
const signature = request.headers['x-billing-signature'];
|
|
334
106
|
|
|
335
|
-
|
|
107
|
+
let event;
|
|
108
|
+
try {
|
|
109
|
+
event = billing.webhooks.constructEvent(
|
|
110
|
+
rawBody,
|
|
111
|
+
Array.isArray(signature) ? signature[0] : signature,
|
|
112
|
+
process.env.MEGABILLING_WEBHOOK_SECRET!
|
|
113
|
+
);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error instanceof BillingWebhookVerificationError) return;
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
336
118
|
|
|
337
|
-
|
|
119
|
+
if (await alreadyProcessed(event.id)) return;
|
|
338
120
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
121
|
+
switch (event.type) {
|
|
122
|
+
default:
|
|
123
|
+
console.log(event.id, event.type, event.data);
|
|
124
|
+
}
|
|
342
125
|
|
|
343
|
-
|
|
126
|
+
await markProcessed(event.id);
|
|
127
|
+
}
|
|
128
|
+
```
|
|
344
129
|
|
|
345
|
-
|
|
130
|
+
Оболочка события: `id` (дедуп), `type`, `data`, плюс `object`, `livemode`, `apiVersion`, `createdAt`, `version`. Полный список `type` — в типах `BILLING_WEBHOOK_EVENT_TYPES` / `BillingWebhookEventMap`.
|
|
346
131
|
|
|
347
|
-
|
|
132
|
+
Локально: секрет из кабинета + `constructEvent` на сыром теле.
|
|
348
133
|
|
|
349
|
-
|
|
134
|
+
### 6. Спросить доступ перед фичей
|
|
350
135
|
|
|
351
|
-
|
|
136
|
+
Перед платной функцией спрашивайте MegaBilling через `access.retrieve` / `access.check`. Не решайте по статусу оплаты сами.
|
|
352
137
|
|
|
353
138
|
```ts
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
});
|
|
139
|
+
const access = await billing.access.retrieve(customer.id);
|
|
140
|
+
const check = await billing.access.check(customer.id, 'ai_assistant');
|
|
141
|
+
|
|
142
|
+
console.log(access.status, access.features, check.allowed, check.remaining);
|
|
359
143
|
```
|
|
360
144
|
|
|
361
|
-
|
|
145
|
+
Кэш снимка доступа у себя — нормально (обновляйте по webhook). Источник правды — MegaBilling. При сомнении — свежий `access.retrieve`. Не открывайте доступ только по `successUrl`.
|
|
362
146
|
|
|
363
|
-
|
|
147
|
+
### 7. Отмена и смена тарифа
|
|
364
148
|
|
|
365
|
-
|
|
149
|
+
**Отмена** (`atPeriodEnd: true` — до конца периода; `false` — сразу):
|
|
366
150
|
|
|
367
151
|
```ts
|
|
368
|
-
const
|
|
369
|
-
|
|
152
|
+
const subs = await billing.subscriptions.list({ customer: customer.id });
|
|
153
|
+
const subscriptionId = subs.data[0]!.id;
|
|
154
|
+
|
|
155
|
+
await billing.subscriptions.cancel(
|
|
156
|
+
subscriptionId,
|
|
370
157
|
{ atPeriodEnd: true },
|
|
371
|
-
{ idempotencyKey:
|
|
158
|
+
{ idempotencyKey: `subscription-cancel:${subscriptionId}:v1` }
|
|
372
159
|
);
|
|
373
|
-
|
|
374
|
-
console.log(subscription.status, subscription.cancelAtPeriodEnd);
|
|
375
160
|
```
|
|
376
161
|
|
|
377
|
-
|
|
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(...)`.
|
|
162
|
+
**Смена тарифа** — сначала preview, потом change:
|
|
384
163
|
|
|
385
164
|
```ts
|
|
386
|
-
const
|
|
165
|
+
const preview = await billing.subscriptions.previewChange(subscriptionId, {
|
|
166
|
+
price: 'price_pro_monthly',
|
|
167
|
+
effectivePolicy: 'IMMEDIATE', // или 'PERIOD_END'
|
|
168
|
+
prorationBehavior: 'PRORATE', // или 'NONE'
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const change = await billing.subscriptions.change(
|
|
172
|
+
subscriptionId,
|
|
387
173
|
{
|
|
388
|
-
|
|
389
|
-
|
|
174
|
+
preview: preview.id,
|
|
175
|
+
expectedTotalMinor: preview.dueNowMinor,
|
|
390
176
|
successUrl: 'https://app.example.com/billing/success',
|
|
391
177
|
cancelUrl: 'https://app.example.com/billing',
|
|
392
|
-
// promoCode: 'PROMO', // опционально
|
|
393
178
|
},
|
|
394
|
-
{ idempotencyKey:
|
|
179
|
+
{ idempotencyKey: `change:${subscriptionId}:v1` }
|
|
395
180
|
);
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
### `billing.access.retrieve(customerId, options?)`
|
|
399
|
-
|
|
400
|
-
`GET /v1/customers/{customerId}/access`
|
|
401
181
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
```ts
|
|
405
|
-
const access = await billing.access.retrieve('cus_123');
|
|
182
|
+
// Если нужна доплата — отправьте пользователя на change.confirmationUrl
|
|
183
|
+
console.log(change.confirmationUrl);
|
|
406
184
|
```
|
|
407
185
|
|
|
408
|
-
|
|
186
|
+
### По желанию: usage и счета
|
|
409
187
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
`POST /v1/billing/meter-events`
|
|
413
|
-
|
|
414
|
-
Принимает usage event по публичному `eventName` и публичному `customer`, не раскрывая внутренние `meterId`, `customerAccountId` или `subscriptionId`.
|
|
188
|
+
Usage: `meterEvents.create` (повтор защищает `identifier`) и `meterEvents.summary`. Счета: только чтение через `invoices.list` / `invoices.retrieve`.
|
|
415
189
|
|
|
416
190
|
```ts
|
|
417
|
-
|
|
191
|
+
await billing.meterEvents.create({
|
|
418
192
|
eventName: 'ai_tokens',
|
|
419
|
-
customer:
|
|
193
|
+
customer: customer.id,
|
|
420
194
|
value: 1250,
|
|
421
195
|
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
196
|
});
|
|
446
197
|
|
|
447
|
-
|
|
448
|
-
console.log(item.meter, item.aggregatedValue, item.startTime, item.endTime);
|
|
449
|
-
}
|
|
198
|
+
const invoices = await billing.invoices.list({ customer: customer.id, limit: 20 });
|
|
450
199
|
```
|
|
451
200
|
|
|
452
|
-
|
|
201
|
+
---
|
|
453
202
|
|
|
454
203
|
## Ошибки
|
|
455
204
|
|
|
456
|
-
SDK бросает `BillingError` для ошибок API и транспортных сбоев (включая timeout и network errors).
|
|
457
|
-
|
|
458
205
|
```ts
|
|
459
206
|
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
|
-
);
|
|
207
|
+
await billing.checkout.sessions.create(/* ... */);
|
|
469
208
|
} catch (error) {
|
|
470
209
|
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);
|
|
210
|
+
console.error(error.code, error.status, error.requestId, error.param);
|
|
476
211
|
}
|
|
477
212
|
}
|
|
478
213
|
```
|
|
479
214
|
|
|
480
|
-
|
|
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.
|
|
215
|
+
`requestId` передайте в поддержку MegaBilling. Неверная подпись webhook — `BillingWebhookVerificationError`.
|
|
509
216
|
|
|
510
|
-
|
|
217
|
+
---
|
|
511
218
|
|
|
512
|
-
|
|
219
|
+
## Чем пакет не занимается
|
|
513
220
|
|
|
514
|
-
-
|
|
515
|
-
-
|
|
516
|
-
-
|
|
517
|
-
-
|
|
221
|
+
- Админку каталога и ключи настраиваете в кабинете.
|
|
222
|
+
- Аннулирование счёта и credit note — в кабинете.
|
|
223
|
+
- Настройки платёжного провайдера и фискализации — не через этот пакет.
|
|
224
|
+
- Hosted Billing Portal и выплаты нескольким продавцам в этом публичном API нет.
|
|
518
225
|
|
|
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).
|
|
226
|
+
---
|
|
530
227
|
|
|
531
228
|
## Переменные окружения
|
|
532
229
|
|
|
533
|
-
| Переменная
|
|
534
|
-
|
|
|
535
|
-
| `BILLING_API_KEY`
|
|
536
|
-
| `BILLING_API_URL`
|
|
537
|
-
| `
|
|
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.
|
|
230
|
+
| Переменная | Нужна? | Зачем |
|
|
231
|
+
| ---------------------------- | ----------- | --------------------------------------- |
|
|
232
|
+
| `BILLING_API_KEY` | Да | Ключ API из кабинета |
|
|
233
|
+
| `BILLING_API_URL` | Нет | Адрес API, если он нестандартный |
|
|
234
|
+
| `MEGABILLING_WEBHOOK_SECRET` | Для событий | Секрет для проверки подписи из кабинета |
|