Files
veilix.app/docs/04-billing.md
T
2026-10-21 03:30:38 +02:00

19 KiB
Raw Blame History

04. Биллинг

1. Принципы

  1. Всё через счёт. Любой успешный платёж зачисляется на баланс (движение topup). Любой заказ оплачивается списанием с баланса (order_charge). Поэтому оплата картой, оплата с баланса, доплата разницы и пополнение заранее работают одним механизмом.
  2. Баланс — это сумма движений по счёту. Поле customers.balance — кэш, он обновляется в той же транзакции базы, что и запись движения. Раз в сутки задача reconcile сверяет кэш с суммой.
  3. Движения неизменяемы. Ошибка исправляется встречной записью.
  4. Одна точка проведения платежа — core/billing/processPayment(paymentId). Её вызывают вебхук провайдера, ручной платёж из админки и тесты. Повторный вызов ничего не делает.
  5. Суммы — в минимальных единицах валюты аккаунта.

2. Валюта аккаунта

  1. При регистрации валюта аккаунта = валюта, выбранная в переключателе (или валюта по умолчанию домена).
  2. Сменить валюту в кабинете можно, пока баланс нулевой и нет ключей и неоплаченных заказов. Дальше — через поддержку: администратор пересчитывает вручную корректировками.
  3. Цены в кабинете всегда в валюте аккаунта. Тариф без цены в этой валюте недоступен.

3. Покупка ключа (оформление заказа на my.*)

  1. Ссылка «Купить» с сайта ведёт на my.<домен>/{язык}/order?tariff=…&location=…&currency=….
  2. Шаг «Почта»: email и код (05-auth.md). Вошедший пользователь шаг пропускает.
  3. Сервер проверяет: тариф активен, есть цена в валюте аккаунта, локация доступна (dedicated: available или on_demand).
  4. Создаётся заказ pending: new_key, сумма — firstPeriodAmount, действует 24 часа. Если у пользователя уже есть такой же неоплаченный заказ, используется он.
  5. Шаг «Оплата»: сводка заказа, согласие с условиями (обязательная галочка со ссылками на юридические страницы) и способ оплаты. a. Баланса хватает — кнопка «Оплатить с баланса», заказ оплачивается сразу. b. Баланса не хватает — список платёжных систем: активные, в валюте аккаунта, с назначением order, видимые пользователю. Создаётся платёж pending на сумму «стоимость минус баланс» (показываем: «С баланса спишется X, к оплате Y»), дальше — страница или виджет провайдера (этап 4).
  6. На этапе 1 видимых платёжных систем нет. Заказ создаётся, пользователь видит «Заказ создан, ожидает оплаты». Оплату проводит администратор ручным платежом, либо пользователь оплачивает с баланса, который пополнил администратор. Так проверяется весь сценарий без эквайринга.
  7. После оплаты: заказ paid, ключ создаётся в статусе provisioning, письма order_paid пользователю и admin_provisioning_needed на почту поддержки.

4. Пополнение баланса

  1. Платёж с назначением topup. Суммы — пресеты сайта для валюты (sites.topupPresets) или своя сумма в пределах лимитов платёжной системы.
  2. После зачисления — письмо topup_succeeded.
  3. После любого пополнения система сама оплачивает с баланса неоплаченные заказы на продление этого пользователя, начиная со старого. Неоплаченные заказы на новые ключи сама не оплачивает.
  4. Криптовалюта (этап 4): счёт в монете, курс фиксируется на 15 минут, зачисление в валюте аккаунта после подтверждения сети.

5. Проведение платежа: processPayment

Выполняется, когда платёж переходит в succeeded, в одной транзакции:

  1. Заблокировать строку платежа. Если processedAt заполнен — выйти.
  2. Заблокировать строку пользователя.
  3. Движение +amount (topup).
  4. Если назначение order и заказ pending: баланса хватает — движение −order.amount (order_charge), заказ paid. Не хватает — заказ остаётся pending, деньги остаются на балансе.
  5. Заказ оплачен — исполнить: new_key создаёт ключ provisioning с копией цены продления, периода и лимита устройств; renewal продлевает ключ (раздел 6).
  6. Проставить processedAt.
  7. Проверить реферальное вознаграждение (раздел 11).
  8. Поставить в очередь письма, записать журнал действий.

