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 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']

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'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)),

View file

@ -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

View file

@ -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

View file

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

View file

@ -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',

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.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

View file

@ -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'
}