Merge branch 'feat/balance_feature' into 'main'

feat: Balance Feature

See merge request dev/yuyu!9
This commit is contained in:
Setyo Nugroho 2023-02-10 07:05:06 +00:00
commit c2a6745b00
10 changed files with 236 additions and 11 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -3,7 +3,7 @@ from djmoney.settings import DECIMAL_PLACES
from rest_framework import serializers from rest_framework import serializers
from api import custom_validator 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 from core.component import component
@ -75,3 +75,26 @@ class NotificationSerializer(serializers.ModelSerializer):
model = Notification model = Notification
fields = ['id', 'project', 'title', 'short_description', 'content', 'sent_status', 'is_read', 'created_at', fields = ['id', 'project', 'title', 'short_description', 'content', 'sent_status', 'is_read', 'created_at',
'recipient'] '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']

View file

@ -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'admin_overview', views.AdminOverviewViewSet, basename='admin_overview')
router.register(r'project_overview', views.ProjectOverviewViewSet, basename='project_overview') router.register(r'project_overview', views.ProjectOverviewViewSet, basename='project_overview')
router.register(r'notification', views.NotificationViewSet, basename='notification') router.register(r'notification', views.NotificationViewSet, basename='notification')
router.register(r'balance', views.BalanceViewSet, basename='balance')
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),

View file

@ -5,15 +5,17 @@ import pytz
from django.db import transaction from django.db import transaction
from django.utils import timezone from django.utils import timezone
from djmoney.money import Money 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.decorators import action
from rest_framework.generics import get_object_or_404
from rest_framework.response import Response 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 import component, labels
from core.component.component import INVOICE_COMPONENT_MODEL from core.component.component import INVOICE_COMPONENT_MODEL
from core.exception import PriceNotFound 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.notification import send_notification_from_template
from core.utils.dynamic_setting import get_dynamic_settings, get_dynamic_setting, set_dynamic_setting, BILLING_ENABLED, \ from core.utils.dynamic_setting import get_dynamic_settings, get_dynamic_setting, set_dynamic_setting, BILLING_ENABLED, \
INVOICE_TAX, COMPANY_NAME, COMPANY_ADDRESS INVOICE_TAX, COMPANY_NAME, COMPANY_ADDRESS
@ -175,7 +177,13 @@ class InvoiceViewSet(viewsets.ModelViewSet):
@action(detail=True) @action(detail=True)
def finish(self, request, pk): def finish(self, request, pk):
invoice = Invoice.objects.filter(id=pk).first() 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 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() invoice.finish()
send_notification_from_template( send_notification_from_template(
@ -196,7 +204,13 @@ class InvoiceViewSet(viewsets.ModelViewSet):
@action(detail=True) @action(detail=True)
def rollback_to_unpaid(self, request, pk): def rollback_to_unpaid(self, request, pk):
invoice = Invoice.objects.filter(id=pk).first() 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 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() invoice.rollback_to_unpaid()
send_notification_from_template( send_notification_from_template(
@ -419,3 +433,48 @@ class NotificationViewSet(viewsets.ModelViewSet):
serializer = NotificationSerializer(notification) serializer = NotificationSerializer(notification)
return Response(serializer.data) 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

View file

@ -2,7 +2,7 @@ from django.contrib import admin
from core.models import FlavorPrice, VolumePrice, FloatingIpsPrice, BillingProject, Invoice, InvoiceVolume, \ from core.models import FlavorPrice, VolumePrice, FloatingIpsPrice, BillingProject, Invoice, InvoiceVolume, \
InvoiceFloatingIp, InvoiceInstance, DynamicSetting, InvoiceImage, ImagePrice, SnapshotPrice, RouterPrice, \ InvoiceFloatingIp, InvoiceInstance, DynamicSetting, InvoiceImage, ImagePrice, SnapshotPrice, RouterPrice, \
InvoiceSnapshot, InvoiceRouter, Notification InvoiceSnapshot, InvoiceRouter, Notification, Balance, BalanceTransaction
@admin.register(DynamicSetting) @admin.register(DynamicSetting)
@ -83,3 +83,16 @@ class InvoiceImageAdmin(admin.ModelAdmin):
@admin.register(Notification) @admin.register(Notification)
class InvoiceImageAdmin(admin.ModelAdmin): class InvoiceImageAdmin(admin.ModelAdmin):
list_display = ('project', 'title', 'short_description', 'sent_status') 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

View file

@ -92,7 +92,7 @@ class InvoiceHandler(metaclass=abc.ABCMeta):
project=None, project=None,
title=f'{settings.EMAIL_TAG} [Error] Price not found when rolling invoice', 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)}', 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}', f'invoice {getattr(instance, "invoice").id}',
) )

