Компания
56,57
рейтинг
29 августа 2014 в 09:05

Разработка → Разработка своей системы биллинга на Django tutorial

При разработке большинства сервисов возникает потребность во внутреннем биллинге для аккаунтов сервиса. Так и в нашем сервисе возникла такая задача. Готовые пакеты для её решения мы так и не смогли найти, в итоге пришлось разрабатывать систему биллинга с нуля.
В статье хочу рассказать о нашем опыте и подводных камнях, с которыми пришлось столкнуться во время разработки.

Задачи

Задачи, которые нам предстояло решить были типичны для любой системы денежного учета: прием платежей, лог транзакций, оплата и повторяющиеся платежи (подписка).

Транзакции

Основной единицей системы, очевидно, была выбрана транзакция. Для транзакции была написана следующая простая модель:
class UserBalanceChange(models.Model):
    user = models.ForeignKey('User', related_name='balance_changes')
    reason = models.IntegerField(choices=REASON_CHOICES, default=NO_REASON)
    amount = models.DecimalField(_('Amount'), default=0, max_digits=18, decimal_places=6)
    datetime = models.DateTimeField(_('date'), default=timezone.now)
Транзакция состоит из ссылки на пользователя, причины пополнения (или списания), суммы транзакции и времени совершения операции.

Баланс

Баланс пользователя очень легко посчитать при помощи функции annotate из ORM Django (считаем сумму значений одного столбца), но мы столкнулись с тем, что при большом количестве транзакций данная операция сильно нагружает БД. Поэтому было решено денормализовать БД, добавив поле “balance” в модель пользователя. Данное поле обновляется в методе “save” в модели “UserBalanceChange”, а для уверенности в актуальности данных в нем, мы каждую ночь его пересчитываем.
Правильнее, конечно же, хранить информацию о текущем балансе пользователя в кэше (например, в Redis) и инвалидировать при каждом изменении модели.

Прием платежей

Для самых популярных систем приема платежей есть готовые пакеты, поэтому проблем с их установкой и настройкой, как правило, не возникает. Достаточно выполнить несколько простых шагов:
  • Регистрируемся в платежной системе;
  • Получаем API ключи;
  • Устанавливаем соответствующий пакет для Django;
  • Реализовываем форму оплаты;
  • Реализовываем функцию зачисления средств на баланс после оплаты.
Прием платежей реализуется очень гибко, например, для системы Robokassa (используемся приложение django-robokassa) код выглядит так:
from robokassa.signals import result_received
def payment_received(sender, **kwargs):
    order = OrderForPayment.objects.get(id=kwargs['InvId'])
    user = User.objects.get(id=order.user.id)
    order.success=True
    order.save()
    try:
        sum = float(order.payment)
    except Exception, e:
        pass
    else:
        balance_change = UserBalanceChange(user=user, amount=sum, reason=BALANCE_REASONS.ROBOKASSA)
        balance_change.save()
По аналогии можно подключить любую систему оплаты, например PayPal, Яндекс.Касса

Списание средств

Со списаниями чуть сложнее – перед операцией необходимо проверять, каким будет баланс счета после проведения операции, причем “по-честному” – при помощи annotate. Это необходимо делать для того, чтобы не обслуживать пользователя “в кредит”, что особенно важно, когда транзакции выполняются на большие суммы.
payment_sum = 8.32
users = User.objects.filter(id__in=has_clients, balance__gt=payment_sum).select_related('tariff')
Здесь мы написали без annotate, так как в данейшем есть дополнительные проверки.

Повторяющиеся списания

Разобравшись с основами, переходим к самому интересному — повторяющимся списаниям. У нас есть потребность каждый час (назовет это “биллинг-период”) снимать с пользователя определенную сумму в соответствии с его тарифным планом. Для реализации этого механизма мы используем celery – написан task, который выполняется каждый час. Логика в этом моменте получилась сложная, так как необходимо учитывать много факторов:
  • между выполнениями задачи в celery никогда не пройдет ровно час (биллинг-период);
  • пользователь пополняет свой баланс (он становится >0) и получает доступ к услугам между биллинг-периодами, снимать за период было бы нечестно;
  • пользователь может поменять тариф в любое время;
  • celery может по каким-либо причинам перестать выполнять задачи

