feat(wallet): implement wallet transaction model, views, and serializers for fund management
All checks were successful
Build and Push Docker Images / Build and Push Docker Images (push) Successful in 4m42s

This commit is contained in:
2025-07-25 14:38:34 +05:00
parent f8c91e8f14
commit 1554829b9a
11 changed files with 256 additions and 52 deletions

View File

@@ -8,6 +8,7 @@ from django.db import models
from .managers import CustomUserManager from .managers import CustomUserManager
from django.utils import timezone from django.utils import timezone
import pyotp import pyotp
from billing.models import WalletTransaction
class User(AbstractUser): class User(AbstractUser):
@@ -47,6 +48,31 @@ class User(AbstractUser):
def get_all_fields(self, instance): def get_all_fields(self, instance):
return [field.name for field in instance.get_fields()] 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() objects = CustomUserManager()
@@ -110,7 +136,7 @@ class TemporaryUser(models.Model):
verbose_name_plural = "Temporary Users" verbose_name_plural = "Temporary Users"
def __str__(self) -> str: def __str__(self) -> str:
return str(self.t_username) return f"{self.t_username}"
class Atoll(models.Model): class Atoll(models.Model):

View File

@@ -18,7 +18,6 @@ from .views import (
RetrieveUpdateDestroyIslandView, RetrieveUpdateDestroyIslandView,
filter_user, filter_user,
filter_temporary_user, filter_temporary_user,
UpdateUserWalletView,
VerifyOTPView, VerifyOTPView,
UserVerifyAPIView, UserVerifyAPIView,
UserUpdateAPIView, UserUpdateAPIView,
@@ -37,9 +36,6 @@ urlpatterns = [
path("tokens/", KnoxTokenListApiView.as_view(), name="knox_tokens"), path("tokens/", KnoxTokenListApiView.as_view(), name="knox_tokens"),
# path("auth/", CustomAuthToken.as_view()), # path("auth/", CustomAuthToken.as_view()),
path("users/", ListUserView.as_view(), name="users"), path("users/", ListUserView.as_view(), name="users"),
path(
"update-wallet/<int:pk>/", UpdateUserWalletView.as_view(), name="update-wallet"
),
path("users/<int:pk>/", UserDetailAPIView.as_view(), name="user-detail"), path("users/<int:pk>/", UserDetailAPIView.as_view(), name="user-detail"),
path("users/<int:pk>/update/", UserUpdateAPIView.as_view(), name="user-update"), path("users/<int:pk>/update/", UserUpdateAPIView.as_view(), name="user-update"),
path("users/filter/", filter_user, name="filter-users"), path("users/filter/", filter_user, name="filter-users"),

View File

@@ -35,6 +35,7 @@ from django.core.mail import send_mail
from django.db.models import Q from django.db.models import Q
from api.notifications import send_otp from api.notifications import send_otp
from .utils import check_person_api_verification from .utils import check_person_api_verification
import uuid
# local apps import # local apps import
from .serializers import ( from .serializers import (
@@ -66,35 +67,6 @@ def healthcheck(request):
return Response({"status": "Good"}, status=status.HTTP_200_OK) 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): class CreateTemporaryUserView(generics.CreateAPIView):
# Create user API view # Create user API view
serializer_class = TemporaryUserSerializer serializer_class = TemporaryUserSerializer
@@ -423,6 +395,8 @@ class AgreementUpdateAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView)
{"message": "Invalid file type. Only PDF files are allowed."}, {"message": "Invalid file type. Only PDF files are allowed."},
status=status.HTTP_400_BAD_REQUEST, 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: if agreement:
user.agreement = agreement user.agreement = agreement
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)

View File

@@ -1,9 +1,28 @@
from django.contrib import admin from django.contrib import admin
from .models import Payment, BillFormula, Topup from .models import Payment, BillFormula, Topup, WalletTransaction
# Register your models here. # 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): class PaymentAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"id", "id",
@@ -53,3 +72,4 @@ class TopupAdmin(admin.ModelAdmin):
admin.site.register(Payment, PaymentAdmin) admin.site.register(Payment, PaymentAdmin)
admin.site.register(BillFormula) admin.site.register(BillFormula)
admin.site.register(Topup, TopupAdmin) admin.site.register(Topup, TopupAdmin)
admin.site.register(WalletTransaction, WalletTransactionAdmin)

View File

@@ -1,5 +1,5 @@
import django_filters import django_filters
from .models import Payment, Topup from .models import Payment, Topup, WalletTransaction
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
@@ -87,3 +87,24 @@ class TopupFilter(django_filters.FilterSet):
"created_at", "created_at",
"is_expired", "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"]

View File

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

View File

