Developing your own billing system on Django
- Tutorial
When developing most services, there is a need for internal billing for service accounts. So in our service there was such a problem. We could not find any ready-made packages for its solution, as a result, we had to develop a billing system from scratch.
In the article I want to talk about our experience and the pitfalls that I had to face during development.
It’s more correct, of course, to store information about the user's current balance in the cache (for example, in Redis) and invalidate each time the model changes.
We tried to implement this algorithm without introducing an additional field, but it turned out not to be beautiful and not convenient. Therefore, we had to add the last_hourly_billing field to the User model, where we indicate the time of the last repeated operation.
Logic of work:
This system, unfortunately, is not flexible: if we add another type of recurring payments, you will have to add a new field. Most likely, in the process of refactoring, we will write an additional model. Something like this:
This model will allow very flexible implementation of recurring payments.
The first indicator for us is a kind of indicator of the growth (traction) of our startup, the second is the retention of users.
We will talk about how we implemented dashboard and follow metrics in one of the following articles.
I wish everyone a successful setting up the billing system and receive large payments!
PS Already in the process of writing the article I found the ready-made package django-account-balances , I think that you can pay attention if you are creating a loyalty system.
In the article I want to talk about our experience and the pitfalls that I had to face during development.
Tasks
The tasks that we had to solve were typical for any system of cash accounting: receiving payments, transaction logs, payment and recurring payments (subscription).Transactions
The main unit of the system, obviously, was chosen transaction. The following simple model was written for the transaction: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)
A transaction consists of a link to the user, the reason for the replenishment (or write-off), the amount of the transaction and the time of the operation.Balance
It is very easy to calculate the user's balance using the annotate function from ORM Django (we consider the sum of the values of one column), but we are faced with the fact that with a large number of transactions this operation loads the database heavily. Therefore, it was decided to denormalize the database by adding the “balance” field to the user model. This field is updated in the “save” method in the “UserBalanceChange” model, and to be sure of the relevance of the data in it, we recalculate it every night.It’s more correct, of course, to store information about the user's current balance in the cache (for example, in Redis) and invalidate each time the model changes.
Accept payments
There are ready-made packages for the most popular payment acceptance systems, therefore, as a rule, there are no problems with their installation and configuration. It is enough to follow a few simple steps:- We are registered in the payment system;
- We receive API keys;
- Install the appropriate package for Django;
- We realize the form of payment;
- We realize the function of crediting funds to the balance after payment.
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()
By analogy, you can connect any payment system, for example PayPal, Yandex.CashDebit
With write-offs it’s a bit more complicated - before the operation it is necessary to check what the account balance will be after the operation, and “honestly” - using annotate. This is necessary in order not to serve the user “on credit”, which is especially important when transactions are carried out in large amounts.payment_sum = 8.32
users = User.objects.filter(id__in=has_clients, balance__gt=payment_sum).select_related('tariff')
Here we wrote without annotate, since in the future there are additional checks. Duplicate charges
Having dealt with the basics, we move on to the most interesting - repeated charges. We have a need to withdraw a certain amount from the user every hour (call it a “billing period”) in accordance with his tariff plan. To implement this mechanism, we use celery - a task is written that runs every hour. The logic at this point turned out to be complicated, since many factors must be taken into account:- exactly one hour will pass between task executions in celery (billing period);
- the user replenishes his balance (he becomes> 0) and gets access to services between billing periods, it would be dishonest to take off for a period;
- the user can change the tariff at any time;
- celery may stop performing tasks for some reason
We tried to implement this algorithm without introducing an additional field, but it turned out not to be beautiful and not convenient. Therefore, we had to add the last_hourly_billing field to the User model, where we indicate the time of the last repeated operation.
Logic of work:
- Each billing period, we look at the time last_hourly_billing and write off the amount according to the tariff plan, then update the field last_hourly_billing;
- When changing the tariff plan, we write off the amount at the previous tariff and update the last_hourly_billing field;
- When the service is activated, we update the last_hourly_billing field.
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()
This system, unfortunately, is not flexible: if we add another type of recurring payments, you will have to add a new field. Most likely, in the process of refactoring, we will write an additional model. Something like this:
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)
This model will allow very flexible implementation of recurring payments.
Dashboard
We use django-admin-tools for a convenient dashboard in the admin panel. We decided that we would monitor the following two important indicators:- Last 5 payments and user payment schedule for the last month;
- Users whose balance is close to 0 (of those who have already paid);
The first indicator for us is a kind of indicator of the growth (traction) of our startup, the second is the retention of users.
We will talk about how we implemented dashboard and follow metrics in one of the following articles.
I wish everyone a successful setting up the billing system and receive large payments!
PS Already in the process of writing the article I found the ready-made package django-account-balances , I think that you can pay attention if you are creating a loyalty system.