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 ready 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 had to face during the development.
Tasks
The tasks that we had to solve were typical of any monetary accounting system: payment acceptance, transaction log, payment, and recurring payments (subscription).
Transactions
The main unit of the system, obviously, was selected 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 reasons for the deposit (or write-off), the amount of the transaction and the time of the transaction.
')
Balance
The user’s balance is very easy to calculate using the
annotate function from the ORM Django (counting the sum of the values ​​of one column), but we are faced with the fact that with a large number of transactions this operation heavily loads the database. 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 make sure that the data in it is current, we recalculate it every night.
It is more correct, of course, to store information about the current balance of the user in the cache (for example, in Redis) and to invalidate each time the model is changed.
Payment acceptance
For the most popular payment acceptance systems there are ready-made packages, therefore, problems with their installation and configuration, as a rule, do not arise. Just follow a few simple steps:
- We register in the payment system;
- Get API keys;
- Install the appropriate package for Django;
- We realize the form of payment;
- We implement the function of crediting funds to the balance after payment.
Payment acceptance is implemented very flexibly, for example, for the Robokassa system (we use the
django-robokassa application ), the code looks like this:
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.Money
Debit
With write-offs, it is a little more difficult - before the operation it is necessary to check what the account balance will be after the operation, and moreover, “honestly” with the help of annotate. This should be done in order not to service the user “on credit”, which is especially important when transactions are performed on 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 there are additional checks in this case.
Duplicate charges
Having dealt with the basics, go to the most interesting - recurring write-offs. We have a need every hour (call it a “billing period”) to withdraw a certain amount from a user in accordance with his tariff plan. To implement this mechanism, we use
celery - written task, which runs every hour. The logic in this moment turned out to be complicated, since many factors must be taken into account:
- there will never be exactly one hour between the accomplishments of a celery task (billing period);
- the user replenishes his balance (it becomes> 0) and gets access to services between billing periods, it would be dishonest to withdraw for the period;
- the user can change the tariff at any time;
- celery may for some reason stop performing tasks
We tried to implement this algorithm without introducing an additional field, but it turned out to be neither beautiful nor 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.
Work Logic:
- Each billing period we look at the time last_hourly_billing and write off the amount according to the tariff plan, then we 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 activating the service, 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, we 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 who have a balance approaching 0 (from those who have already paid);
The first indicator for us is a kind of indicator of growth (traction) of our startup, the second is the reversibility (retention) of users.
How we implemented the dashboard and monitor the metrics will be described in one of the following articles.
I wish you all a successful setting of the billing system and receiving large payments!
PS Already in the process of writing an article I found a ready-made
django-account-balances package, I think you can pay attention if you are making a loyalty system.