@@ -1,11 +1,11 @@
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from api.models import User
import uuid import uuid
from django.conf import settings
from devices.models import Device
# Create your models here. # Create your models here.
user = settings.AUTH_USER_MODEL
from devices.models import Device
# Create your models here. # Create your models here.
@@ -20,7 +20,7 @@ class Payment(models.Model):
number_of_months = models.IntegerField() number_of_months = models.IntegerField()
amount = models.FloatField() amount = models.FloatField()
paid = models.BooleanField(default=False) 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) paid_at = models.DateTimeField(null=True, blank=True)
method = models.CharField(max_length=255, choices=PAYMENT_TYPES, default="TRANSFER") method = models.CharField(max_length=255, choices=PAYMENT_TYPES, default="TRANSFER")
expiry_notification_sent = models.BooleanField(default=False) expiry_notification_sent = models.BooleanField(default=False)
@@ -65,7 +65,7 @@ class BillFormula(models.Model):
class Topup(models.Model): class Topup(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
amount = models.FloatField() 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 = models.BooleanField(default=False)
paid_at = models.DateTimeField(null=True, blank=True) paid_at = models.DateTimeField(null=True, blank=True)
status = models.CharField( status = models.CharField(
@@ -94,3 +94,28 @@ class Topup(models.Model):
class Meta: class Meta:
ordering = ["-created_at"] 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"]

View File

@@ -1,5 +1,5 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Payment, Topup from .models import Payment, Topup, WalletTransaction
from devices.serializers import AdminDeviceSerializer from devices.serializers import AdminDeviceSerializer
@@ -69,3 +69,23 @@ class TopupSerializer(serializers.ModelSerializer):
"updated_at", "updated_at",
] ]
read_only_fields = ["id", "created_at", "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"]

View File

@@ -10,6 +10,7 @@ from .views import (
VerifyTopupPaymentAPIView, VerifyTopupPaymentAPIView,
TopupDetailAPIView, TopupDetailAPIView,
CancelTopupView, CancelTopupView,
ListWalletTransactionView,
) )
urlpatterns = [ urlpatterns = [
@@ -41,4 +42,10 @@ urlpatterns = [
CancelTopupView.as_view(), CancelTopupView.as_view(),
name="cancel-topup", name="cancel-topup",
), ),
# Wallet transactions
path(
"wallet-transactions/",
ListWalletTransactionView.as_view(),
name="list-wallet-transactions",
),
] ]

View File

@@ -15,11 +15,17 @@ from api.tasks import add_new_devices_to_omada
from apibase.env import BASE_DIR, env from apibase.env import BASE_DIR, env
import logging import logging
from .models import Device, Payment, Topup from .models import Device, Payment, Topup, WalletTransaction
from .serializers import PaymentSerializer, UpdatePaymentSerializer, TopupSerializer from .serializers import (
from .filters import PaymentFilter, TopupFilter PaymentSerializer,
UpdatePaymentSerializer,
TopupSerializer,
WalletTransactionSerializer,
)
from .filters import PaymentFilter, TopupFilter, WalletTransactionFilter
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import Optional from typing import Optional
from api.models import User
env.read_env(os.path.join(BASE_DIR, ".env")) env.read_env(os.path.join(BASE_DIR, ".env"))
@@ -161,7 +167,6 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
lookup_field = "pk" lookup_field = "pk"
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
# TODO: Fix check for success payment
payment = self.get_object() payment = self.get_object()
data = request.data data = request.data
user = request.user user = request.user
@@ -193,9 +198,16 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
) )
else: else:
self.process_wallet_payment( self.process_wallet_payment(
user, user, # type: ignore
payment, payment,
) )
return Response(
{
"status": True,
"message": "Payment verified successfully using wallet.",
},
status=status.HTTP_400_BAD_REQUEST,
)
if method == "TRANSFER": if method == "TRANSFER":
data = { data = {
"benefName": f"{user.first_name} {user.last_name}", # type: ignore "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, 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("processing wallet payment...")
print(user, payment.amount) print(user, payment.amount)
@@ -259,7 +271,9 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
payment.method = "WALLET" payment.method = "WALLET"
payment.save() payment.save()
user.wallet_balance -= payment.amount user.deduct_wallet_funds(
payment.amount, "Wallet payment for devices", payment.id
)
user.save() user.save()
return True return True
@@ -473,7 +487,11 @@ class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIVi
topup_verification_response = self.verify_transfer_topup(data, topup_instance) topup_verification_response = self.verify_transfer_topup(data, topup_instance)
print("Topup verification response:", topup_verification_response) print("Topup verification response:", topup_verification_response)
if topup_verification_response.success: 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() user.save()
topup_instance.status = "PAID" topup_instance.status = "PAID"
topup_instance.save() topup_instance.save()
@@ -533,3 +551,43 @@ class CancelTopupView(StaffEditorPermissionMixin, generics.UpdateAPIView):
instance.status = "CANCELLED" instance.status = "CANCELLED"
instance.save() instance.save()
return super().update(request, *args, **kwargs) 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)

View File

@@ -1,8 +1,10 @@
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from api.models import User
import re import re
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.conf import settings
user = settings.AUTH_USER_MODEL
def validate_mac_address(value): def validate_mac_address(value):
@@ -38,7 +40,7 @@ class Device(models.Model):
created_at = models.DateTimeField(default=timezone.now) created_at = models.DateTimeField(default=timezone.now)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
user = models.ForeignKey( 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): def __str__(self):