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

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

View File

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

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

View File

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

View File

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

View File

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