Мы пытались реализовать данный алгоритм без введения дополнительного поля, но получилось не красиво и не удобно. Поэтому нам пришлось в модель User добавить поле last_hourly_billing, где указываем время последней повторяющиеся операции.
Логика работы:
  • Каждый биллинг-период мы смотрим время last_hourly_billing и списываем сумму согласно тарифному плану, затем обновляем поле last_hourly_billing;
  • При смене тарифного плана мы списываем сумму по прошлому тарифу и обновляем поле last_hourly_billing;
  • При активации услуги мы обновляем поле last_hourly_billing.

def charge_tariff_hour_rate(user):
    now = datetime.now
    second_rate = user.get_second_rate()
    hour_rate = (now - user.last_hourly_billing).total_seconds() * second_rate
    balance_change_reason = UserBalanceChange.objects.create(
                user=user,
                reason=UserBalanceChange.TARIFF_HOUR_CHARGE,
                amount=-hour_rate,
    )
    balance_change_reason.save()
    user.last_hourly_billing = now
    user.save()

Данная система, к сожалению, не является гибкой: если мы добавим еще один тип повторяющихся платежей — придется добавлять новое поле. Скорее всего, в процессе рефакторинга, мы напишем дополнительную модель. Примерно такую:
class UserBalanceSubscriptionLast(models.Model):
    user = models.ForeignKey('User', related_name='balance_changes')
    subscription = models.ForeignKey('Subscription', related_name='subscription_changes')
    datetime = models.DateTimeField(_('date'), default=timezone.now)

Эта модель позволит очень гибко реализовать повторяющиеся платежи.

Dashboard

Мы используем django-admin-tools для удобного dashboard в панели администрирования. Мы решили, что будем следить за следующими двумя важными показателями:
  • Последние 5 оплат и график платежей пользователей за последний месяц;
  • Пользователи, у которых баланс приближается к 0 (из тех, кто уже платил);

Первый показатель для нас является своего рода показателем роста (traction) нашего стартапа, второй — это возвращаемость (retention) пользователей.
О том, как мы реализовали dashboard и следим за метриками, мы расскажем в одной из следующих статей.
Желаю всем удачной настройки биллинг-системы и получения больших платежей!

