При разработке большинства сервисов возникает потребность во внутреннем биллинге для аккаунтов сервиса. Так и в нашем сервисе возникла такая задача. Готовые пакеты для её решения мы так и не смогли найти, в итоге пришлось разрабатывать систему биллинга с нуля.
В статье хочу рассказать о нашем опыте и подводных камнях, с которыми пришлось столкнуться во время разработки.
Задачи
Задачи, которые нам предстояло решить были типичны для любой системы денежного учета: прием платежей, лог транзакций, оплата и повторяющиеся платежи (подписка).
Транзакции
Основной единицей системы, очевидно, была выбрана транзакция. Для транзакции была написана следующая простая модель:
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