View file

@ -1,15 +1,15 @@
import logging import logging
import traceback import traceback
from typing import Mapping, Dict, Iterable from typing import Dict, Iterable
from django.core.management import BaseCommand from django.core.management import BaseCommand
from django.utils import timezone from django.utils import timezone
from core.models import Invoice, InvoiceComponentMixin
from core.component import component, labels 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.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, \ from core.utils.dynamic_setting import get_dynamic_setting, BILLING_ENABLED, INVOICE_TAX, COMPANY_NAME, \
COMPANY_ADDRESS COMPANY_ADDRESS, INVOICE_AUTO_DEDUCT_BALANCE
from yuyu import settings from yuyu import settings
LOG = logging.getLogger("yuyu_new_invoice") LOG = logging.getLogger("yuyu_new_invoice")
@ -68,6 +68,28 @@ class Command(BaseCommand):
"invoice": new_invoice "invoice": new_invoice
}, fallback_price=True) }, 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( send_notification_from_template(
project=active_invoice.project, project=active_invoice.project,
title=settings.EMAIL_TAG + f' Your Invoice #{active_invoice.id} is Ready', title=settings.EMAIL_TAG + f' Your Invoice #{active_invoice.id} is Ready',

View file

@ -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,
},
),
]

View file

@ -4,7 +4,7 @@ import re
from django.conf import settings from django.conf import settings
from django.core.mail import send_mail 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 import timezone
from django.utils.html import strip_tags from django.utils.html import strip_tags
from djmoney.models.fields import MoneyField from djmoney.models.fields import MoneyField
@ -127,6 +127,7 @@ class Invoice(BaseModel, TimestampMixin):
self.end_date = date self.end_date = date
self.tax = tax_percentage * self.subtotal / 100 self.tax = tax_percentage * self.subtotal / 100
self.total = self.tax + self.subtotal self.total = self.tax + self.subtotal
# TODO: Deduct balance
self.save() self.save()
def finish(self): def finish(self):
@ -297,3 +298,59 @@ class Notification(BaseModel, TimestampMixin):
LOG.exception('Error sending notification') LOG.exception('Error sending notification')
self.sent_status = False self.sent_status = False
self.save() 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

View file

@ -8,6 +8,8 @@ COMPANY_NAME = "company_name"
COMPANY_LOGO = "company_logo" COMPANY_LOGO = "company_logo"
COMPANY_ADDRESS = "company_address" COMPANY_ADDRESS = "company_address"
EMAIL_ADMIN = "email_admin" EMAIL_ADMIN = "email_admin"
INVOICE_AUTO_DEDUCT_BALANCE = "invoice_auto_deduct_balance"
HOW_TO_TOP_UP = "how_to_top_up"
DEFAULTS = { DEFAULTS = {
BILLING_ENABLED: False, BILLING_ENABLED: False,
@ -16,6 +18,8 @@ DEFAULTS = {
COMPANY_LOGO: '', COMPANY_LOGO: '',
COMPANY_ADDRESS: '', COMPANY_ADDRESS: '',
EMAIL_ADMIN: '', EMAIL_ADMIN: '',
INVOICE_AUTO_DEDUCT_BALANCE: True,
HOW_TO_TOP_UP: 'Please Contact Administrator'
} }