Некоторое время назад я запустил Telegram-бота для мониторинга сайтов и обозначил в нём такой тариф:

Один сайт на мониторинге — бесплатно.
Каждый дополнительный — 2 ₽ в день.

Сколько строк кода получилось из двух строк описания я покажу ниже. По пути расскажу, как обретала свою логику биллинговая система. Возможно, это сэкономит вам немножко мыслетоплива в похожем проекте.

Этап 1: просто возможность оплатить

С самого начала бот затевался как коммерческий проект. Тем более, количество пользователей и добавленных ими сайтов создавало определённую нагрузку на хостинг. Рекорд — 56 сайтов у одного пользователя. Поэтому через пару месяцев после первой моей публикации о боте я решил ввести платный тариф.

Начал с того, что просто добавил удобную возможность оплаты, об этом подробно рассказал в другой статье. Разослал пользователям уведомления о том, что скоро мониторинг двух и более сайтов будет стоить денег, и сразу получил первые несколько оплат. А с ними — чувство эйфории.

Итог: оплата работает, бот доказал свою ценность рублём, можно идти дальше.

Этап 2: ограничения для тех, кто не оплатил

Ещё пару дней после анонсированного срока начала действия платного тарифа в боте ничего не поменялось: новых оплат не было, но и я ещё даже не продумал биллинг. Было ощущение, что выгоднее его вообще не делать ради нескольких платных пользователей.

Но решил, всё же, сделать MVP биллинга из трёх функций:

  1. Предупреждение, а затем и приостановка мониторинга всех сайтов кроме одного у пользователей без оплат (если у них больше 1 сайта).

  2. Автоматическое возобновление мониторинга при поступлении оплаты на любую сумму.

  3. Невозможность добавить второй сайт на мониторинг, если нет ни одной оплаты.

То есть, пользователь мог даже истратить всю сумму пополнения и продолжать пользоваться ботом сколько угодно — списаний с баланса всё ещё нет. Но никто об этом, конечно, не знал.

Итог: получил ещё одну оплату.

Этап 3: списания с баланса по тарифу

Какое-то время размышлял о том, как лучше всего организовать эти самые списания по 2 рубля в день за каждый доп. сайт, и выбрал такой вариант:

  1. В начале суток рассчитываю списания за предыдущие сутки.

  2. Сумма списания не может превышать баланс (чтобы не уводить в минус).

  3. Сохраняю сумму списания в базу с уникальным ключом клиент+дата (страховка от дублирования).

  4. Днём проверяю балансы клиентов (поступления минус списания) и количество подписок у них.

  5. Шлю уведомления тем, у кого баланса хватит меньше чем на 9 дней, если в последние два дня о балансе не уведомлял.

  6. Приостанавливаю доп. сайты тем, у кого баланс 0, при условии что я их уже уведомлял о нулевом балансе минимум сутки назад.

  7. При попытке добавить второй сайт на мониторинг проверяю уже не просто наличие поступлений, а баланс, то есть учитываю списания.

  8. В пункте меню про тарифы добавляю баланс.

Получается, что при нулевом балансе мониторинг продолжает работать бесплатно некоторое время, но в данном случае это сознательный выбор, чтобы не тревожить пользователей ночью и, с другой стороны, не сделать биллинг ещё более сложным. Учитывая, что минимальную сумму пополнения определяю я, эксплуатировать эту особенность мне в убыток не получится.

Техническая реализация

Серверная часть бота запускается по расписанию каждые 5 минут и выполняет проверки для сайтов на мониторинге. Дополнительно, в зависимости от времени суток, выполняются действия по расчёту списаний, по уведомлению пользователей и приостановке мониторинга. Поэтому все функции и запросы к базе рассчитаны на то, что могут запускаться несколько раз в час, а потом долгое время не запускаться — кажется, это повышает надёжность.

Вот так выглядит запрос (MySQL) для расчёта списаний:

SELECT
    result.chat_id,
    CAST(result.amount_calculated AS SIGNED) AS amount_calculated, -- Сумма списания по тарифу, без учёта баланса
    CAST(GREATEST(
        0, -- Подстраховка от списания отрицательной суммы из-за отрицательного баланса (по идее, невозможно из условия отбора)
        LEAST(result.amount_calculated, result.balance) -- Не списываем в минус
    ) AS SIGNED) AS amount_fact, -- Сколько фактически списать
    CAST(result.balance AS SIGNED) AS balance_before, -- Баланс до списания
    result.date_for_charge -- Дата, за которую делаем списание
