Imported from imnadsa/prodamus-subscription-skill (
skills/prodamus-subscription/SKILL.md). Install upstream withnpx skills add imnadsa/prodamus-subscription-skill --skill prodamus-subscription. Copyright stays with the author.
Подписка на сервис: от пустого проекта до первого рубля
Скилл собран по боевой системе с рекуррентными списаниями. Он описывает не «как в теории», а какая конструкция выживает в бою и на чём она ломается.
Прежде чем писать код — выясни у пользователя 4 вещи
Не угадывай, спроси. Каждый ответ меняет схему.
- Кто платит — организация целиком или каждая её единица (филиал/проект/рабочее
пространство) отдельно? От этого зависит, на какой таблице живут поля подписки.
Если единиц несколько — читай раздел «Второй контур» в
references/data-model.md. - Тариф и период — сколько стоит, за какой срок, есть ли пробный период, есть ли бесплатные (подаренные) аккаунты.
- Шлюз — Продамус / ЮKassa / CloudPayments / Stripe. Шлюз влияет ТОЛЬКО на проверку подписи и имена полей в теле вебхука; вся остальная конструкция общая.
- Где живёт бэкенд — Supabase Edge Functions, Node/Express, Next.js route. Референс написан на Deno (Supabase), портируется в лоб.
Если пользователь не технарь и на вопросы ответить не может — предложи дефолт: подписка на организацию, 1 месяц, Продамус, и покажи, что получится.
Шаг 1. Таблицы
Возьми assets/schema.sql, подставь имена таблиц проекта, накати миграцию.
Минимум — три вещи:
- на таблице клиента:
subscription_status,subscription_expires_at,gateway_subscription_id,billing_email,billing_phone; - таблица
payment_events— журнал ВСЕХ событий шлюза с сырым payload; - UNIQUE-индекс на
payment_events.event_key— это замок идемпотентности, без него подписка будет продлеваться кратно числу ретраев.
Статусы держи ровно эти, не выдумывай новых:
| Статус | Смысл | Доступ |
|---|---|---|
free |
выдан вручную (подарок, бартер, свои) | всегда есть |
trial |
пробный период | по дате |
active |
оплачен | по дате expires_at |
past_due |
списание не прошло, шлюз повторяет | по дате, без отсрочки |
expired |
не оплачен | нет |
Детали и переходы — references/data-model.md.
Шаг 2. Вебхук
Возьми assets/webhook.ts — это референс, а не псевдокод; он повторяет боевую
логику. Порядок действий внутри жёсткий, менять его нельзя:
- Распарсить тело. Шлюз шлёт
application/x-www-form-urlencodedс вложенными ключами (subscription[id],products[0][name]) — нужен свой парсер в объект. - Проверить подпись. HMAC-SHA256 по каноническому JSON. Алгоритм Продамуса:
рекурсивная сортировка ключей → все скаляры в строку → компактный JSON →
экранировать
/как\/(PHPjson_encodeтак делает, JS — нет; это причина 90% «подпись не сходится»). Подпись не сошлась → запиши событиеinvalid_signatureи верни 401. - Понять, ЗА ЧТО заплатили — по
plan_idтарифа, НЕ по сумме и НЕ поprofile_id. Если у вас несколько продуктов на одном кабинете, чужой платёж не должен продлевать эту подписку. - Найти клиента — четырьмя способами по очереди:
order_num(мы сами кладём туда slug клиента в ссылке оплаты) → id подписки → email → телефон. Ошибка базы при поиске — это НЕ «клиент не найден»: верни 500, пусть шлюз повторит доставку. Ответишь 200 — платёж потерян навсегда. - Решить новый статус по типу события (см. таблицу переходов в
references/data-model.md). - Применить денежные правила:
- новый срок =
max(текущий expires_at, сегодня + период)— активация не должна СРЕЗАТЬ уже оплаченный вперёд срок; - закрывающее событие (отмена, провал, деактивация) НЕ трогает статус, пока оплаченный период не кончился;
- сумма меньше минимальной (
MIN_SUBSCRIPTION_AMOUNT) — не активируем, логируемrejected_low_amount.
- новый срок =
- Запереть идемпотентность. Вставь строку в
payment_eventsс детерминированнымevent_key(slug|order|status|amount|date) ДО обновления статуса. Ошибка23505(нарушение UNIQUE) = это повтор → ответь 200 и выйди, ничего не меняя. - Обновить клиента. Апдейт не прошёл → удали только что вставленное событие (иначе замок останется, а статус не применится никогда) и верни 500.
- Побочные эффекты — только
await. В serverless-среде воркер умирает сразу после ответа:void fn()и «фоновые» задачи молча не выполняются.
Подробный разбор каждого шага с кодом — references/webhook.md.
Переменные окружения (положи в секреты, не в репозиторий):
PAYMENT_SECRET_KEY=... # секретный ключ кабинета для проверки подписи
MIN_SUBSCRIPTION_AMOUNT=990 # ниже этой суммы не активируем
SUBSCRIPTION_PERIOD_DAYS=31 # период продления
Шаг 3. Ссылка оплаты
В ссылку на форму оплаты всегда подставляй идентификатор клиента:
https://<адрес вашей формы оплаты>/?order_num=<slug клиента>
order_num — единственная надёжная ниточка «этот платёж → этот клиент» на первом
платеже. Без неё вебхук будет угадывать по email и телефону, а они у людей
меняются и дублируются.
Если клиента ещё нет (оплата с лендинга до регистрации) — положи в order_num
uuid строки заказа из своей таблицы signup_orders, а клиента создай уже в
вебхуке после успешной оплаты.
Шаг 4. Гейт доступа
Возьми assets/access.ts. Правила:
- решение о доступе — чистая функция
hasAccess(status, expiresAt, now), чтобы её можно было покрыть тестами и не гадать; - вызывается один раз в корне приложения, до отрисовки контента;
- нет доступа → отдельный экран оплаты с кнопкой, ведущей на ссылку из шага 3, а не пустая страница и не редирект в никуда;
- данные ещё грузятся → не блокируй: блокировка по отсутствию данных выглядит как поломка сервиса и пугает платящих.
Разбор — references/access-gate.md.
Шаг 5. Проверка до первого живого рубля
Пройди чек-лист references/testing.md целиком. Минимум:
node assets/sign-check.mjs— алгоритм подписи сходится на эталонном payload;- отправь тестовый вебхук дважды подряд → срок продлился один раз;
- отправь событие с суммой 1 ₽ → доступ не открылся;
- отправь отмену подписки клиенту с оплаченным месяцем → доступ остался;
- проведи один настоящий платёж на минимальную сумму и сверь
expires_atв базе.
Шаг 6. Что рассказать пользователю
Он не будет читать код. Скажи ему тремя пунктами:
что теперь происходит после оплаты, где смотреть историю платежей
(payment_events / админка) и что делать, если клиент говорит «я заплатил, а доступа нет»
(найти его строку в журнале по телефону или email и посмотреть event_type).
Железные правила (нарушение каждого стоило нам денег)
- Идемпотентность — на UNIQUE-индексе в базе, а не на проверке «не было ли такого события минуту назад». Шлюзы ретраят часами.
- Ошибка базы ≠ «клиента нет». 500 вместо 200 — цена ошибки — потерянный платёж.
- Оплаченный вперёд период не отбирают никакие закрывающие события.
- Активация не понижает срок: всегда
max(старый, новый). - Продукт определяется по
plan_idтарифа, а не по сумме платежа. past_dueне даёт бесплатной отсрочки: доступ живёт ровно доexpires_at.- Сумма ниже минимальной не активирует подписку (защита от поддельного вебхука).
- Событие в журнал пишется ВСЕГДА — даже при неверной подписи и ненайденном клиенте. Иначе разбирать жалобу «я оплатил» будет нечем.
- Сырой payload сохраняется целиком (
raw_payload jsonb). Однажды спасёт. - Никаких фоновых задач без
awaitв serverless.
Каждое правило с историей инцидента — references/pitfalls.md.