@besales/robokassa-sdk
v0.2.0
Published
SDK клиент для Robokassa Payment Microservice (server-side only)
Downloads
84
Readme
@besales/robokassa-sdk
TypeScript SDK для интеграции с микросервисом обработки платежей через Robokassa.
SERVER-SIDE ONLY — не использовать в браузере. SDK работает с API-ключами, которые нельзя раскрывать на клиенте.
Установка
yarn add @besales/robokassa-sdk
# или
npm install @besales/robokassa-sdkТребования: Node.js >= 18.0.0 (используется нативный fetch)
Быстрый старт
import { RobokassaPaymentClient } from '@besales/robokassa-sdk';
const client = new RobokassaPaymentClient({
baseUrl: 'https://payments.example.com',
apiKey: process.env.PAYMENT_SERVICE_API_KEY!, // глобальный ключ
clientApiKey: process.env.PAYMENT_SERVICE_CLIENT_API_KEY!, // ключ клиента
});
// Одноразовый платёж
const { paymentLink, paymentId } = await client.payments.create({
userId: 'user-uuid',
productId: 'product-uuid',
});
// → redirect пользователя на paymentLink
// Подписка
const result = await client.subscriptions.create({
userId: 'user-uuid',
planId: 'plan-uuid',
});
if (result.requiresPayment) {
// → redirect на result.paymentLink
}Двойная аутентификация
SDK использует два API-ключа:
| Ключ | Откуда взять | Для чего |
|------|-------------|----------|
| apiKey | Переменная окружения API_KEY на сервере платежей | CRUD клиентов, продуктов, планов; создание платежей и подписок; списки; статистика |
| clientApiKey | Поле apiKey из таблицы Client (создаётся в админке) | Получение/отмена/пауза/возобновление конкретных подписок |
// Методы, требующие Global API Key (apiKey)
client.clients.create(...) client.products.list()
client.plans.create(...) client.payments.create(...)
client.subscriptions.create(...)
client.subscriptions.list(...)
// Методы, требующие Client API Key (clientApiKey)
client.subscriptions.getById(id)
client.subscriptions.cancel(id, userId)
client.subscriptions.pause(id, userId)
client.subscriptions.resume(id, userId)
client.subscriptions.changePlan(id, userId, input)
client.subscriptions.clearScheduledPlanChange(id, userId)Если clientApiKey не передан в конструктор, вызов методов с auth: 'client' бросит ошибку.
Мультиклиентская Модель
Один payment service может хранить данные многих клиентов одновременно.
Это значит:
Client— это внешний сервис;ProductиSubscriptionPlanпринадлежат конкретномуclientName;- наличие тарифов у клиента
Aничего не говорит о наличии тарифов у клиентаB.
Практический вывод:
- если у вашего backend
PAYMENT_SERVICE_CLIENT_NAME=beprompt, то вам нужны планы именно дляbeprompt; - планы клиента
12astroне будут “общими” и не закроют каталог дляbeprompt.
Рекомендуемый сценарий:
- Создать
Clientдля внешнего сервиса. - Создать
Productи/илиSubscriptionPlanс тем жеclientName. - Сохранить remote
planIdво внешнем сервисе.
Если вы строите каталог подписок, фильтруйте планы по clientName:
const { data: plans } = await client.plans.list({
clientName: process.env.PAYMENT_SERVICE_CLIENT_NAME!,
isActive: true,
});Модули
Клиенты (clients)
// Список всех клиентов
const clients = await client.clients.list();
// Создать клиента
const newClient = await client.clients.create({
name: 'my_bot',
apiUrl: 'https://my-bot.example.com',
apiKey: 'webhook-secret-key',
});
// Получить по ID
const bot = await client.clients.getById('client-id');
// Обновить
await client.clients.update('client-id', { apiUrl: 'https://new-url.com' });
// Удалить (ошибка если есть привязанные продукты)
await client.clients.delete('client-id');Продукты (products)
Цена продуктов указывается в рублях.
// Все продукты
const products = await client.products.list();
// Продукты конкретного клиента
const botProducts = await client.products.listByClient('my_bot');
// Создать
const product = await client.products.create({
name: 'Пакет 100 генераций',
price: 999, // 999 рублей
generations: 100,
clientName: 'my_bot',
});Тарифные планы (plans)
Цена планов указывается в копейках (99900 = 999 рублей).
// Список с пагинацией
const { data: plans, total, page, limit } = await client.plans.list({
clientName: 'my_bot',
isActive: true,
page: 1,
limit: 20,
});
// Создать план
const plan = await client.plans.create({
name: 'Pro подписка',
price: 99900, // 999 рублей (в копейках!)
interval: 'MONTHLY',
generations: 1000,
clientName: 'my_bot',
trialDays: 7, // 7 дней бесплатного триала
});
// Обновить (PATCH, не PUT)
await client.plans.update('plan-id', { price: 149900 });
// Удалить/деактивировать
const result = await client.plans.delete('plan-id');
// result.deleted или result.deactivatedУдаление работает так:
- если у плана нет подписок, он удаляется физически;
- если у плана уже есть подписки, сервис не удаляет его физически, а деактивирует (
isActive=false).
Если нужно просто убрать тариф из каталога, обычно безопаснее сделать:
await client.plans.update('plan-id', { isActive: false });Платежи (payments)
// Создать платёж
const { paymentLink, paymentId } = await client.payments.create({
userId: 'user-uuid',
productId: 'product-uuid',
language: 'ru',
robokassaConfigId: 'clp...', // опционально
});
// → redirect пользователя на paymentLink
// Обновить данные платежа (до оплаты)
await client.payments.update(paymentId, { username: 'john_doe' });Подписки (subscriptions)
// Создать подписку
const result = await client.subscriptions.create({
userId: 'user-uuid',
planId: 'plan-uuid',
language: 'ru',
robokassaConfigId: 'clp...', // опционально
});
if (result.requiresPayment) {
// Подписка без триала — нужна оплата
// redirect на result.paymentLink
} else {
// Подписка с триалом — уже активна
}
// Список подписок
const { data: subs, count } = await client.subscriptions.list({
userId: 'user-uuid',
status: 'ACTIVE',
page: 1,
limit: 20,
});
// Получить по ID (требует clientApiKey)
const sub = await client.subscriptions.getById('sub-id');
// Отменить (требует clientApiKey)
await client.subscriptions.cancel('sub-id', 'user-id');
// Приостановить (требует clientApiKey)
await client.subscriptions.pause('sub-id', 'user-id', 2); // на 2 месяца
// Возобновить (требует clientApiKey)
await client.subscriptions.resume('sub-id', 'user-id');
// Немедленный апгрейд на более дорогой тариф (требует clientApiKey)
const upgrade = await client.subscriptions.changePlan('sub-id', 'user-id', {
newPlanId: 'plan-pro',
applyAt: 'immediately',
});
if (upgrade.requiresPayment) {
// Для активации апгрейда нужна доплата
// redirect на upgrade.paymentLink
}
// Запланированный даунгрейд на конец периода (требует clientApiKey)
const scheduled = await client.subscriptions.changePlan('sub-id', 'user-id', {
newPlanId: 'plan-basic',
applyAt: 'end_of_period',
});
if (scheduled.isScheduled) {
console.log(
'Переход запланирован на',
scheduled.subscription.scheduledPlanChangeAt
);
}
// Отменить запланированную смену тарифа (требует clientApiKey)
await client.subscriptions.clearScheduledPlanChange('sub-id', 'user-id');
// Статистика
const stats = await client.subscriptions.getStats();
const cronStats = await client.subscriptions.getCronStats();Правила changePlan():
applyAt: 'immediately'поддерживает только переход на план с большей ценой и возвращаетpaymentLinkдля доплаты;applyAt: 'end_of_period'поддерживает только переход на план с меньшей ценой и записываетscheduledPlanId/scheduledPlanChangeAt;- смена между разными
intervalразрешена, решение принимается только по абсолютной цене нового плана.
Health check
const health = await client.healthcheck();
// { status: 'ok', service: 'payment-service', timestamp: '...' }
const subHealth = await client.subscriptions.health();Webhook-обработчики
SDK предоставляет готовые обработчики webhook'ов с валидацией API-ключа.
Платёжный сервис отправляет два типа webhook'ов на ваш сервис:
POST {apiUrl}/api/user/webhook— уведомления о платежахPOST {apiUrl}/api/user/subscription-webhook— изменения статуса подписок
Express
import { createExpressWebhookMiddleware } from '@besales/robokassa-sdk/webhooks';
// Webhook платежей
app.post('/api/user/webhook', ...createExpressWebhookMiddleware({
apiKey: process.env.WEBHOOK_API_KEY!,
onPayment: async (payload) => {
console.log('Платёж:', payload.paymentId, payload.status);
console.log('Генерации:', payload.generationsAdded);
console.log('Мультикасса:', payload.robokassaCode);
if (payload.status === 'PAID') {
await addGenerations(payload.userId, payload.generationsAdded);
}
},
}));
// Webhook подписок
app.post('/api/user/subscription-webhook', ...createExpressWebhookMiddleware({
apiKey: process.env.WEBHOOK_API_KEY!,
onSubscription: async (payload) => {
console.log('Подписка:', payload.subscriptionId, payload.status, payload.eventType);
if (payload.eventType === 'PLAN_CHANGED') {
await recalculateQuota(payload.userId, payload.planId, payload.previousPlanId);
}
if (payload.status === 'CANCELLED') {
await deactivateUser(payload.userId);
}
},
}));Fastify
import { robokassaWebhookPlugin } from '@besales/robokassa-sdk/webhooks';
fastify.register(robokassaWebhookPlugin, {
apiKey: process.env.WEBHOOK_API_KEY!,
onPayment: async (payload) => {
await addGenerations(payload.userId, payload.generationsAdded);
},
onSubscription: async (payload) => {
if (payload.status === 'CANCELLED') {
await deactivateUser(payload.userId);
}
},
});Framework-agnostic (core)
import {
validateWebhookApiKey,
parsePaymentWebhook,
parseSubscriptionWebhook,
} from '@besales/robokassa-sdk/webhooks';
// В любом фреймворке
function handleRequest(headers: Record<string, string>, body: unknown) {
if (!validateWebhookApiKey(headers, 'expected-key')) {
return { status: 401 };
}
const payload = parsePaymentWebhook(body);
// ... обработка
}Обработка ошибок
SDK предоставляет типизированные ошибки:
import {
RateLimitError,
NotFoundError,
ValidationError,
UnauthorizedError,
ConflictError,
OutcomeUnknownError,
} from '@besales/robokassa-sdk';
try {
await client.payments.create({ userId: 'uuid', productId: 'uuid' });
} catch (error) {
// API-ошибки (наследуются от PaymentApiError)
if (error instanceof RateLimitError) {
// 429 — превышен rate limit
console.log(`Повторите через ${error.retryAfterMs}ms`);
} else if (error instanceof NotFoundError) {
// 404 — продукт или пользователь не найден
} else if (error instanceof ValidationError) {
// 400 — невалидные входные данные
} else if (error instanceof UnauthorizedError) {
// 401 — неверный API-ключ
} else if (error instanceof ConflictError) {
// 409 — конфликт (дубликат подписки, имя занято)
}
// ⚠️ OutcomeUnknownError НЕ наследует PaymentApiError!
// Это отдельный тип — «результат запроса неизвестен», не «API вернул ошибку».
// catch (e instanceof PaymentApiError) его НЕ поймает.
if (error instanceof OutcomeUnknownError) {
// Сетевая ошибка на POST/PATCH/DELETE
// Запрос мог выполниться, а мог нет — проверьте через GET перед повтором!
console.log('Оригинальная ошибка:', error.originalError);
}
}Retry-политика
| Метод | Retry при сетевой ошибке | Retry при 429 | Почему |
|-------|--------------------------|---------------|--------|
| GET | Автоматически (2 попытки) | Автоматически | Безопасно, идемпотентно |
| POST | Нет → OutcomeUnknownError | Нет → RateLimitError | Нет idempotency keys — может создать дубликат |
| PATCH | Нет → OutcomeUnknownError | Нет → RateLimitError | Нет гарантий идемпотентности |
| DELETE | Нет → OutcomeUnknownError | Нет → RateLimitError | Может выполниться дважды |
Принудительный retry для mutating-запроса (только если уверены):
// Opt-in retry для POST
const result = await client.someModule.someMethod(data, { retry: true });Конфигурация
const client = new RobokassaPaymentClient({
baseUrl: 'https://payments.example.com', // Обязательный
apiKey: 'global-api-key', // Обязательный
clientApiKey: 'client-api-key', // Для операций с подписками
language: 'ru', // Язык ответов: 'ru' | 'en'
timeout: 10_000, // Таймаут в мс (дефолт: 10000)
retries: 2, // Retry для GET (дефолт: 2)
});Webhook payload'ы
PaymentWebhookPayload
| Поле | Тип | Описание |
|------|-----|----------|
| paymentId | string | ID платежа |
| userId | string \| null | ID пользователя |
| status | string | Статус: PAID, FAILED |
| amount | number \| null | Сумма в рублях |
| generationsAdded | number | Количество начисленных генераций |
| productName | string | Название продукта |
| robokassaCode | string | Код Robokassa-конфигурации |
| isRecurring | boolean? | Рекуррентный платёж? |
| subscriptionType | string? | Тип подписки (MONTHLY и т.д.) |
| subscriptionDays | number? | Дней подписки |
SubscriptionWebhookPayload
| Поле | Тип | Описание |
|------|-----|----------|
| subscriptionId | string | ID подписки |
| userId | string | ID пользователя |
| status | string | Статус: ACTIVE, CANCELLED, PAUSED, EXPIRED |
| planId | string | ID тарифного плана |
| scheduledPlanId | string \| null | ID плана, на который запланирован переход |
| scheduledPlanChangeAt | string \| null | Дата автопереключения плана (ISO 8601) |
| previousPlanId | string \| null | Предыдущий план при eventType = PLAN_CHANGED |
| startDate | string | Дата начала (ISO 8601) |
| endDate | string \| null | Дата окончания |
| nextBillingDate | string \| null | Следующее списание |
| isActive | boolean | Активна ли подписка |
| autoRenew | boolean | Автопродление |
| language | string | Язык |
| eventType | 'STATE_CHANGE' \| 'UPCOMING_BILLING' \| 'PLAN_CHANGED' | Тип события webhook |
| robokassaCode | string | Код Robokassa-конфигурации |
PLAN_CHANGED приходит при фактическом применении смены тарифа:
- сразу после успешной доплаты за
applyAt: 'immediately'; - после успешного списания нового периода для
applyAt: 'end_of_period'.
Лицензия
Проприетарный. Только для внутреннего использования.