FROM (
    SELECT
        chat_id,
        FLOOR(GREATEST(
            0, 
            (SUM(TIMESTAMPDIFF(MINUTE, start_date, end_date))/1440 - 1) * 200
        )) AS amount_calculated, -- Количество сайто-дней в дату списания, 1 сайто-день бесплатно, каждый дополнительный — 200 копеек в сутки.
        payments_sum - COALESCE(charges_sum, 0) AS balance,
        first_date_for_charge AS date_for_charge
    FROM (
        SELECT 
            s.id,
            s.chat_id,
            s.created,
            s.deleted,
            GREATEST(s.created, p.first_date_for_charge) AS start_date,
            LEAST(
                COALESCE(
                    s.deleted, -- Если подписка удалена, то до момента удаления,
                    p.first_date_for_charge + INTERVAL 1 DAY), -- иначе до даты за датой списания.
                p.first_date_for_charge + INTERVAL 1 DAY) AS end_date, -- но не позже даты за датой списания.
            p.payments_sum,
            p.charges_sum,
            p.first_date_for_charge
        FROM subscriptions s
        JOIN (
            SELECT 
                ip.chat_id,
                ip.payments_sum,
                ch.charges_sum,
                ch.last_charge_date,
                s.first_subscription_date,
                CAST(
                    GREATEST(
                        s.first_subscription_date,
                        COALESCE(
                            ch.last_charge_date, 
                            :tariff_start_date
                        ) + INTERVAL 1 DAY
                    ) AS date
                ) AS first_date_for_charge -- Самая старая дата, за которую нужно сделать списание у пользователя
            FROM (
                SELECT chat_id, SUM(amount) AS payments_sum
                FROM income_payments
                GROUP BY chat_id
            ) ip
            LEFT JOIN (
                SELECT chat_id, SUM(amount_fact) AS charges_sum, MAX(date_for_charge) AS last_charge_date
                FROM charges
                GROUP BY chat_id
            ) ch ON ch.chat_id = ip.chat_id
            JOIN (
                SELECT min(created) AS first_subscription_date, chat_id
                FROM subscriptions
                GROUP BY chat_id
            ) s ON s.chat_id = ip.chat_id
            WHERE 
                ip.payments_sum - COALESCE(ch.charges_sum, 0) > 0 -- Если есть что списать
                AND (
                    ch.last_charge_date IS NULL -- Если не было списаний
                    OR ch.last_charge_date < NOW() - INTERVAL 2 DAY -- Или последнее списание было больше 2 дней назад
                )
        ) p ON p.chat_id = s.chat_id
        WHERE -- Ищем подписки, действовашие в дату списания
            (s.deleted IS NULL OR s.deleted > p.first_date_for_charge)
            AND 
            (s.created < p.first_date_for_charge + INTERVAL 1 DAY)
    ) total
    GROUP BY 
        chat_id,
        payments_sum,
        charges_sum,
        first_date_for_charge
) result
;

Пояснения:

  1. chat_id в данном случае можно считать идентификатором клиента.

  2. subscriptions — таблица с подписками, 1 подписка — 1 сайт на мониторинге.

  3. charges — таблица со списаниями.

  4. income_payments — таблица с пополнениями баланса.

  5. :tariff_start_date — единственный передаваемый параметр, начало действия платного тарифа.

  6. Все суммы считаются в копейках как целые числа.

  7. Приведение типов ( CAST ) добавлено из-за того, что без него php после pdo эти числа получает в виде строк.

  8. Для каждого подписчика определяется самая ранняя дата, за которую нужно рассчитать списание — это необязательно вчерашний день. Сделано так для расчёта списаний за период с объявления платного тарифа до момента его имплементации в коде. Запрос выполняется несколько раз за сутки, постепенно нагоняя упущенные дни. В штатном режиме после первого же выполнения списаний в течение этих суток запрос уже не будет ничего возвращать.

  9. Для каждого пользователя рассчитываем, сколько сайто-дней приходилось на расчётную дату, 1 сайт вычитаем, а остаток умножаем на суточный тариф. Получается, если половину суток было 2 сайта, а вторую половину — ни одного, то списания не будет.

  10. Клиенты без оплат в выборку не попадают — с них списать всё равно нечего.

  11. Если по итогу получилось нулевое списание, оно всё равно сохраняется, чтобы потом этот клиент с этой датой уже не попадал в выборку.

  12. Запрос выглядит, возможно, громоздко, но быстро работает и выдаёт в готовом виде все необходимые данные для списаний. На стороне php остаётся только взять нужные поля и поместить в таблицу списаний, попутно залогировав.

