From 1554829b9a0fb9f047ed760b03167901131b32aa Mon Sep 17 00:00:00 2001 From: i701 Date: Fri, 25 Jul 2025 14:38:34 +0500 Subject: [PATCH] =?UTF-8?q?feat(wallet):=20implement=20wallet=20transactio?= =?UTF-8?q?n=20model,=20views,=20and=20serializers=20for=20fund=20manageme?= =?UTF-8?q?nt=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/models.py | 28 +++++++- api/urls.py | 4 -- api/views.py | 32 +-------- billing/admin.py | 22 +++++- billing/filters.py | 23 +++++- billing/migrations/0014_wallettransaction.py | 55 +++++++++++++++ billing/models.py | 35 +++++++-- billing/serializers.py | 22 +++++- billing/urls.py | 7 ++ billing/views.py | 74 +++++++++++++++++--- devices/models.py | 6 +- 11 files changed, 256 insertions(+), 52 deletions(-) create mode 100644 billing/migrations/0014_wallettransaction.py diff --git a/api/models.py b/api/models.py index 0506147..7022a1f 100644 --- a/api/models.py +++ b/api/models.py @@ -8,6 +8,7 @@ from django.db import models from .managers import CustomUserManager from django.utils import timezone import pyotp +from billing.models import WalletTransaction class User(AbstractUser): @@ -47,6 +48,31 @@ class User(AbstractUser): def get_all_fields(self, instance): return [field.name for field in instance.get_fields()] + def add_wallet_funds(self, amount, description="", reference_id=None): + self.wallet_balance += amount + self.save(update_fields=["wallet_balance"]) + WalletTransaction.objects.create( + user=self, + amount=amount, + transaction_type="TOPUP", + description=description, + reference_id=reference_id, + ) + + def deduct_wallet_funds(self, amount, description="", reference_id=None): + if self.wallet_balance >= amount: + self.wallet_balance -= amount + self.save(update_fields=["wallet_balance"]) + WalletTransaction.objects.create( + user=self, + amount=amount, + transaction_type="DEBIT", + description=description, + reference_id=reference_id, + ) + return True + return False + objects = CustomUserManager() @@ -110,7 +136,7 @@ class TemporaryUser(models.Model): verbose_name_plural = "Temporary Users" def __str__(self) -> str: - return str(self.t_username) + return f"{self.t_username}" class Atoll(models.Model): diff --git a/api/urls.py b/api/urls.py index f8c05b0..3805b3d 100644 --- a/api/urls.py +++ b/api/urls.py @@ -18,7 +18,6 @@ from .views import ( RetrieveUpdateDestroyIslandView, filter_user, filter_temporary_user, - UpdateUserWalletView, VerifyOTPView, UserVerifyAPIView, UserUpdateAPIView, @@ -37,9 +36,6 @@ urlpatterns = [ path("tokens/", KnoxTokenListApiView.as_view(), name="knox_tokens"), # path("auth/", CustomAuthToken.as_view()), path("users/", ListUserView.as_view(), name="users"), - path( - "update-wallet//", UpdateUserWalletView.as_view(), name="update-wallet" - ), path("users//", UserDetailAPIView.as_view(), name="user-detail"), path("users//update/", UserUpdateAPIView.as_view(), name="user-update"), path("users/filter/", filter_user, name="filter-users"), diff --git a/api/views.py b/api/views.py index 1b26d7f..3f375ba 100644 --- a/api/views.py +++ b/api/views.py @@ -35,6 +35,7 @@ from django.core.mail import send_mail from django.db.models import Q from api.notifications import send_otp from .utils import check_person_api_verification +import uuid # local apps import from .serializers import ( @@ -66,35 +67,6 @@ def healthcheck(request): return Response({"status": "Good"}, status=status.HTTP_200_OK) -class UpdateUserWalletView(generics.UpdateAPIView): - # Create user API view - serializer_class = CustomUserByWalletBalanceSerializer - permission_classes = (permissions.IsAuthenticated,) - queryset = User.objects.all() - lookup_field = "pk" - - def update(self, request, *args, **kwargs): - id_to_update = kwargs.get("pk") - user_id = request.user.id - print(f"User ID: {user_id}") - print(f"ID to update: {id_to_update}") - if user_id != id_to_update: - return Response( - {"message": "You are not authorized to update this user."}, - status=status.HTTP_403_FORBIDDEN, - ) - wallet_balance = request.data.get("wallet_balance") - if not wallet_balance: - return Response( - {"message": "wallet_balance is required."}, - status=status.HTTP_400_BAD_REQUEST, - ) - user = self.get_object() - user.wallet_balance = wallet_balance - user.save() - return Response({"message": "Wallet balance updated successfully."}) - - class CreateTemporaryUserView(generics.CreateAPIView): # Create user API view serializer_class = TemporaryUserSerializer @@ -423,6 +395,8 @@ class AgreementUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView) {"message": "Invalid file type. Only PDF files are allowed."}, status=status.HTTP_400_BAD_REQUEST, ) + # rename the file name to a random UUID followed by user_id + agreement.name = f"{uuid.uuid4()}_{user_id}_agreement.pdf" if agreement: user.agreement = agreement serializer.is_valid(raise_exception=True) diff --git a/billing/admin.py b/billing/admin.py index 62d120d..96526ed 100644 --- a/billing/admin.py +++ b/billing/admin.py @@ -1,9 +1,28 @@ from django.contrib import admin -from .models import Payment, BillFormula, Topup +from .models import Payment, BillFormula, Topup, WalletTransaction # Register your models here. +class WalletTransactionAdmin(admin.ModelAdmin): + list_display = ( + "id", + "user", + "amount", + "transaction_type", + "description", + "reference_id", + "created_at", + ) + search_fields = ( + "user__first_name", + "user__last_name", + "user__mobile", + "user__id_card", + ) + list_filter = ("transaction_type",) + + class PaymentAdmin(admin.ModelAdmin): list_display = ( "id", @@ -53,3 +72,4 @@ class TopupAdmin(admin.ModelAdmin): admin.site.register(Payment, PaymentAdmin) admin.site.register(BillFormula) admin.site.register(Topup, TopupAdmin) +admin.site.register(WalletTransaction, WalletTransactionAdmin) diff --git a/billing/filters.py b/billing/filters.py index 9fd8a6d..8a4c274 100644 --- a/billing/filters.py +++ b/billing/filters.py @@ -1,5 +1,5 @@ import django_filters -from .models import Payment, Topup +from .models import Payment, Topup, WalletTransaction from django.db.models import Q from django.utils import timezone @@ -87,3 +87,24 @@ class TopupFilter(django_filters.FilterSet): "created_at", "is_expired", ] + + +class WalletTransactionFilter(django_filters.FilterSet): + user = django_filters.CharFilter(method="filter_user_search") + amount = django_filters.RangeFilter(field_name="amount") + created_at = django_filters.DateFromToRangeFilter(field_name="created_at") + + def filter_user_search(self, queryset, name, value): + """ + Search across multiple user fields: first_name, last_name, id_card, mobile + """ + return queryset.filter( + Q(user__first_name__icontains=value) + | Q(user__last_name__icontains=value) + | Q(user__id_card__icontains=value) + | Q(user__mobile__icontains=value) + ) + + class Meta: + model = WalletTransaction + fields = ["user", "amount", "created_at"] diff --git a/billing/migrations/0014_wallettransaction.py b/billing/migrations/0014_wallettransaction.py new file mode 100644 index 0000000..ae5438d --- /dev/null +++ b/billing/migrations/0014_wallettransaction.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2 on 2025-07-25 08:34 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("billing", "0013_payment_expiry_notification_sent"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="WalletTransaction", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("amount", models.FloatField()), + ( + "transaction_type", + models.CharField( + choices=[("TOPUP", "Topup"), ("DEBIT", "Debit")], max_length=10 + ), + ), + ("description", models.TextField(blank=True, null=True)), + ( + "reference_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="wallet_transactions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + ] diff --git a/billing/models.py b/billing/models.py index f8b6f8a..969e31d 100644 --- a/billing/models.py +++ b/billing/models.py @@ -1,11 +1,11 @@ from django.db import models from django.utils import timezone -from api.models import User import uuid +from django.conf import settings +from devices.models import Device # Create your models here. - -from devices.models import Device +user = settings.AUTH_USER_MODEL # Create your models here. @@ -20,7 +20,7 @@ class Payment(models.Model): number_of_months = models.IntegerField() amount = models.FloatField() paid = models.BooleanField(default=False) - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="payments") + user = models.ForeignKey(user, on_delete=models.CASCADE, related_name="payments") paid_at = models.DateTimeField(null=True, blank=True) method = models.CharField(max_length=255, choices=PAYMENT_TYPES, default="TRANSFER") expiry_notification_sent = models.BooleanField(default=False) @@ -65,7 +65,7 @@ class BillFormula(models.Model): class Topup(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) amount = models.FloatField() - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="topups") + user = models.ForeignKey(user, on_delete=models.CASCADE, related_name="topups") paid = models.BooleanField(default=False) paid_at = models.DateTimeField(null=True, blank=True) status = models.CharField( @@ -94,3 +94,28 @@ class Topup(models.Model): class Meta: ordering = ["-created_at"] + + +class WalletTransaction(models.Model): + TRANSACTION_TYPES = [ + ("TOPUP", "Topup"), + ("DEBIT", "Debit"), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="wallet_transactions", + ) + amount = models.FloatField() + transaction_type = models.CharField(max_length=10, choices=TRANSACTION_TYPES) + description = models.TextField(blank=True, null=True) + reference_id = models.CharField(max_length=255, blank=True, null=True) + created_at = models.DateTimeField(default=timezone.now) + + def __str__(self): + return f"{self.transaction_type} {self.amount} ({self.user.username})" + + class Meta: + ordering = ["-created_at"] diff --git a/billing/serializers.py b/billing/serializers.py index 4b2d602..55dd7fb 100644 --- a/billing/serializers.py +++ b/billing/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import Payment, Topup +from .models import Payment, Topup, WalletTransaction from devices.serializers import AdminDeviceSerializer @@ -69,3 +69,23 @@ class TopupSerializer(serializers.ModelSerializer): "updated_at", ] read_only_fields = ["id", "created_at", "updated_at"] + + +class WalletTransactionSerializer(serializers.ModelSerializer): + user = serializers.SerializerMethodField() + + def get_user(self, obj): + user = obj.user + if user: + return { + "id": user.id, + "name": user.first_name + " " + user.last_name, + "id_card": user.id_card, + "mobile": user.mobile, + } + return None + + class Meta: # type: ignore + model = WalletTransaction + fields = "__all__" + read_only_fields = ["id", "created_at", "updated_at"] diff --git a/billing/urls.py b/billing/urls.py index df82a2f..340bea9 100644 --- a/billing/urls.py +++ b/billing/urls.py @@ -10,6 +10,7 @@ from .views import ( VerifyTopupPaymentAPIView, TopupDetailAPIView, CancelTopupView, + ListWalletTransactionView, ) urlpatterns = [ @@ -41,4 +42,10 @@ urlpatterns = [ CancelTopupView.as_view(), name="cancel-topup", ), + # Wallet transactions + path( + "wallet-transactions/", + ListWalletTransactionView.as_view(), + name="list-wallet-transactions", + ), ] diff --git a/billing/views.py b/billing/views.py index ef3d0cc..0d8ca88 100644 --- a/billing/views.py +++ b/billing/views.py @@ -15,11 +15,17 @@ from api.tasks import add_new_devices_to_omada from apibase.env import BASE_DIR, env import logging -from .models import Device, Payment, Topup -from .serializers import PaymentSerializer, UpdatePaymentSerializer, TopupSerializer -from .filters import PaymentFilter, TopupFilter +from .models import Device, Payment, Topup, WalletTransaction +from .serializers import ( + PaymentSerializer, + UpdatePaymentSerializer, + TopupSerializer, + WalletTransactionSerializer, +) +from .filters import PaymentFilter, TopupFilter, WalletTransactionFilter from dataclasses import dataclass, asdict from typing import Optional +from api.models import User env.read_env(os.path.join(BASE_DIR, ".env")) @@ -161,7 +167,6 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView): lookup_field = "pk" def update(self, request, *args, **kwargs): - # TODO: Fix check for success payment payment = self.get_object() data = request.data user = request.user @@ -193,9 +198,16 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView): ) else: self.process_wallet_payment( - user, + user, # type: ignore payment, ) + return Response( + { + "status": True, + "message": "Payment verified successfully using wallet.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) if method == "TRANSFER": data = { "benefName": f"{user.first_name} {user.last_name}", # type: ignore @@ -250,7 +262,7 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView): status=status.HTTP_400_BAD_REQUEST, ) - def process_wallet_payment(self, user, payment): + def process_wallet_payment(self, user: User, payment: Payment): print("processing wallet payment...") print(user, payment.amount) @@ -259,7 +271,9 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView): payment.method = "WALLET" payment.save() - user.wallet_balance -= payment.amount + user.deduct_wallet_funds( + payment.amount, "Wallet payment for devices", payment.id + ) user.save() return True @@ -473,7 +487,11 @@ class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIVi topup_verification_response = self.verify_transfer_topup(data, topup_instance) print("Topup verification response:", topup_verification_response) if topup_verification_response.success: - user.wallet_balance += topup_instance.amount # type: ignore + user.add_wallet_funds( # type: ignore + topup_instance.amount, + f"Topup of {topup_instance.amount} MVR", + topup_instance.id, + ) user.save() topup_instance.status = "PAID" topup_instance.save() @@ -533,3 +551,43 @@ class CancelTopupView(StaffEditorPermissionMixin, generics.UpdateAPIView): instance.status = "CANCELLED" instance.save() return super().update(request, *args, **kwargs) + + +class ListWalletTransactionView(StaffEditorPermissionMixin, generics.ListAPIView): + serializer_class = WalletTransactionSerializer + queryset = WalletTransaction.objects.all().select_related("user") + filter_backends = [DjangoFilterBackend] + filterset_fields = "__all__" + filterset_class = WalletTransactionFilter + + def get_queryset(self): + queryset = super().get_queryset() + if getattr(self.request.user, "is_admin") or self.request.user.is_superuser: + return queryset + return queryset.filter(user=self.request.user) + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + all_transations = request.query_params.get( + "all_transations", "false" + ).lower() in [ + "true", + "1", + "yes", + ] + if ( + request.user.is_authenticated + and getattr(request.user, "is_admin") + and bool(all_transations) + ): + pass + else: + queryset = queryset.filter(user=request.user) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) diff --git a/devices/models.py b/devices/models.py index 343361e..d68c170 100644 --- a/devices/models.py +++ b/devices/models.py @@ -1,8 +1,10 @@ from django.db import models from django.utils import timezone -from api.models import User import re from django.core.exceptions import ValidationError +from django.conf import settings + +user = settings.AUTH_USER_MODEL def validate_mac_address(value): @@ -38,7 +40,7 @@ class Device(models.Model): created_at = models.DateTimeField(default=timezone.now) updated_at = models.DateTimeField(auto_now=True) user = models.ForeignKey( - User, on_delete=models.SET_NULL, null=True, blank=True, related_name="devices" + user, on_delete=models.SET_NULL, null=True, blank=True, related_name="devices" ) def __str__(self):