npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Рекомендуемый сценарий:

  1. Создать Client для внешнего сервиса.
  2. Создать Product и/или SubscriptionPlan с тем же clientName.
  3. Сохранить 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'.

Лицензия

Проприетарный. Только для внутреннего использования.