Итог

Теперь биллинг можно считать законченным — есть и пополнения, и мотивация для пополнений, и списания согласно тарифу. Некоторое чувство велосипедостроения присутствует, но чувство творческого удовлетворения его перевешивает. Возможно, подобная задача могла бы быть решена как-то более эффективно — пишите, будет интересно обсудить.

Да и самого бота, конечно, тоже пробуйте, я не только над биллингом в нём потрудился!

Комментарии (9)


  1. ednersky
    25.01.2025 08:24

    а можете привести получившийся план этого запроса?


    1. Pontific Автор
      25.01.2025 08:24


  1. j-b
    25.01.2025 08:24

    Вот бы еще послушать как вы замеряли время ответа, и не попадает ли туда время работы самого скрипта, а так-же как часто вы эти запросы делаете? Ведь время реакции, скажем на "Упавший" магазин может стоить очень дорого...


    1. Pontific Автор
      25.01.2025 08:24

      Замеряю чисто время отработки curl, вот так:

          $start_time = microtime(true);
          $response = curl_exec($ch);
          $end_time = microtime(true);
          $result['response_time_ms'] = intval(round(($end_time - $start_time) * 1000));

      Далее, когда сайт уже на мониторинге, беру два последних результата и сравниваю их с последним сообщённым пользователю временем ответа.

      1. Если минимальное время из двух последних в два раза превышает последнее сообщённое время — сообщаю это новое время.

      2. Если максимальное из двух последних в два раза меньше последнего сообщённого — тоже сообщаю.

      3. В обоих случаях разница должна превышать минимальный порог в секунду.

      Запросы делаю сейчас раз в 5 минут. Если 2 запроса подряд без ответа — шлю уведомление. К сожалению, многие сайты с завидной регулярностью "мерцают", пришлось добавить такой фильтр. Но некоторые и два раза подряд могут быть недоступны, а на третий раз отвечают — не знаю, с чем связано.


      1. j-b
        25.01.2025 08:24

        Т.е. аналитики времени запросов нет как я понимаю,только последние значения...

        Много желающий платить, окупаются затраты на хостинг?


        1. Pontific Автор
          25.01.2025 08:24

          Есть ещё регулярный отчёт за сутки/неделю, в нём можно посмотреть статистику времени ответов, если Вы об этом:


        1. Pontific Автор
          25.01.2025 08:24

          Много желающий платить, окупаются затраты на хостинг?

          Не больше десятка пока что. Хостинг примерно окупается, да.


  1. CodeByZen
    25.01.2025 08:24

    У меня глаза начали кровоточить от sql запроса. :) По-моему это очень сложно. Я бы разбил на хранимые функции как минимум для удобства чтения.

    А по проверке самих сайтов: если у пользователя отвалился бекенд то сервер вернёт 200, а при запросе к api пользователь ничего не увидит. Т.е. сайт лег. Но вы этого не увидите. И будете говорить, что с сайтом все ок.


    1. Pontific Автор
      25.01.2025 08:24

      Я бы разбил на хранимые функции как минимум для удобства чтения.

      Из преимуществ текущего запроса — всё в одном месте, можно читать не переключаясь.

      Но идея с хранимыми функциями мне тоже нравится. Если будет настроение и приведёте пример, какие можно было бы выделить функции — будет интересно.

      если у пользователя отвалился бекенд то сервер вернёт 200, а при запросе к api пользователь ничего не увидит

      Тут зависит от сайта. Если из-за неработающего API поменяется заголовок страницы, то бот сообщит. Можно, например, создать отдельную проверочную страницу для этих целей.

      Можно отдельно добавить сам API в мониторинг (правда, никакой авторизации я не предусматривал).