From 2b21ddb9504d2b8cd4c2e55d310254c1c32c500d Mon Sep 17 00:00:00 2001 From: Setyo Nugroho Date: Fri, 10 Feb 2023 07:05:06 +0000 Subject: [PATCH] feat: Balance Feature --- .DS_Store | Bin 6148 -> 6148 bytes api/serializers.py | 25 ++++++- api/urls.py | 1 + api/views.py | 65 +++++++++++++++++- core/admin.py | 15 +++- core/component/base/invoice_handler.py | 2 +- core/management/commands/process_invoice.py | 30 ++++++-- .../0013_balance_balancetransaction.py | 46 +++++++++++++ core/models.py | 59 +++++++++++++++- core/utils/dynamic_setting.py | 4 ++ 10 files changed, 236 insertions(+), 11 deletions(-) create mode 100644 core/migrations/0013_balance_balancetransaction.py diff --git a/.DS_Store b/.DS_Store index 9b73d04ec315ee5c6a8b484791daaba8dccea703..3f41f83365ed31f95ef28268a662eef438fea57d 100644 GIT binary patch delta 67 zcmZoMXfc=|#>B)qF;Q%yo+2a9#(>?7j69opSdtkxi*g9DOe|>I%+A5j0aUVCkmEb^ VWPTAx4hA4#WME*~93irX82~I;4($K{ delta 460 zcmZoMXfc=|#>B!kF;Q%yo+6{r#(>?7iyN4k7=r1Ii| zq@4UD1_p)`Nd-BX#U%y?*BP0ZSy%}Xf;8x#zY z0oxQWAYNUqYoMc`Yf!7BP;F>p0%Tj5o7UEHa)_%M+IlABR#sKl)Yi=aIu-~R86h+S zKa_@1Gl2{gPv)c>1}Ep|7JwDhGpPMQSCE_U;sOl?4t|@jl?%HMJ7TIyA*iMx1IZ$W z29SLy4k-&R%Hzn%&r1hNFm6l~X57rq!OsDVtIdVX-d&&4Ko1! C`*eT+ diff --git a/api/serializers.py b/api/serializers.py index 8cf70c5..a117e2e 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -3,7 +3,7 @@ from djmoney.settings import DECIMAL_PLACES from rest_framework import serializers from api import custom_validator -from core.models import Invoice, BillingProject, Notification +from core.models import Invoice, BillingProject, Notification, Balance, BalanceTransaction from core.component import component @@ -75,3 +75,26 @@ class NotificationSerializer(serializers.ModelSerializer): model = Notification fields = ['id', 'project', 'title', 'short_description', 'content', 'sent_status', 'is_read', 'created_at', 'recipient'] + + +class BalanceSerializer(serializers.ModelSerializer): + project = BillingProjectSerializer() + amount = MoneyField(max_digits=10, decimal_places=DECIMAL_PLACES) + amount_currency = serializers.CharField(source="amount.currency") + + class Meta: + model = Balance + fields = ['id', 'project', 'amount', 'amount_currency'] + + +class BalanceTransactionSerializer(serializers.ModelSerializer): + amount = MoneyField(max_digits=10, decimal_places=DECIMAL_PLACES) + amount_currency = serializers.CharField(source="amount.currency") + action = serializers.CharField(required=False) + description = serializers.CharField() + + class Meta: + model = BalanceTransaction + fields = ['id', 'amount', 'amount_currency', 'action', 'description', 'created_at'] + + diff --git a/api/urls.py b/api/urls.py index ebf678d..c5a2e06 100644 --- a/api/urls.py +++ b/api/urls.py @@ -13,6 +13,7 @@ router.register(r'invoice', views.InvoiceViewSet, basename='invoice') router.register(r'admin_overview', views.AdminOverviewViewSet, basename='admin_overview') router.register(r'project_overview', views.ProjectOverviewViewSet, basename='project_overview') router.register(r'notification', views.NotificationViewSet, basename='notification') +router.register(r'balance', views.BalanceViewSet, basename='balance') urlpatterns = [ path('', include(router.urls)), diff --git a/api/views.py b/api/views.py index aef4036..1deb5bd 100644 --- a/api/views.py +++ b/api/views.py @@ -5,15 +5,17 @@ import pytz from django.db import transaction from django.utils import timezone from djmoney.money import Money -from rest_framework import viewsets, serializers +from rest_framework import viewsets, serializers, status from rest_framework.decorators import action +from rest_framework.generics import get_object_or_404 from rest_framework.response import Response -from api.serializers import InvoiceSerializer, SimpleInvoiceSerializer, BillingProjectSerializer, NotificationSerializer +from api.serializers import InvoiceSerializer, SimpleInvoiceSerializer, BillingProjectSerializer, \ + NotificationSerializer, BalanceSerializer, BalanceTransactionSerializer from core.component import component, labels from core.component.component import INVOICE_COMPONENT_MODEL from core.exception import PriceNotFound -from core.models import Invoice, BillingProject, Notification +from core.models import Invoice, BillingProject, Notification, Balance, InvoiceInstance from core.notification import send_notification_from_template from core.utils.dynamic_setting import get_dynamic_settings, get_dynamic_setting, set_dynamic_setting, BILLING_ENABLED, \ INVOICE_TAX, COMPANY_NAME, COMPANY_ADDRESS @@ -175,7 +177,13 @@ class InvoiceViewSet(viewsets.ModelViewSet): @action(detail=True) def finish(self, request, pk): invoice = Invoice.objects.filter(id=pk).first() + balance = Balance.get_balance_for_project(invoice.project) + skip_balance = self.request.query_params.get('skip_balance', '') + if invoice.state == Invoice.InvoiceState.UNPAID: + if not skip_balance: + deduct_transaction = f"Balance deduction for invoice #{invoice.id}" + balance.top_down(invoice.total, deduct_transaction) invoice.finish() send_notification_from_template( @@ -196,7 +204,13 @@ class InvoiceViewSet(viewsets.ModelViewSet): @action(detail=True) def rollback_to_unpaid(self, request, pk): invoice = Invoice.objects.filter(id=pk).first() + balance = Balance.get_balance_for_project(invoice.project) + skip_balance = self.request.query_params.get('skip_balance', '') if invoice.state == Invoice.InvoiceState.FINISHED: + if not skip_balance: + # Refund + refund_transaction = f"Refund for invoice #{invoice.id}" + balance.top_up(invoice.total, refund_transaction) invoice.rollback_to_unpaid() send_notification_from_template( @@ -419,3 +433,48 @@ class NotificationViewSet(viewsets.ModelViewSet): serializer = NotificationSerializer(notification) return Response(serializer.data) + + +# region Balance +class BalanceViewSet(viewsets.ViewSet): + def list(self, request): + balances = [] + for project in BillingProject.objects.all(): + balances.append(Balance.get_balance_for_project(project=project)) + return Response(BalanceSerializer(balances, many=True).data) + + def retrieve(self, request, pk): + balance = Balance.objects.filter(pk).first() + return Response(BalanceSerializer(balance).data) + + @action(detail=True, methods=['GET']) + def retrieve_by_project(self, request, pk): + project = get_object_or_404(BillingProject, tenant_id=pk) + balance = Balance.get_balance_for_project(project=project) + return Response(BalanceSerializer(balance).data) + + @action(detail=True, methods=['GET']) + def transaction_by_project(self, request, pk): + project = get_object_or_404(BillingProject, tenant_id=pk) + transactions = Balance.get_balance_for_project(project=project).balancetransaction_set + return Response(BalanceTransactionSerializer(transactions, many=True).data) + + @action(detail=True, methods=['POST']) + def top_up_by_project(self, request, pk): + project = get_object_or_404(BillingProject, tenant_id=pk) + balance = Balance.get_balance_for_project(project=project) + balance_transaction = balance.top_up( + Money(amount=request.data['amount'], currency=request.data['amount_currency']), + description=request.data['description']) + return Response(BalanceTransactionSerializer(balance_transaction).data) + + @action(detail=True, methods=['POST']) + def top_down_by_project(self, request, pk): + project = get_object_or_404(BillingProject, tenant_id=pk) + balance = Balance.get_balance_for_project(project=project) + balance_transaction = balance.top_down( + Money(amount=request.data['amount'], currency=request.data['amount_currency']), + description=request.data['description']) + return Response(BalanceTransactionSerializer(balance_transaction).data) + +# end region diff --git a/core/admin.py b/core/admin.py index 7f2b5fb..b3f66d7 100644 --- a/core/admin.py +++ b/core/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin from core.models import FlavorPrice, VolumePrice, FloatingIpsPrice, BillingProject, Invoice, InvoiceVolume, \ InvoiceFloatingIp, InvoiceInstance, DynamicSetting, InvoiceImage, ImagePrice, SnapshotPrice, RouterPrice, \ - InvoiceSnapshot, InvoiceRouter, Notification + InvoiceSnapshot, InvoiceRouter, Notification, Balance, BalanceTransaction @admin.register(DynamicSetting) @@ -83,3 +83,16 @@ class InvoiceImageAdmin(admin.ModelAdmin): @admin.register(Notification) class InvoiceImageAdmin(admin.ModelAdmin): list_display = ('project', 'title', 'short_description', 'sent_status') + + +@admin.register(Balance) +class BalanceAdmin(admin.ModelAdmin): + list_display = ('project', 'amount') + +@admin.register(BalanceTransaction) +class BalanceTransactionAdmin(admin.ModelAdmin): + list_display = ('get_project', 'amount', 'action', 'description', 'created_at') + + @admin.display(ordering='balance__project', description='Project') + def get_project(self, obj): + return obj.balance.project \ No newline at end of file diff --git a/core/component/base/invoice_handler.py b/core/component/base/invoice_handler.py index 79c934f..2b544b1 100644 --- a/core/component/base/invoice_handler.py +++ b/core/component/base/invoice_handler.py @@ -92,7 +92,7 @@ class InvoiceHandler(metaclass=abc.ABCMeta): project=None, title=f'{settings.EMAIL_TAG} [Error] Price not found when rolling invoice', short_description=f'Please check your Price configuration. Error on ID {getattr(instance, self.KEY_FIELD)}', - content=f'Please check your Price configuration fro {e.identifier}. Error on ID {getattr(instance, self.KEY_FIELD)} for ' + content=f'Please check your Price configuration for {e.identifier}. Error on ID {getattr(instance, self.KEY_FIELD)} for ' f'invoice {getattr(instance, "invoice").id}', ) diff --git a/core/management/commands/process_invoice.py b/core/management/commands/process_invoice.py index 326490d..ec2b417 100644 --- a/core/management/commands/process_invoice.py +++ b/core/management/commands/process_invoice.py @@ -1,15 +1,15 @@ import logging import traceback -from typing import Mapping, Dict, Iterable +from typing import Dict, Iterable from django.core.management import BaseCommand from django.utils import timezone -from core.models import Invoice, InvoiceComponentMixin from core.component import component, labels +from core.models import Invoice, InvoiceComponentMixin, Balance from core.notification import send_notification_from_template, send_notification -from core.utils.dynamic_setting import get_dynamic_setting, BILLING_ENABLED, INVOICE_TAX, COMPANY_LOGO, COMPANY_NAME, \ - COMPANY_ADDRESS +from core.utils.dynamic_setting import get_dynamic_setting, BILLING_ENABLED, INVOICE_TAX, COMPANY_NAME, \ + COMPANY_ADDRESS, INVOICE_AUTO_DEDUCT_BALANCE from yuyu import settings LOG = logging.getLogger("yuyu_new_invoice") @@ -68,6 +68,28 @@ class Command(BaseCommand): "invoice": new_invoice }, fallback_price=True) + # Auto Finish Deduct Balance + if get_dynamic_setting(INVOICE_AUTO_DEDUCT_BALANCE): + balance = Balance.get_balance_for_project(active_invoice.project) + + deduct_transaction = f"Automatic balance deduction for invoice #{active_invoice.id}" + if balance.top_down_if_amount_is_good(active_invoice.total, deduct_transaction): + # Auto finish invoice + active_invoice.finish() + send_notification_from_template( + project=active_invoice.project, + title=settings.EMAIL_TAG + f' Invoice #{active_invoice.id} has been Paid from Balance', + short_description=f'Invoice is paid with total of {active_invoice.total}', + template='invoice.html', + context={ + 'invoice': active_invoice, + 'company_name': get_dynamic_setting(COMPANY_NAME), + 'address': get_dynamic_setting(COMPANY_ADDRESS), + } + ) + return + + # Not Auto Finish send_notification_from_template( project=active_invoice.project, title=settings.EMAIL_TAG + f' Your Invoice #{active_invoice.id} is Ready', diff --git a/core/migrations/0013_balance_balancetransaction.py b/core/migrations/0013_balance_balancetransaction.py new file mode 100644 index 0000000..4190a4c --- /dev/null +++ b/core/migrations/0013_balance_balancetransaction.py @@ -0,0 +1,46 @@ +# Generated by Django 3.2.6 on 2022-11-29 15:43 + +from decimal import Decimal +from django.db import migrations, models +import django.db.models.deletion +import djmoney.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0012_auto_20221121_1511'), + ] + + operations = [ + migrations.CreateModel( + name='Balance', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('amount_currency', djmoney.models.fields.CurrencyField(choices=[('IDR', 'Indonesian Rupiah')], default='IDR', editable=False, max_length=3)), + ('amount', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0'), max_digits=10)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.billingproject')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='BalanceTransaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('amount_currency', djmoney.models.fields.CurrencyField(choices=[('IDR', 'Indonesian Rupiah')], default='IDR', editable=False, max_length=3)), + ('amount', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=10)), + ('action', models.CharField(choices=[('top_up', 'Top Up'), ('top_down', 'Top Down')], max_length=256)), + ('description', models.CharField(max_length=256)), + ('balance', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.balance')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/core/models.py b/core/models.py index 2bab5ba..a5b781a 100644 --- a/core/models.py +++ b/core/models.py @@ -4,7 +4,7 @@ import re from django.conf import settings from django.core.mail import send_mail -from django.db import models +from django.db import models, transaction from django.utils import timezone from django.utils.html import strip_tags from djmoney.models.fields import MoneyField @@ -127,6 +127,7 @@ class Invoice(BaseModel, TimestampMixin): self.end_date = date self.tax = tax_percentage * self.subtotal / 100 self.total = self.tax + self.subtotal + # TODO: Deduct balance self.save() def finish(self): @@ -297,3 +298,59 @@ class Notification(BaseModel, TimestampMixin): LOG.exception('Error sending notification') self.sent_status = False self.save() + + +# region balance +class Balance(BaseModel, TimestampMixin): + project = models.ForeignKey('BillingProject', on_delete=models.CASCADE) + amount = MoneyField(max_digits=10, default=0) + + @classmethod + def get_balance_for_project(cls, project): + balance, created = Balance.objects.get_or_create(project=project, defaults={ + "project": project + }) + + return balance + + @transaction.atomic + def top_up(self, amount, description): + balance_transaction = BalanceTransaction(balance=self, action=BalanceTransaction.ActionType.TOP_UP, + amount=amount, description=description) + balance_transaction.save() + + self.amount += amount + self.save() + + return balance_transaction + + @transaction.atomic + def top_down(self, amount, description): + balance_transaction = BalanceTransaction(balance=self, action=BalanceTransaction.ActionType.TOP_DOWN, + amount=amount, description=description) + balance_transaction.save() + + self.amount -= amount + self.save() + + return balance_transaction + + def top_down_if_amount_is_good(self, amount, description) -> bool: + if self.amount >= amount: + self.top_down(amount, description) + return True + + return False + + +class BalanceTransaction(BaseModel, TimestampMixin): + class ActionType(models.TextChoices): + TOP_UP = "top_up" + TOP_DOWN = "top_down" + + balance = models.ForeignKey('Balance', on_delete=models.CASCADE, blank=True, null=True) + amount = MoneyField(max_digits=10) + action = models.CharField(choices=ActionType.choices, max_length=256) + description = models.CharField(max_length=256) + +# end region diff --git a/core/utils/dynamic_setting.py b/core/utils/dynamic_setting.py index 4c65094..6e25bb2 100644 --- a/core/utils/dynamic_setting.py +++ b/core/utils/dynamic_setting.py @@ -8,6 +8,8 @@ COMPANY_NAME = "company_name" COMPANY_LOGO = "company_logo" COMPANY_ADDRESS = "company_address" EMAIL_ADMIN = "email_admin" +INVOICE_AUTO_DEDUCT_BALANCE = "invoice_auto_deduct_balance" +HOW_TO_TOP_UP = "how_to_top_up" DEFAULTS = { BILLING_ENABLED: False, @@ -16,6 +18,8 @@ DEFAULTS = { COMPANY_LOGO: '', COMPANY_ADDRESS: '', EMAIL_ADMIN: '', + INVOICE_AUTO_DEDUCT_BALANCE: True, + HOW_TO_TOP_UP: 'Please Contact Administrator' }