Если заказ к моменту оплаты отменён или истёк, деньги остаются на балансе, пользователь получает письмо о зачислении.

Оплата заказа с баланса без внешнего платежа — те же шаги 2, 4, 5, 7, 8.

6. Продление

6.1 Автоматическое (задача renewals, каждые 15 минут)

Берём ключи в статусе active или grace с autoRenew = true, у которых expiresAt ≤ сейчас + renewalLeadHours и нет оплаченного заказа на продление с renewalFor = expiresAt. Для каждого, с блокировкой ключа:

  1. Найти или создать заказ renewal: renewalFor = expiresAt, сумма — renewalAmount ключа.
  2. Баланса хватает — списание, заказ paid, expiresAt = renewalFor + period, статус active, письмо renewal_succeeded.
  3. Не хватает, но есть сохранённая карта с автосписанием (Stripe, этап 4) — платёж на разницу без участия пользователя. Неудача — повтор: за 24 часа до окончания, в момент окончания, через 24 и 48 часов (внутри льготного периода). После первой неудачи — письмо renewal_failed.
  4. Нет ни денег, ни карты — заказ ждёт оплаты. Его оплатит пополнение (раздел 4.3) или кнопка «Продлить сейчас».

6.2 «Продлить сейчас»

Доступна для ключей active и grace. Создаёт (или берёт существующий) заказ renewal на renewalFor = expiresAt и оплачивает его с баланса или через платёжную систему. Ключ продлевается на период от текущего expiresAt. Можно продлить заранее несколько раз подряд.

6.3 Автопродление и карта

  1. Галочка автопродления — у каждого ключа.
  2. «Отвязать карту» — в аккаунте, раздел «Счёт и платежи». После отвязки автопродление списывает только баланс.

7. Льготный период и окончание (задача expiry, каждые 15 минут)

  1. active, срок истёк, продление не оплачено → grace, graceUntil = expiresAt + graceDays, письмо grace_started. Ключ работает.
  2. Продление оплачено в льготный период → active, новый срок считается от старого expiresAt: бесплатных дней нет.
  3. grace, graceUntil наступил → expired, неоплаченный заказ на продление → expired, письмо key_expired. На этапе 1 ключ попадает в список «К отключению» в админке; отключение автоматическое — этап 5.
  4. Правило одинаковое для ключей с автопродлением и без. Без автопродления пользователь сам решает, продлевать ли в льготный период.
  5. Истёкший ключ не продлевается: покупается новый. Администратор может вернуть ключ вручную.

8. Выдача ключа

  1. Админка, ключ в статусе provisioning: администратор добавляет подключения (WG/AWG по устройствам, ссылку Happ, RU-адрес), IP-адреса и нажимает «Выдать ключ».
  2. Нужно хотя бы одно активное подключение. Затем: active, activatedAt = сейчас, expiresAt = сейчас + период, письмо key_ready.
  3. Список «Очередь выдачи» в админке: ключи provisioning по времени создания; локации on_demand помечены сроком «24 часа».

9. Ручные операции в админке

Операция Что происходит
Ручной платёж Платёж через платёжную систему manual: пользователь, назначение, заказ (необязательно), сумма в валюте аккаунта, статус, комментарий. Когда статус становится succeeded, срабатывает processPayment — как у настоящего платежа. Можно завести pending (ждём перевод) и позже перевести в succeeded. Галочки «провести как реальную» нет: проводится всегда
Корректировка баланса Движение adjustment ±сумма, комментарий обязателен. Цепочка действий не запускается
Возврат на баланс На заказе: сумма (не больше оплаченной минус возвращённое), галочка «Отменить ключ». Движение refund, заказ refunded или partially_refunded, ключ cancelled, если отмечено. Письмо refund_issued
Выдать ключ provisioning → active (раздел 8)
Продлить вручную Продлить ключ на N дней без оплаты (компенсация)
Заблокировать Пользователь blocked: войти нельзя. Ключ suspended

Все операции пишутся в журнал действий.