P.S. Уже в процессе написания статьи нашел готовый пакет django-account-balances, думаю, что можно обратить внимание, если вы делаете систему лояльности.
Автор: @akamoroz
BitCalm
рейтинг 56,57
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

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

  • +1
    Можете пояснить зачем вы каждый раз вычисляете баланс? Вообще говоря это очень плохая идея, так-как чем дальше тем дольше он у вас будет считаться.
    • +2
      Мы его и не вычисляем каждый раз. Он денормализован и вычисляется только при записи транзакции.
      • 0
        Поэтому было решено денормализовать БД, добавив поле “balance” в модель пользователя. Данное поле обновляется в методе “save” в модели “UserBalanceChange”, а для уверенности в актуальности данных в нем, мы каждую ночь его пересчитываем.
        Правильнее, конечно же, хранить информацию о текущем балансе пользователя в кэше (например, в Redis) и инвалидировать при каждом изменении модели.

        Я вот про это. Последнее не надо. Как и пересчитывать баланс. Если у вас есть опасность неверности баланса, то сделайте таблицу истории балансов

        class BalanceChange(models.Model):
            user = models.ForeignKey('User', related_name='balance_changes')
            user_balance_change = models.ForeignKey('UserBalanceChange', related_name='balance_changes')
            balance = models.DecimalField(_('Amount'), default=0, max_digits=18, decimal_places=6) 
            tsfrom = models.DateTimeField(_('date'), default=timezone.now)
            tsto = models.DateTimeField(_('date'), default=timezone.now)
        

        Как-то так. Да и в этом случае можно отказаться вообще от хранения баланса. А в качестве текущего баланса берется запись с tsto со значением null.
        • 0
          Хранить данных примерно столько же, с UserBalanceChange общий баланс можно точно так же в транзакции обновлять (без агрегаций, через UPDATE… SET balance = balance — 100.00). С UserBalanceChange проще вставить событие «в середину», прошедшим числом, ну и поломать что-то сложнее.

          Кроме того, вроде бы BalanceChange сложно заставить работать правильно, если несколько изменений баланса происходят одновременно — мы не можем правильно прочитать текущее значение баланса (его еще может не быть видно) => не можем правильно заполнить поле «balance» (а значит, если отказаться от хранения баланса, то он может стать неверным), и нам надо как-то разрешать конфликты по tsfrom / tsto.

          UserBalanceChange — это, насколько понимаю, примерно реализация martinfowler.com/eaaDev/AccountingNarrative.html.

          Я бы в нем еще поле reason сделал GFK вместо int, чтоб записи в UserBalanceChange связывать с событиями, которые к измемению баланса привели (например, к подписке или к реферралу).
  • 0
    except Exception, e: pass

    Я надеюсь, что все-таки в оригинальном коде вместо «pass» присутствует корректный обработчик ошибок.
    • 0
      Да, конечно, там стоит обработчик и логгер.
      • 0
        А я надеюсь, что вместо «Exception, e» я начну все чаще видеть «Exception as e»
        • 0
          А я надеюсь что в оригинальном коде все таки не Exception, а ValueError
  • 0
    Арифметика с плавающей точкой в отношении денег в какой-то момент принесёт неожиданные сюрпризы.
    habrahabr.ru/post/112953/

    При очередной оплате домашнего интернета у меня на счету показывается что-то типа 449,9999890 вместо 450; и из-за этого однажды не списалась оплата с последующей приостановкой предоставления услуг.
    • +1
      Обратите внимание что тип amount вполне себе decimal
      • 0
        Да, спасибо, упустил
  • +1
    Для реализации этого механизма мы используем celery – написан task, который выполняется каждый час.

    А почему celery, а не cron + commands?
    • 0
      Если это простенький сайтик с 2-3 задачами по расписанию, то тогда особого смыла конечно нет
      Если проект достаточно сложный, то celery удобнее в плане управления и масштабирования.
      При росте выносим все задачи на отдельный сервер(а) и удобно рулим ими.
    • 0
      Спасибо dlancer за ответ. Celery более гибко позволяет работать с задачами, причем напрямую из Django проекта. Отсутсвует необходимость «помнить» о расписании в cron'е.
    • 0
      В дополнение к вышесказанному:

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

      Также, кроме просто выноса всех задач на отдельный сервер, можно задачи распределять на выполнение в разные очереди и назначать на каждую очередь своих обработчиков. Это позволяет не стопорить частые и быстрые задачи теми задачами, которые требуют больших ресурсов и работают долго.
  • +2
    А почему вы считате, что правильнее хранить баланс в кеше? Если вы используете транзакции, то никаких проблем с созданием транзакции и обновлением баланса быть не должно.
  • +3
    А зачем делать так, чтобы получить user?
    order = OrderForPayment.objects.get(id=kwargs['InvId'])
    user = User.objects.get(id=order.user.id)
    

    Можное же просто
    user = order.user
    


    Зачем создавать и сразу же повторно сохранять?
    balance_change_reason = UserBalanceChange.objects.create(
                    user=user,
                    reason=UserBalanceChange.TARIFF_HOUR_CHARGE,
                    amount=-hour_rate,
        )
    balance_change_reason.save()
    
    • 0
      Вообще странно открыть статью про биллинг и не увидеть ничего про транзакции на уровне БД.
      Особенно изворот с балансом в этом плане сомнителен — должен обновляться в той же транзакции, которая добавляет UserBalanceChange.
      Минус тут только один — чем больше записей в системе, тем больше время изменения баланса(пополнение\списание) и тут можно по всякому извращаться.

      p.s. не в тот «уровень» ответил :(

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

Самое читаемое Разработка