mirror of
https://github.com/i701/sarlink-portal-api.git
synced 2025-10-05 13:35:23 +00:00
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
All checks were successful
Build and Push Docker Images / Build and Push Docker Images (push) Successful in 4m42s
This commit is contained in:
@@ -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):
|
||||
|
@@ -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/<int:pk>/", UpdateUserWalletView.as_view(), name="update-wallet"
|
||||
),
|
||||
path("users/<int:pk>/", UserDetailAPIView.as_view(), name="user-detail"),
|
||||
path("users/<int:pk>/update/", UserUpdateAPIView.as_view(), name="user-update"),
|
||||
path("users/filter/", filter_user, name="filter-users"),
|
||||
|
32
api/views.py
32
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)
|
||||
|
@@ -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)
|
||||
|
@@ -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"]
|
||||
|
55
billing/migrations/0014_wallettransaction.py
Normal file
55
billing/migrations/0014_wallettransaction.py
Normal 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"],
|
||||
},
|
||||
),
|
||||
]
|
@@ -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"]
|
||||
|
@@ -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"]
|
||||
|
@@ -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",
|
||||
),
|
||||
]
|
||||
|
@@ -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)
|
||||
|
@@ -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):
|
||||
|
Reference in New Issue
Block a user