10. Возвраты

  1. Политика: если сервер не заработал по нашей вине — полностью в течение первых 7 дней; если пользователь передумал — остаток неиспользованного периода. Сумму считает администратор, система подсказывает остаток пропорционально дням.
  2. Этап 1 — только возврат на баланс.
  3. Этап 4 — возврат на исходный способ оплаты, если провайдер умеет. Запись refund (+) на баланс по заказу, затем payout (−) — деньги уходят на карту. Итог: баланс не меняется, деньги у пользователя на карте.

11. Реферальное вознаграждение

  1. Привязка: при регистрации, если есть cookie vx_ref (ставит my.<домен>/r/<код>, живёт 60 дней) или параметр /start <код> в боте. Код того же сайта, не свой. Создаётся приглашение pending.
  2. Условие: оплачен первый заказ new_key приглашённого. Пополнение баланса условием не считается.
  3. Проверка: совпадает хэш IP регистрации приглашённого и пригласившего → review, решает администратор. Иначе — начисление.
  4. Начисление: движение referral_bonus обоим, в валюте каждого, сумма из sites.referralBonus. Нет суммы для валюты — бонус не начисляется, в журнале предупреждение. Приглашение rewarded, письма referral_reward.

12. Уведомления

Когда Шаблон Условие
Заказ оплачен order_paid всегда
Ключ ждёт подготовки (24 часа) key_provisioning локация on_demand
Ключ выдан key_ready
За reminderDays до окончания renewal_reminder автопродление включено, но баланса и карты не хватит
За reminderDays до окончания expiry_reminder автопродление выключено
Продление прошло renewal_succeeded
Карта не прошла renewal_failed
Начался льготный период grace_started
Ключ истёк key_expired
Баланс пополнен topup_succeeded
Реферальный бонус referral_reward
Возврат refund_issued
Администраторам admin_new_order, admin_provisioning_needed, admin_new_lead на почту поддержки сайта

С этапа 3 те же уведомления дублируются в основного бота, если Telegram привязан.

13. Статусы

13.1 Ключ

stateDiagram-v2
  [*] --> provisioning: заказ оплачен
  provisioning --> active: выдан
  active --> active: продление оплачено
  active --> grace: срок истёк, оплаты нет
  grace --> active: продление оплачено
  grace --> expired: льготный период закончился
  provisioning --> cancelled: возврат
  active --> cancelled: возврат с отменой
  active --> suspended: блокировка
  suspended --> active: разблокировка

13.2 Заказ

stateDiagram-v2
  [*] --> pending
  pending --> paid
  pending --> cancelled: отменён
  pending --> expired: истёк срок заказа или ключ истёк
  paid --> partially_refunded
  paid --> refunded
  partially_refunded --> refunded

13.3 Платёж

stateDiagram-v2
  [*] --> pending
  pending --> succeeded
  pending --> failed
  pending --> cancelled
  succeeded --> partially_refunded
  succeeded --> refunded

14. Конкурентность и идемпотентность

  1. Все денежные операции — в транзакции базы. Баланс меняется только под блокировкой строки пользователя (SELECT … FOR UPDATE).
  2. Уникальные ограничения защищают от двойного проведения: движения (см. 03-data-model.md, 6.3), платежи (adapter + providerPaymentId), заказы (key + renewalFor).
  3. Вебхуки: проверка подписи, сохранение id события, идемпотентная обработка, быстрый ответ 200.
  4. Задачи берут ключи с FOR UPDATE SKIP LOCKED, чтобы параллельные запуски не продлили ключ дважды.

15. Как показываем в кабинете

  1. История платежей — одна строка на событие для человека. Платёж картой за заказ (зачисление и списание) показывается одной строкой: «Первый месяц · Нидерланды · Амстердам · Карта ···· 4242 · −4 900 ₽». Списание с баланса — «С баланса». Пополнение, реферальный бонус, возврат, корректировка — отдельными строками.
  2. «Следующее списание» — ближайшее продление среди ключей с автопродлением: сумма, дата, ключ; ниже — следующие.
  3. Бейдж «Истекает» — до окончания не больше expiringBadgeDays и (автопродление выключено или не хватит ни баланса, ни карты).
  4. На странице ключа: «Продлится автоматически через N дней», если денег или карты хватает, иначе «Осталось N дней» (предупреждающий цвет).