Перейти к содержанию

Интеграция оплаты: YooKassa

Что оплачивается

Есть два независимых продукта:

  1. подписка KarmaCode — доступ к глубокому разбору, маршруту, прогнозам и трекеру;
  2. консультация практика — разовая покупка поверх подписки с комиссией платформы.

Они не должны использовать одну «универсальную» запись без типа: различаются продавец, чек, возврат, entitlement и юридическая схема.

Тарифы подписки

Домен и API сразу поддерживают несколько Product и несколько версий Price на продукт. В MVP seed создаёт один активный месячный тариф в RUB без trial; его точная сумма является конфигурацией каталога, а не константой в коде. Одновременно может продаваться только явно активная версия цены, но старые orders/subscriptions продолжают ссылаться на ту версию, по которой были созданы. Так добавление второго тарифа не потребует переделки заказов, entitlement или истории платежей.

Adapter и источник истины

billing обращается к PaymentProvider interface. Для YooKassa MVP разумно использовать небольшой HTTP adapter на встроенном fetch, сверенный с их автоматически обновляемой OpenAPI-спецификацией; официального Node.js SDK в списке серверных SDK нет, а случайный community SDK не должен определять доменную модель.

Состояние в нашей БД — операционная проекция, но факт проведения/возврата подтверждает YooKassa. Страница return_url никогда не выдаёт доступ сама: она показывает processing, пока webhook или проверочный GET не подтвердит статус.

Первичная подписка

  1. Клиент выбирает активную версию тарифа; backend создаёт Order и PaymentAttempt с неизменяемыми суммой, валютой RUB, описанием и receipt lines.
  2. Backend формирует UUID idempotency key, сохраняет его до вызова и делает POST /v3/payments с capture: true, redirect confirmation, metadata внутренних ID и save_payment_method: true.
  3. YooKassa возвращает provider payment ID и confirmation URL; секретный ключ никогда не уходит клиенту.
  4. После payment.succeeded backend делает контрольный GET объекта, сравнивает shop, status, amount, currency и metadata, сохраняет только provider payment_method.id и признак saved, активирует subscription/entitlement.
  5. Пользователь заранее видит период, сумму, правила автопродления и способ отмены; согласие фиксируется отдельно.

Официальный сценарий сохранения способа описан в документации YooKassa. Номер карты и CVC backend не принимает и не хранит.

Продление

Отдельный worker выбирает подписки с currentPeriodEnd в окне продления:

  1. атомарно создаёт один invoice на период и payment attempt с уникальным внутренним ключом;
  2. создаёт платёж с сохранённым payment_method_id, без пользовательского redirect;
  3. при успехе сдвигает период ровно от прежней границы и создаёт entitlement;
  4. при отказе ставит past_due, уведомляет и повторяет по утверждённому графику;
  5. после grace period ставит expired, но не удаляет историю;
  6. cancelAtPeriodEnd прекращает новые списания, сохраняя доступ до оплаченной даты.

Повтор задачи не может создать второй invoice/платёж за тот же период. Смена цены создаёт новую price version; уже созданный invoice не пересчитывается.

Webhooks

Endpoint: POST /api/v1/webhooks/yookassa, без пользовательской auth, но с provider verification.

  • HTTPS на 443/8443 и TLS 1.2+;
  • проверка текущего объекта через authenticated GET; allowlist IP может быть дополнительным, но не единственным контролем;
  • события минимум payment.waiting_for_capture, payment.succeeded, payment.canceled, refund.succeeded;
  • дедупликация по provider/object/status и hash payload, monotonic state transition;
  • raw payload и время сохраняются в ProviderEvent, секретные/избыточные платёжные поля редактируются;
  • ответ 200 только после durable commit; повторная доставка безопасна;
  • неизвестное валидное событие сохраняется и отвечает 200, невалидное — 4xx;
  • тяжёлая downstream-работа (AI/уведомление) создаётся через outbox.

YooKassa повторяет не подтверждённые уведомления до 24 часов и рекомендует проверять статус или source IP: официальные webhooks.

Идемпотентность и ошибки

Для каждого POST/DELETE в YooKassa отправляется стабильный Idempotence-Key до 64 символов. Провайдер гарантирует его обработку в пределах 24 часов, поэтому наша БД дополнительно хранит вечную бизнес-уникальность операции. При network timeout повторяем тот же payload с тем же ключом либо делаем GET, но не создаём новый платёж.

Официальное описание: формат API и Idempotence-Key.

Сумма — Decimal(12,2) либо целые копейки с ISO currency. number/float для финансовых вычислений запрещён.

Консультации, комиссия и выплаты

До внешнего пилота надо выбрать договорную модель:

  • Split payments: YooKassa распределяет один платёж продавцам и удерживает platform fee. Платформа должна получить соответствующий статус, а практики — подключиться как магазины. Это ближе к требованию «автосплит»: официальное описание.
  • Безопасная сделка: деньги удерживаются до оказания услуги и затем выплачиваются физлицу; подходит не всем статусам и имеет отдельные лимиты/идентификацию.
  • Платформа — продавец: обычный приём и отдельные расчёты с практиком. Возможность зависит от договоров, налогов, кассы и сути услуги; это не техническое решение команды разработки.

Для внутреннего тестирования принимается первичный режим «платформа — продавец + ручной settlement» без автоматической выплаты практику. Его нельзя выдавать за split: до внешнего пилота схема должна получить письменное подтверждение бизнеса/юриста и отражение в оферте. Внутренний ledger в любом случае хранит gross, provider fee, platform fee, practitioner payable, refund allocation и settlement status. Решение включено в pre-pilot gate.

Возвраты и спорные случаи

  • возврат создаёт отдельный Refund с собственным idempotency key и reason;
  • payment status не переписывается в «refunded»: агрегируется сумма полных/частичных refund;
  • доступ по подписке меняется только по утверждённой refund policy;
  • отмена практиком: предлагаемый PRD default — 100% возврат + отдельный bonus credit;
  • no-show клиента и запрос в течение 48 часов — policy, а не hardcode; версия policy сохраняется с заказом;
  • приложение не предоставляет ручную операцию mark succeeded или удаление финансовой истории;
  • ежедневная reconciliation сравнивает provider objects/реестры с внутренним ledger и поднимает расхождения в техническом мониторинге; административного API в MVP нет.

Для split правила распределения возврата отличаются, см. документацию YooKassa.

Минимальные таблицы

Product, Price, Order, PaymentAttempt, ProviderEvent, PaymentMethodReference, Subscription, Entitlement, Refund, LedgerEntry, а после выбора модели — PractitionerSettlement/Transfer.

Приёмочные сценарии

  • успешная первичная подписка и сохранённый метод;
  • пользователь вернулся раньше webhook — доступа ещё нет, затем он появляется;
  • одинаковый create request/webhook доставлен 2–10 раз — одна операция и один entitlement;
  • timeout после POST — retry не списывает второй раз;
  • успешное и неуспешное продление, grace, cancel-at-period-end;
  • полное и частичное возмещение;
  • неверная сумма/metadata в webhook блокирует transition и создаёт alert;
  • консультация: комиссия, отмена практиком, no-show и settlement после оказания;
  • reconciliation находит искусственно созданное расхождение.

Перед внешним пилотом нужны магазин YooKassa, подключённые автоплатежи, HTTPS webhook, политика чеков по 54-ФЗ, оферта, согласие на регулярные списания и утверждённая юридическая схема практиков. Полный перечень — в pre-pilot gate.