Author SHA1 Message Date
i701 c3fc48fddc refactor(tasks): update job scheduling frequency and reduce max hours for old job removal 🔨 2025-07-11 09:53:03 +05:00
i701 bb2d0348c2 feat(devices): add pending_payment_id field to DeviceSerializer for tracking unpaid payments 2025-07-09 22:06:28 +05:00
i701 e2ede37f4f refactor(devices): update device payment status in background task and remove unused serializer field 🔨 2025-07-09 21:55:33 +05:00
i701 6418b1469d refactor(tasks): replace Enum with string literals for notification types in SMS tasks 🔨 2025-07-09 20:32:04 +05:00
i701 911d01b8e3 feat(billing): implement notification for expired payments and refactor SMS handling 2025-07-09 20:13:42 +05:00
i701 1644bd47b9 feat(payment): add expiry_notification_sent field to track notification status 2025-07-09 20:13:30 +05:00
i701 ff897ee2ab feat(tasks): add periodic task to remove old jobs with enhanced logging and context handling 2025-07-09 20:13:20 +05:00
i701 35384ef049 refactor(api): change async functions to synchronous for user verification task 🔨 2025-07-08 22:57:43 +05:00
i701 8657435fbf fix(payment): refine unpaid payment query to include status and expiration checks 🐛 2025-07-08 20:53:05 +05:00
i701 39da7607f6 refactor(billing): enhance SMS message formatting for expired topups with improved date handling 2025-07-08 20:52:42 +05:00
i701 d0a8408121 feat(payment): update periodic task to run every minute and improve SMS notification handling for expired topups 2025-07-08 16:00:30 +05:00
i701 c17e34a592 feat(payment): update payment status to "PAID" upon verification 2025-07-06 22:53:02 +05:00
i701 cdfa2d9192 feat(payment): add expiration time for payments to enhance payment validity tracking 2025-07-06 22:04:37 +05:00
i701 342e963861 chore(payment): replace DeletePaymentView with CancelPaymentView in payment URLs 🔧 2025-07-06 21:24:39 +05:00
i701 4f794571e9 feat(payment): add is_expired filter and serializer method for payment expiration tracking 2025-07-06 21:23:56 +05:00
i701 6bc2d71a0e test(payment): add PaymentTests for canceling payments functionality 2025-07-06 21:23:35 +05:00
i701 ceb30025ee feat(admin): add is_expired field to PaymentAdmin for better payment visibility
feat(admin): include expiry_date in DeviceAdmin for enhanced device management 
2025-07-06 21:23:12 +05:00
i701 950f42ae3f feat(payment): add status field and is_expired property to Payment model for enhanced payment tracking 2025-07-06 21:22:56 +05:00
i701 f10fa74fbb refactor(payment): enhance payment verification with detailed response structure and transaction data 🔨 2025-07-06 21:22:36 +05:00
shihaam 60e394fffa clean up dangling images after restart 2025-07-05 20:23:00 +05:00
i701 193ce850b4 fix(filters): refine filter logic in TopupFilter to ensure only unpaid topups are considered for expiration check 🐛 2025-07-05 20:14:58 +05:00
i701 27f89b6d3d feat(filters): add is_expired filter to TopupFilter for improved topup management 2025-07-05 19:54:04 +05:00
i701 63b1a6b9ef fix(tasks): include paid filter in expired topups query to prevent notifications for paid topups 🐛 2025-07-05 18:05:39 +05:00
i701 3dafc7d4c8 feat(serializers): add paid_at field to TopupSerializer for improved data tracking 2025-07-05 17:55:26 +05:00
Abdulla AidhaanandGitHub 8e564f766b Merge pull request #9 from i701/feat/topups
feat/topups
2025-07-05 17:42:57 +05:00
i701 cd7555e35f fix(views): update paid_at field in VerifyTopupPaymentAPIView to use transaction date from MIB response 🐛 2025-07-05 17:42:06 +05:00
i701 3cc29cade6 feat(serializers): include status field in TopupSerializer for better data representation 2025-07-05 17:37:50 +05:00
i701 7003e4bcba feat(views): implement topup cancellation and update status to CANCELLED 2025-07-05 17:37:35 +05:00
i701 2da9dcf141 feat(commands): add random status assignment for seeded topups 2025-07-05 17:37:17 +05:00
i701 081366f87f feat(models): add status field to Topup model with choices for Pending, Paid, and Cancelled 2025-07-05 17:37:04 +05:00
i701 6431d61d39 fix(views): format topup amount to two decimal places in VerifyTopupPaymentAPIView 🐛 2025-07-05 17:16:00 +05:00
i701 8871de8ce1 fix(views): format topup amount to two decimal places in VerifyTopupPaymentAPIView 🔨 2025-07-05 17:02:19 +05:00
i701 54056e9b7e fix(views): add logging for MIB verification response in VerifyPaymentView 🐛 2025-07-05 16:44:33 +05:00
i701 09768ef2d3 fix(tasks): include creation time in SMS notification for expired topups 🐛 2025-07-05 16:30:11 +05:00
i701 48bde9d52b fix(views-verifypayment): improve error handling in payment verification process 🐛 2025-07-05 16:24:10 +05:00
i701 e85d605454 fix(views): correct logging of topup payment verification time 🐛 2025-07-05 15:54:24 +05:00
i701 683cfe76f1 refactor(settings): remove caching for now 🔨 2025-07-05 15:02:50 +05:00
Abdulla AidhaanandGitHub 3b3e963568 Merge pull request #8 from i701/feat/task-queues
feat/task queues
2025-07-05 14:35:41 +05:00
i701 c31acead70 refactor(signals, tasks): update user verification task to use async and improve logging 🔨 2025-07-05 14:33:25 +05:00
i701 6ec31023c7 feat(topup): add expiry notification handling and SMS notification task 2025-07-05 14:33:10 +05:00
i701 6fb70e82a3 feat(tasks): integrate procrastinate app and configure on_app_ready callback 2025-07-05 14:32:57 +05:00
i701 cf25863afe fix(billing): remove +5 minutes for payment verification payload 🐛 2025-07-04 22:52:13 +05:00
i701 cd17ced67d refactor(topup): Add logging for topup payment verification process and print response 2025-07-04 22:23:02 +05:00
Abdulla AidhaanandGitHub f3e86d5873 Merge pull request #7 from i701/feat/topups
feat(topup): Update wallet balance upon successful topup verification 
2025-07-04 21:55:53 +05:00
i701 b6517e79ab feat(topup): Update wallet balance upon successful topup verification 2025-07-04 21:54:27 +05:00
Abdulla AidhaanandGitHub e88f4268cf Merge pull request #6 from i701/feat/topups
feature/topups
2025-07-04 20:16:22 +05:00
i701 e8e6a09b24 feat(billing): Add 'expires_at' field to TopupSerializer for better expiration tracking 2025-07-04 20:14:57 +05:00
i701 70f8efb19a refactor(signals): Simplify topup permissions assignment by including all permissions 🔨 2025-07-04 20:14:48 +05:00
i701 ddb65ca985 feat(billing): Add delete functionality for topups and update creation logic with expiration time 2025-07-04 20:14:37 +05:00
Abdulla AidhaanandGitHub 740d16189b Merge pull request #5 from i701/feat/topups
feature/topups
2025-07-04 16:34:15 +05:00
i701 212ea2541f refactor(billing): Simplify topup seeding by removing payment status and date logic 🔨 2025-07-04 16:32:45 +05:00
i701 5db71edc2c feat(billing): Implement DeleteTopupView with expiration and authorization checks 2025-07-04 16:32:34 +05:00
i701 6568504f5b migration(billing): Add expires_at field and is_expired property to Topup model for expiration management 🔧 2025-07-04 16:32:21 +05:00
i701 d4b26074e6 feat(billing): Add is_expired field to TopupAdmin and TopupSerializer for better topup management 2025-07-04 16:31:47 +05:00
Abdulla AidhaanandGitHub 638c32cb80 Merge pull request #4 from i701/feat/topups
feat(billing): Implement TopupDetailAPIView for retrieving single top…
2025-07-04 10:12:15 +05:00
i701 61e008d4fb feat(billing): Implement TopupDetailAPIView for retrieving single topup details 2025-07-04 10:11:09 +05:00
Abdulla AidhaanandGitHub 25bad98900 Merge pull request #3 from i701/feat/topups
feature/topups
2025-07-03 21:07:14 +05:00
i701 cec2045e5f refactor(billing): Enhance TopupSerializer to include detailed user information and update ListCreateTopupView queryset filtering 🔨 2025-07-03 21:04:59 +05:00
i701 e4a01597aa refactor(billing): Refactor TopupFilter to enhance user search functionality and update fields 🔨 2025-07-03 21:04:42 +05:00
i701 4a944c176b feat(billing): Add management command to seed topup models with dummy data 2025-07-03 21:04:14 +05:00
i701 f67a3762ad test(billing): Add comprehensive tests for Topup model and payment verification 🧪 2025-07-03 21:03:55 +05:00
i701 2122e8dfee feat(billing): Enhance Topup admin interface and add default ordering for Topup model 2025-07-03 21:03:15 +05:00
i701 978a4a27d0 feat(billing): Add topup permissions assignment for new users 2025-07-03 21:02:37 +05:00
i701 367ccf0f88 feat(justfile): Add migrate and make-migrations commands for database management 2025-07-03 17:13:41 +05:00
i701 c07d3c93d2 feat(billing): WIP Add Topup model, filters, serializers, and views for topup management 2025-07-03 17:13:25 +05:00
i701 bae0882879 refactor(DeviceListCreateAPIView): Update all_devices parameter handling for improved boolean parsing 🔨 2025-06-30 23:21:08 +05:00
i701 708d7c2bec refactor(filters): Enhance user filtering in DeviceFilter to search across multiple user fields 🔨 2025-06-30 22:40:13 +05:00
Abdulla AidhaanandGitHub dc8fe44004 Merge pull request #2 from i701/feat/payments-filter
refactor(portal-api): Device serializers to include user information …
2025-06-30 22:30:45 +05:00
Abdulla AidhaanandGitHub c4f8989734 Merge pull request #1 from i701/feat/payments-filter
feat(api-payments): add relevant payment filters for payments view and add seeding 🌱
2025-06-29 20:01:54 +05:00
i701 eb43b3108d Refactor Omada client initialization: move instantiation to add_new_devices_to_omada task 2025-06-28 14:49:39 +05:00
i701 828da25046 Update PostgreSQL database settings to include default values for development 2025-06-28 14:39:21 +05:00
28 changed files with 1184 additions and 117 deletions
+4
View File
@@ -39,3 +39,7 @@ jobs:
docker compose --progress plain down portal-api portal-api-nginx && \
docker compose --progress plain up -d portal-api portal-api-nginx && \
docker compose exec portal-api python manage.py migrate"
- name: Clean up dangling images
if: github.event_name != 'pull_request'
run: ssh root@10.0.1.5 -t "docker image prune -f"
+3 -2
View File
@@ -7,7 +7,6 @@ from django.db.models.signals import post_save
from api.models import User
from django.contrib.auth.models import Permission
from api.tasks import verify_user_with_person_api_task
from asgiref.sync import sync_to_async
@receiver(post_save, sender=User)
@@ -18,7 +17,10 @@ def assign_device_permissions(sender, instance, created, **kwargs):
atoll_read_permission = Permission.objects.get(codename="view_atoll")
island_read_permission = Permission.objects.get(codename="view_island")
payment_permissions = Permission.objects.filter(content_type__model="payment")
topup_permissions = Permission.objects.filter(content_type__model="topup")
for permission in topup_permissions:
instance.user_permissions.add(permission)
for permission in device_permissions:
instance.user_permissions.add(permission)
instance.user_permissions.add(atoll_read_permission, island_read_permission)
@@ -27,7 +29,6 @@ def assign_device_permissions(sender, instance, created, **kwargs):
@receiver(post_save, sender=User)
@sync_to_async
def verify_user_with_person_api(sender, instance, created, **kwargs):
if created:
verify_user_with_person_api_task(instance.id)
+34 -20
View File
@@ -5,26 +5,39 @@ from api.notifications import send_sms
import os
import logging
from django.utils import timezone
from api.notifications import send_clean_telegram_markdown
# from api.notifications import send_clean_telegram_markdown
from api.omada import Omada
from apibase.env import env, BASE_DIR
from procrastinate.contrib.django import app
from procrastinate import builtin_tasks
logger = logging.getLogger(__name__)
env.read_env(os.path.join(BASE_DIR, ".env"))
omada_client = Omada()
@app.periodic(cron="0 * * * *") # every 1 hour
@app.task(
queueing_lock="remove_old_jobs",
pass_context=True,
)
async def remove_old_jobs(context, timestamp):
logger.info("Running remove_old_jobs task...")
return await builtin_tasks.remove_old_jobs(
context,
queue="heavy_tasks",
max_hours=1,
remove_failed=True,
remove_cancelled=True,
remove_aborted=True,
)
@app.task
def add(x, y):
print(f"Adding {x} and {y}")
return x + y
@app.periodic(cron="0 0 */28 * *") # type: ignore
@app.periodic(
cron="0 0 */28 * *", queue="heavy_tasks", periodic_id="deactivate_expired_devices"
) # type: ignore
@app.task
def deactivate_expired_devices():
expired_devices = Device.objects.filter(
@@ -64,6 +77,7 @@ def add_new_devices_to_omada(new_devices: list[dict]):
:param new_devices: List of new device names to add.
"""
logger.info("Running add new devices to Omada task...")
omada_client = Omada()
omada_client.add_new_devices_to_omada(new_devices)
@@ -81,16 +95,16 @@ def verify_user_with_person_api_task(user_id: int):
return None
# Call the Person API to verify the user
verification_failed_message = f"""
_The following user verification failed_:
*ID Card:* {user.id_card}
*Name:* {user.first_name} {user.last_name}
*House Name:* {user.address}
*Date of Birth:* {user.dob}
*Island:* {(user.atoll.name if user.atoll else "N/A")} {(user.island.name if user.island else "N/A")}
*Mobile:* {user.mobile}
Visit [SAR Link Portal](https://portal.sarlink.net) to manually verify this user.
"""
# verification_failed_message = f"""
# _The following user verification failed_:
# *ID Card:* {user.id_card}
# *Name:* {user.first_name} {user.last_name}
# *House Name:* {user.address}
# *Date of Birth:* {user.dob}
# *Island:* {(user.atoll.name if user.atoll else "N/A")} {(user.island.name if user.island else "N/A")}
# *Mobile:* {user.mobile}
# Visit [SAR Link Portal](https://portal.sarlink.net) to manually verify this user.
# """
# logger.info(verification_failed_message)
PERSON_VERIFY_BASE_URL = env.str("PERSON_VERIFY_BASE_URL", default="") # type: ignore
@@ -171,7 +185,7 @@ def verify_user_with_person_api_task(user_id: int):
user.mobile,
f"Dear {user.first_name} {user.last_name}, \n\nYour account registration is being processed. \n\nWe will notify you once verification is complete. \n\n - SAR Link",
)
send_clean_telegram_markdown(message=verification_failed_message)
# send_clean_telegram_markdown(message=verification_failed_message)
return False
else:
# Handle the error case
+14
View File
@@ -0,0 +1,14 @@
# projects/tasks_app.py
from procrastinate import App
from procrastinate.contrib.django import django_connector
app = App(
connector=django_connector.DjangoConnector(),
periodic_defaults={"max_delay": 86400}, # accept up to 24h delay
)
def on_app_ready(app):
app.periodic_defaults = {"max_delay": 86400}
app.import_paths.append("api.tasks")
app.import_paths.append("billing.tasks")
-2
View File
@@ -31,7 +31,6 @@ from typing import cast, Dict, Any
from django.core.mail import send_mail
from django.db.models import Q
from api.notifications import send_otp
from .tasks import add
from .utils import check_person_api_verification
# local apps import
@@ -60,7 +59,6 @@ class ErrorMessages:
@api_view(["GET"])
def healthcheck(request):
add.defer(1, 2)
return Response({"status": "Good"}, status=status.HTTP_200_OK)
+14 -31
View File
@@ -137,37 +137,23 @@ if not DEBUG:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": env("POSTGRES_DATABASE"),
"USER": env("POSTGRES_USER"),
"PASSWORD": env("POSTGRES_PASSWORD"),
"HOST": env("POSTGRES_HOST"),
"PORT": env("POSTGRES_PORT"),
"NAME": env("POSTGRES_DATABASE", default="mydb"), # type: ignore
"USER": env("POSTGRES_USER", default="postgres"), # type: ignore
"PASSWORD": env("POSTGRES_PASSWORD", default="testpass123"), # type: ignore
"HOST": env("POSTGRES_HOST", default="localhost"), # type: ignore
"PORT": env("POSTGRES_PORT", default="6500"), # type: ignore
}
}
# More robust caching configuration
CACHES = {
"default": {
"BACKEND": (
"django_redis.cache.RedisCache"
if not DEBUG
else "django.core.cache.backends.locmem.LocMemCache"
),
"LOCATION": (
env("REDIS_URL", default="redis://redis:6379/") if not DEBUG else "" # type: ignore
),
"OPTIONS": (
{
"CLIENT_CLASS": (
"django_redis.client.DefaultClient" if not DEBUG else None
),
}
if not DEBUG
else {}
),
}
}
# More robust caching configuration
# CACHES = {
# "default": {
# "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
# "LOCATION": "unix:/tmp/memcached.sock",
# }
# }
# Password validation
@@ -371,8 +357,5 @@ PASSWORDLESS_AUTH = {
}
# CELERY CONFIGURATION
CELERY_BROKER_URL = f"redis://{REDIS_HOST}:6379/0"
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_BACKEND = f"redis://{REDIS_HOST}:6379/0"
PROCRASTINATE_ON_APP_READY = "api.tasks_app.on_app_ready"
PROCRASTINATE_APP = "api.tasks_app.app"
+33 -1
View File
@@ -14,10 +14,42 @@ class PaymentAdmin(admin.ModelAdmin):
"paid_at",
"method",
"created_at",
"expires_at",
"is_expired",
"updated_at",
)
@admin.display(boolean=True, description="Expired")
def is_expired(self, obj):
return obj.is_expired
class TopupAdmin(admin.ModelAdmin):
list_display = (
"id",
"user",
"amount",
"paid",
"paid_at",
"status",
"created_at",
"is_expired",
"expires_at",
"updated_at",
)
search_fields = (
"user__first_name",
"user__last_name",
"user__id_card",
"user__mobile",
)
@admin.display(boolean=True, description="Expired")
def is_expired(self, obj):
return obj.is_expired
admin.site.register(Payment, PaymentAdmin)
admin.site.register(BillFormula)
admin.site.register(Topup)
admin.site.register(Topup, TopupAdmin)
+56 -1
View File
@@ -1,5 +1,7 @@
import django_filters
from .models import Payment
from .models import Payment, Topup
from django.db.models import Q
from django.utils import timezone
class PaymentFilter(django_filters.FilterSet):
@@ -12,7 +14,60 @@ class PaymentFilter(django_filters.FilterSet):
mib_reference = django_filters.CharFilter(lookup_expr="icontains")
paid_at = django_filters.DateFromToRangeFilter()
created_at = django_filters.DateFromToRangeFilter()
is_expired = django_filters.BooleanFilter(method="filter_is_expired")
def filter_is_expired(self, queryset, name, value):
"""
Filter payments based on whether they are expired or not
"""
now = timezone.now()
queryset = queryset.filter(paid=False)
if value:
return queryset.filter(expires_at__isnull=False, expires_at__lt=now)
else:
return queryset.filter(Q(expires_at__isnull=True) | Q(expires_at__gte=now))
class Meta:
model = Payment
fields = "__all__"
class TopupFilter(django_filters.FilterSet):
amount = django_filters.RangeFilter(field_name="amount")
paid = django_filters.BooleanFilter(field_name="paid")
user = django_filters.CharFilter(method="filter_user_search")
created_at = django_filters.DateFromToRangeFilter(field_name="created_at")
is_expired = django_filters.BooleanFilter(method="filter_is_expired")
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)
)
def filter_is_expired(self, queryset, name, value):
"""
Filter topups based on whether they are expired or not
"""
now = timezone.now()
queryset = queryset.filter(paid=False)
if value:
return queryset.filter(expires_at__isnull=False, expires_at__lt=now)
else:
return queryset.filter(Q(expires_at__isnull=True) | Q(expires_at__gte=now))
class Meta:
model = Topup
fields = [
"amount",
"paid",
"status",
"user",
"created_at",
"is_expired",
]
@@ -0,0 +1,60 @@
# billing/management/commands/seed_billing.py
import random
from django.core.management.base import BaseCommand
from django.utils import timezone
from faker import Faker
from billing.models import Topup
from api.models import User
class Command(BaseCommand):
help = "Seeds topup models with dummy data."
def add_arguments(self, parser):
parser.add_argument(
"--number",
type=int,
default=10,
help="The number of topups to create.",
)
def handle(self, *args, **options):
number = options["number"]
fake = Faker()
users = User.objects.all()
if not users.exists():
self.stdout.write(
self.style.ERROR(
"No users found. Please seed users first (e.g., python manage.py seed_users)."
)
)
return
self.stdout.write(self.style.NOTICE(f"Seeding {number} topups..."))
for _ in range(number):
random_user = random.choice(users)
expires_at_date = timezone.now() + timezone.timedelta(
minutes=10,
)
print(
f"Creating topup for user {getattr(random_user, 'id', None)} expires at: {expires_at_date}"
)
Topup.objects.create(
amount=fake.pydecimal(
left_digits=4,
right_digits=2,
positive=True,
min_value=100.00,
max_value=5000.00,
),
status=random.choice(["PENDING", "PAID", "CANCELLED"]),
user=random_user,
updated_at=timezone.now(),
expires_at=expires_at_date,
)
self.stdout.write(self.style.SUCCESS(f"Successfully seeded {number} topups."))
@@ -0,0 +1,17 @@
# Generated by Django 5.2 on 2025-07-03 11:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0005_alter_payment_options_payment_mib_reference"),
]
operations = [
migrations.AddField(
model_name="topup",
name="mib_reference",
field=models.CharField(blank=True, default="", null=True),
),
]
+17
View File
@@ -0,0 +1,17 @@
# Generated by Django 5.2 on 2025-07-03 12:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0006_topup_mib_reference"),
]
operations = [
migrations.AddField(
model_name="topup",
name="paid_at",
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,26 @@
# Generated by Django 5.2 on 2025-07-04 06:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0007_topup_paid_at"),
]
operations = [
migrations.AlterModelOptions(
name="topup",
options={"ordering": ["-created_at"]},
),
migrations.AddField(
model_name="topup",
name="expired",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="topup",
name="expires_at",
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,16 @@
# Generated by Django 5.2 on 2025-07-04 11:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("billing", "0008_alter_topup_options_topup_expired_topup_expires_at"),
]
operations = [
migrations.RemoveField(
model_name="topup",
name="expired",
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.2 on 2025-07-05 09:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0009_remove_topup_expired"),
]
operations = [
migrations.AddField(
model_name="topup",
name="expiry_notification_sent",
field=models.BooleanField(default=False),
),
]
+25
View File
@@ -0,0 +1,25 @@
# Generated by Django 5.2 on 2025-07-05 12:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0010_add_expiry_notification_sent_to_topup"),
]
operations = [
migrations.AddField(
model_name="topup",
name="status",
field=models.CharField(
choices=[
("PENDING", "Pending"),
("PAID", "Paid"),
("CANCELLED", "Cancelled"),
],
default="PENDING",
max_length=20,
),
),
]
+25
View File
@@ -0,0 +1,25 @@
# Generated by Django 5.2 on 2025-07-06 15:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0011_topup_status"),
]
operations = [
migrations.AddField(
model_name="payment",
name="status",
field=models.CharField(
choices=[
("PENDING", "Pending"),
("PAID", "Paid"),
("CANCELLED", "Cancelled"),
],
default="PENDING",
max_length=20,
),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.2 on 2025-07-09 14:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0012_payment_status"),
]
operations = [
migrations.AddField(
model_name="payment",
name="expiry_notification_sent",
field=models.BooleanField(default=False),
),
]
+38
View File
@@ -23,10 +23,26 @@ class Payment(models.Model):
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)
expires_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(default=timezone.now)
updated_at = models.DateTimeField(auto_now=True)
devices = models.ManyToManyField(Device, related_name="payments")
status = models.CharField(
max_length=20,
choices=[
("PENDING", "Pending"),
("PAID", "Paid"),
("CANCELLED", "Cancelled"),
],
default="PENDING",
)
@property
def is_expired(self):
if self.expires_at is None:
return False
return timezone.now() > self.expires_at
def __str__(self):
return f"Payment by {self.user}"
@@ -51,8 +67,30 @@ class Topup(models.Model):
amount = models.FloatField()
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(
max_length=20,
choices=[
("PENDING", "Pending"),
("PAID", "Paid"),
("CANCELLED", "Cancelled"),
],
default="PENDING",
)
mib_reference = models.CharField(default="", null=True, blank=True)
expires_at = models.DateTimeField(null=True, blank=True)
expiry_notification_sent = models.BooleanField(default=False)
created_at = models.DateTimeField(default=timezone.now)
updated_at = models.DateTimeField(auto_now=True)
@property
def is_expired(self):
if self.expires_at is None:
return False
return timezone.now() > self.expires_at
def __str__(self):
return f"Topup for {self.user}"
class Meta:
ordering = ["-created_at"]
+43 -3
View File
@@ -1,19 +1,59 @@
from rest_framework import serializers
from .models import Payment
from .models import Payment, Topup
from devices.serializers import DeviceSerializer
class PaymentSerializer(serializers.ModelSerializer):
devices = DeviceSerializer(many=True, read_only=True)
is_expired = serializers.SerializerMethodField()
class Meta:
def get_is_expired(self, obj):
return obj.is_expired
class Meta: # type: ignore
model = Payment
fields = "__all__"
class UpdatePaymentSerializer(serializers.ModelSerializer):
class Meta:
class Meta: # type: ignore
model = Payment
fields = [
"number_of_months",
]
class TopupSerializer(serializers.ModelSerializer):
user = serializers.SerializerMethodField()
is_expired = 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
def get_is_expired(self, obj):
return obj.is_expired
class Meta: # type: ignore
model = Topup
fields = [
"id",
"amount",
"user",
"paid",
"paid_at",
"status",
"mib_reference",
"is_expired",
"expires_at",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
+149
View File
@@ -0,0 +1,149 @@
import logging
from django.db import transaction
from django.utils import timezone
from procrastinate.contrib.django import app
from api.notifications import send_sms
from billing.models import Topup, Payment
from django.utils.timezone import localtime
from datetime import datetime
logger = logging.getLogger(__name__)
@app.periodic(
cron="*/1 * * * * *", periodic_id="notify_expired_topups", queue="heavy_tasks"
)
@app.task
def update_expired_topups(timestamp: int):
expired_topups_qs = Topup.objects.filter(
expires_at__lte=timezone.now(),
expiry_notification_sent=False,
paid=False,
).select_related("user")
if not expired_topups_qs.exists():
logger.info("No expired topups found.")
return {"total_expired_topups": 0}
with transaction.atomic():
count = expired_topups_qs.count()
logger.info(f"Found {count} topups to expire.")
for topup in expired_topups_qs:
if topup.user and topup.user.mobile and not topup.expiry_notification_sent:
send_sms_task.defer(
mobile=topup.user.mobile,
type="TOPUP",
amount=topup.amount,
model_id=str(topup.id),
created_at=localtime(topup.created_at).isoformat(),
user=f"{topup.user.first_name + ' ' + topup.user.last_name}"
if topup.user.last_name and topup.user.first_name
else "User",
)
topup.expiry_notification_sent = True
topup.save()
else:
topup.expiry_notification_sent = True
topup.save()
return
return {
"total_expired_topups": count,
}
@app.periodic(
cron="*/1 * * * * *", periodic_id="notify_expired_payments", queue="heavy_tasks"
)
@app.task
def update_expired_payments(timestamp: int):
expired_payments_qs = Payment.objects.filter(
expires_at__lte=timezone.now(),
expiry_notification_sent=False,
paid=False,
).select_related("user")
if not expired_payments_qs.exists():
logger.info("No expired payments found.")
return {"total_expired_payments": 0}
with transaction.atomic():
count = expired_payments_qs.count()
logger.info(f"Found {count} payments to expire.")
for payment in expired_payments_qs:
for device in payment.devices.all():
device.has_a_pending_payment = False
device.save()
if (
payment.user
and payment.user.mobile
and not payment.expiry_notification_sent
):
send_sms_task.defer(
mobile=payment.user.mobile,
type="PAYMENT",
amount=payment.amount,
model_id=str(payment.id),
created_at=localtime(payment.created_at).isoformat(),
user=f"{payment.user.first_name + ' ' + payment.user.last_name}"
if payment.user.last_name and payment.user.first_name
else "User",
)
payment.expiry_notification_sent = True
payment.save()
else:
payment.expiry_notification_sent = True
payment.save()
return
return {
"total_expired_payments": count,
}
@app.task
def send_sms_task(
user: str,
mobile: str,
amount: float,
model_id: str,
created_at: str,
type: str = "TOPUP", # Default to TOPUP if not provided,
):
try:
dt = datetime.fromisoformat(created_at)
formatted_date = dt.strftime("%d %b %Y, %I:%M %p")
except Exception:
formatted_date = created_at
message: str = ""
if type == "TOPUP":
message = (
f"Dear {user}, \n\nYour topup of {amount} MVR [created at {formatted_date}] has expired. "
"Please make a new topup to update your wallet. \n\n- SAR Link"
)
elif type == "PAYMENT":
message = f"Dear {user}, \n\nYour payment of {amount} MVR [created at {formatted_date}] has expired. \n\n- SAR Link"
send_sms(mobile, message)
logger.info(f"SMS sent to {mobile} for expired {type} of {amount} MVR.")
if type == "TOPUP":
try:
topup = Topup.objects.get(id=model_id)
topup.expiry_notification_sent = True
topup.save()
logger.info(f"Marked topup {model_id} as notified.")
except Topup.DoesNotExist:
logger.error(
f"Topup id: {model_id} not found when trying to mark as notified."
)
else:
try:
topup = Payment.objects.get(id=model_id)
topup.expiry_notification_sent = True
topup.save()
logger.info(f"Marked payment {model_id} as notified.")
except Payment.DoesNotExist:
logger.error(
f"Payment id: {model_id} not found when trying to mark as notified."
)
+249 -1
View File
@@ -1,3 +1,251 @@
from django.test import TestCase
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model
from .models import Topup, Payment
from .serializers import TopupSerializer
from django.utils import timezone
from datetime import timedelta
from decouple import config
from unittest import mock
# Create your tests here.
User = get_user_model()
REAL_USER_FIRST_NAME = config("REAL_USER_FIRST_NAME", default="josh")
REAL_USER_LAST_NAME = config("REAL_USER_LAST_NAME", default="mosh")
class TopupTests(TestCase):
def setUp(self):
self.client = APIClient()
self.real_user = User.objects.create_user(
username="testuser",
password="testpassword",
first_name=REAL_USER_FIRST_NAME,
last_name=REAL_USER_LAST_NAME,
acc_no="7770000010629",
is_admin=True,
)
self.user = User.objects.create_user(
username="plskillme",
password="modewasgayithink",
first_name="mode",
last_name="hussain",
acc_no="1122334455",
)
self.admin_user = User.objects.create_superuser(
username="adminuser",
password="adminpassword",
email="admin@example.com",
first_name="Admin",
last_name="User",
acc_no="987654321",
)
self.client.force_authenticate(user=self.real_user)
def test_create_topup(self):
url = reverse("create-list-topups")
data = {"amount": 100.00}
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Topup.objects.count(), 1)
topup = Topup.objects.first()
self.assertEqual(getattr(topup, "amount"), 100.00)
self.assertEqual(getattr(topup, "user"), self.real_user)
def test_create_topup_no_amount(self):
url = reverse("create-list-topups")
data = {}
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_list_topups(self):
Topup.objects.create(amount=50.00, user=self.real_user)
Topup.objects.create(amount=75.00, user=self.real_user)
url = reverse("create-list-topups")
response = self.client.get(url, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["data"]), 2)
def test_list_topups_admin(self):
self.client = APIClient()
self.client.force_authenticate(user=self.admin_user)
Topup.objects.create(amount=50.00, user=self.real_user)
admin_user = User.objects.create_user(
username="anotheruser",
password="testpassword",
first_name="Another",
last_name="User",
acc_no="1122334455",
is_admin=True,
)
Topup.objects.create(amount=75.00, user=admin_user)
url = reverse("create-list-topups")
response = self.client.get(url, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["data"]), 2) # Admin sees all
def test_list_topups_filtered_by_user(self):
other_user = User.objects.create_user(
username="otheruser",
password="testpassword",
first_name="Other",
last_name="User",
acc_no="5544332211",
)
Topup.objects.create(amount=50.00, user=self.user)
Topup.objects.create(amount=75.00, user=other_user)
url = reverse("create-list-topups")
response = self.client.get(url, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["data"]), 2)
@mock.patch("billing.views.localtime")
def test_verify_topup_payment(self, mock_localtime):
fixed_time = timezone.datetime(
2025, 7, 3, 19, 36, tzinfo=timezone.get_current_timezone()
)
mock_localtime.return_value = fixed_time
topup = Topup.objects.create(amount=1.5, user=self.real_user)
url = reverse("verify-topup-payment", kwargs={"pk": topup.pk})
self.client = APIClient()
self.client.force_authenticate(user=self.real_user)
data = {
"benefName": f"{REAL_USER_FIRST_NAME} {REAL_USER_LAST_NAME}",
"accountNo": "7770000010629",
"absAmount": 1.5,
"time": fixed_time.strftime("%Y-%m-%d %H:%M"), # Use the same fixed time
}
response = self.client.patch(url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
topup.refresh_from_db()
self.assertEqual(topup.paid, True)
def test_verify_topup_payment_already_verified(self):
topup = Topup.objects.create(amount=100.00, user=self.real_user, paid=True)
url = reverse("verify-topup-payment", kwargs={"pk": topup.pk})
data = {}
response = self.client.patch(url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_verify_topup_payment_unauthorized(self):
other_user = User.objects.create_user(
username="otheruser",
password="testpassword",
first_name="Other",
last_name="User",
acc_no="5544332211",
)
topup = Topup.objects.create(amount=100.00, user=other_user)
url = reverse("verify-topup-payment", kwargs={"pk": topup.pk})
data = {}
response = self.client.patch(url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_topup_serializer(self):
topup = Topup.objects.create(amount=120.00, user=self.real_user)
serializer = TopupSerializer(topup)
self.assertEqual(serializer.data["amount"], 120.00)
self.assertEqual(serializer.data["user"]["id"], getattr(self.real_user, "id"))
def test_topup_filter_amount(self):
Topup.objects.create(amount=50.00, user=self.real_user)
Topup.objects.create(amount=100.00, user=self.real_user)
url = reverse("create-list-topups") + "?amount_min=75"
response = self.client.get(url, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["data"]), 1)
self.assertEqual(response.json()["data"][0]["amount"], 100.00)
def test_topup_filter_user_search(self):
Topup.objects.create(amount=50.00, user=self.real_user)
other_user = User.objects.create_user(
username="otheruser",
password="testpassword",
first_name="Other",
last_name="User",
id_card="12345",
mobile="1234567890",
acc_no="5544332211",
)
Topup.objects.create(amount=75.00, user=other_user)
url = reverse("create-list-topups") + "?user=Other"
response = self.client.get(url, format="json")
print(response.json())
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["data"]), 1)
self.assertEqual(response.json()["data"][0]["amount"], 75.00)
def test_topup_filter_created_at(self):
now = timezone.now()
Topup.objects.create(
amount=50.00, user=self.real_user, created_at=now - timedelta(days=2)
)
Topup.objects.create(amount=100.00, user=self.real_user, created_at=now)
url = reverse("create-list-topups") + f"?created_at_after={now.date()}"
response = self.client.get(url, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()["data"]), 1)
self.assertEqual(response.json()["data"][0]["amount"], 100.00)
def test_retrieve_single_topup(self):
topup = Topup.objects.create(amount=50.00, user=self.real_user)
url = reverse("retrieve-topup", kwargs={"pk": topup.pk})
response = self.client.get(url, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["amount"], 50.00)
self.assertEqual(response.json()["user"]["id"], getattr(self.real_user, "id"))
def test_delete_topup(self):
topup = Topup.objects.create(amount=50.00, user=self.real_user)
url = reverse("delete-topup", kwargs={"pk": topup.pk})
response = self.client.delete(url, format="json")
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(Topup.objects.count(), 0)
class PaymentTests(TestCase):
def setUp(self):
self.client = APIClient()
self.real_user = User.objects.create_user(
username="testuser",
password="testpassword",
first_name=REAL_USER_FIRST_NAME,
last_name=REAL_USER_LAST_NAME,
acc_no="7770000010629",
is_admin=True,
)
self.user = User.objects.create_user(
username="plskillme",
password="modewasgayithink",
first_name="mode",
last_name="hussain",
acc_no="1122334455",
)
self.admin_user = User.objects.create_superuser(
username="adminuser",
password="adminpassword",
email="admin@example.com",
first_name="Admin",
last_name="User",
acc_no="987654321",
)
self.client.force_authenticate(user=self.real_user)
def test_cancel_payment(self):
payment = Payment.objects.create(
amount=100.00,
user=self.real_user,
status="PENDING",
number_of_months=1,
paid=False,
)
url = reverse("cancel-payment", kwargs={"pk": payment.pk})
response = self.client.patch(url, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
payment.refresh_from_db()
self.assertEqual(payment.status, "CANCELLED")
self.assertFalse(payment.paid)
+21 -4
View File
@@ -5,7 +5,11 @@ from .views import (
VerifyPaymentView,
PaymentDetailAPIView,
UpdatePaymentAPIView,
DeletePaymentView,
CancelPaymentView,
ListCreateTopupView,
VerifyTopupPaymentAPIView,
TopupDetailAPIView,
CancelTopupView,
)
urlpatterns = [
@@ -17,11 +21,24 @@ urlpatterns = [
name="update-payment",
),
path(
"payment/<str:pk>/delete/",
DeletePaymentView.as_view(),
name="delete-payment",
"payment/<str:pk>/cancel/",
CancelPaymentView.as_view(),
name="cancel-payment",
),
path(
"payment/<str:pk>/verify/", VerifyPaymentView.as_view(), name="verify-payment"
),
# Topups
path("topup/", ListCreateTopupView.as_view(), name="create-list-topups"),
path("topup/<str:pk>/", TopupDetailAPIView.as_view(), name="retrieve-topup"),
path(
"topup/<str:pk>/verify/",
VerifyTopupPaymentAPIView.as_view(),
name="verify-topup-payment",
),
path(
"topup/<str:pk>/cancel/",
CancelTopupView.as_view(),
name="cancel-topup",
),
]
+262 -36
View File
@@ -15,9 +15,11 @@ from api.tasks import add_new_devices_to_omada
from apibase.env import BASE_DIR, env
import logging
from .models import Device, Payment
from .serializers import PaymentSerializer, UpdatePaymentSerializer
from .filters import PaymentFilter
from .models import Device, Payment, Topup
from .serializers import PaymentSerializer, UpdatePaymentSerializer, TopupSerializer
from .filters import PaymentFilter, TopupFilter
from dataclasses import dataclass, asdict
from typing import Optional
env.read_env(os.path.join(BASE_DIR, ".env"))
@@ -26,6 +28,20 @@ PAYMENT_BASE_URL = env("PAYMENT_BASE_URL", default="") # type: ignore
logger = logging.getLogger(__name__)
@dataclass
class Transaction:
ref: str
sourceBank: str
trxDate: str
@dataclass
class PaymentVerificationResponse:
message: str
success: bool
transaction: Optional[Transaction] = None
class InsufficientFundsError(Exception):
pass
@@ -50,7 +66,8 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
number_of_months = data.get("number_of_months")
device_ids = data.get("device_ids", [])
print(amount, number_of_months, device_ids)
current_time = timezone.now()
expires_at = current_time + timedelta(minutes=10)
for device_id in device_ids:
device = Device.objects.filter(id=device_id, user=user).first()
print("DEVICE", device)
@@ -75,6 +92,7 @@ class ListCreatePaymentView(StaffEditorPermissionMixin, generics.ListCreateAPIVi
number_of_months=number_of_months,
paid=data.get("paid", False),
user=user,
expires_at=expires_at,
)
# Connect devices to payment
@@ -108,7 +126,7 @@ class UpdatePaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
devices.update(
is_active=True, expiry_date=device_expire_date, has_a_pending_payment=False
is_active=False, expiry_date=device_expire_date, has_a_pending_payment=False
)
return Response(serializer.data)
@@ -143,7 +161,6 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
)
devices = payment.devices.all()
payment_status = False
if method == "WALLET":
if user.wallet_balance < payment.amount: # type: ignore
return Response(
@@ -151,7 +168,7 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
else:
payment_status = self.process_wallet_payment(
self.process_wallet_payment(
user,
payment,
)
@@ -159,15 +176,12 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
data = {
"benefName": f"{user.first_name} {user.last_name}", # type: ignore
"accountNo": user.acc_no, # type: ignore
"absAmount": payment.amount,
"time": localtime(timezone.now() + timedelta(minutes=5)).strftime(
"%Y-%m-%d %H:%M"
),
"absAmount": "{:.2f}".format(payment.amount),
"time": localtime(payment.created_at).strftime("%Y-%m-%d %H:%M"),
}
payment_status = self.verify_transfer_payment(data, payment)
payment_verification_response = self.verify_transfer_payment(data, payment)
if payment_status:
# Update devices
if payment_verification_response.success:
expiry_date = timezone.now() + timedelta(days=30 * payment.number_of_months)
devices.update(
is_active=True,
@@ -175,7 +189,9 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
has_a_pending_payment=False,
registered=True,
)
# Need to add to omada if its a new device and not an existing device
payment.status = "PAID"
payment.save()
# add to omada if its a new device and not an existing device
device_list = []
for device in devices:
device_list.append(
@@ -191,12 +207,22 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
device.save()
return Response(
{"message": f"Payment verified successfully using [{method}]."},
{
"status": payment_verification_response.success,
"message": payment_verification_response.message,
"transaction": asdict(payment_verification_response.transaction)
if payment_verification_response.transaction
else None,
},
status=status.HTTP_200_OK,
)
else:
return Response(
{"message": f"Payment verification FAILED using [{method}]."},
{
"status": payment_verification_response.success,
"message": payment_verification_response.message
or "Topup payment verification failed.",
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -213,7 +239,126 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
user.save()
return True
def verify_transfer_payment(self, data, payment):
def verify_transfer_payment(self, data, payment) -> PaymentVerificationResponse:
if not PAYMENT_BASE_URL:
raise ValueError(
"PAYMENT_BASE_URL is not set. Please set it in your environment variables."
)
response = requests.post(
f"{PAYMENT_BASE_URL}/verify-payment",
json=data,
headers={"Content-Type": "application/json"},
)
logger.info("MIB Verification Response -> ", response)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.error(f"HTTPError: {e}")
return PaymentVerificationResponse(
message="Payment verification failed.", success=False, transaction=None
)
mib_resp = response.json()
logger.info("MIB Verification Response ->", mib_resp)
if not response.json().get("success"):
return PaymentVerificationResponse(
message=mib_resp["message"],
success=mib_resp["success"],
transaction=None,
)
else:
payment.paid = True
payment.paid_at = timezone.now()
payment.method = "TRANSFER"
payment.mib_reference = mib_resp["transaction"]["ref"] or ""
payment.save()
return PaymentVerificationResponse(
message=mib_resp["message"],
success=mib_resp["success"],
transaction=Transaction(
ref=payment.mib_reference,
sourceBank=mib_resp["transaction"]["sourceBank"],
trxDate=mib_resp["transaction"]["trxDate"],
),
)
class CancelPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
queryset = Payment.objects.all()
serializer_class = PaymentSerializer
lookup_field = "pk"
def update(self, request, *args, **kwargs):
instance = self.get_object()
user = request.user
if instance.status == "CANCELLED":
return Response(
{"message": "Payment has already been cancelled."},
status=status.HTTP_400_BAD_REQUEST,
)
if instance.user != user and not user.is_superuser:
return Response(
{"message": "You are not authorized to cancel this payment."},
status=status.HTTP_403_FORBIDDEN,
)
if instance.paid:
return Response(
{"message": "Paid payments cannot be cancelled."},
status=status.HTTP_400_BAD_REQUEST,
)
devices = instance.devices.all()
instance.status = "CANCELLED"
instance.save()
devices.update(is_active=False, expiry_date=None, has_a_pending_payment=False)
return super().update(request, *args, **kwargs)
class ListCreateTopupView(StaffEditorPermissionMixin, generics.ListCreateAPIView):
queryset = Topup.objects.all()
serializer_class = TopupSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = "__all__"
filterset_class = TopupFilter
def create(self, request, *args, **kwargs):
data = request.data
user = request.user
current_time = timezone.now()
expires_at = current_time + timedelta(minutes=10) # Topup expires in 10 minutes
amount = data.get("amount")
if not amount:
return Response(
{"message": "amount is required."},
status=status.HTTP_400_BAD_REQUEST,
)
topup = Topup.objects.create(amount=amount, user=user, expires_at=expires_at)
serializer = TopupSerializer(topup)
return Response(serializer.data, status=status.HTTP_201_CREATED)
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)
class TopupDetailAPIView(StaffEditorPermissionMixin, generics.RetrieveAPIView):
queryset = Topup.objects.all()
serializer_class = TopupSerializer
lookup_field = "pk"
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)
class VerifyTopupPaymentAPIView(StaffEditorPermissionMixin, generics.UpdateAPIView):
queryset = Topup.objects.all()
serializer_class = TopupSerializer
lookup_field = "pk"
def verify_transfer_topup(self, data, topup) -> PaymentVerificationResponse:
if not PAYMENT_BASE_URL:
raise ValueError(
"PAYMENT_BASE_URL is not set. Please set it in your environment variables."
@@ -224,38 +369,119 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
json=data,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.error(f"HTTPError: {e}")
return PaymentVerificationResponse(
message="Payment verification failed.", success=False, transaction=None
)
mib_resp = response.json()
print(mib_resp)
if not response.json().get("success"):
return mib_resp["success"]
return PaymentVerificationResponse(
message=mib_resp["message"],
success=mib_resp["success"],
transaction=None,
)
else:
payment.paid = True
payment.paid_at = timezone.now()
payment.method = "TRANSFER"
payment.mib_reference = mib_resp["transaction"]["ref"] or ""
payment.save()
return True
topup.paid = True
topup.mib_reference = mib_resp["transaction"]["ref"] or ""
topup.paid_at = mib_resp["transaction"]["trxDate"]
topup.save()
return PaymentVerificationResponse(
message=mib_resp["message"],
success=mib_resp["success"],
transaction=Transaction(
ref=topup.mib_reference,
sourceBank=mib_resp["transaction"]["sourceBank"],
trxDate=mib_resp["transaction"]["trxDate"],
),
)
def update(self, request, *args, **kwargs):
topup_instance = self.get_object()
user = request.user
if topup_instance.paid:
return Response(
{"message": "Payment has already been verified."},
status=status.HTTP_400_BAD_REQUEST,
)
if topup_instance.user != user and not user.is_superuser:
return Response(
{"message": "You are not allowed to pay for this topup."},
status=status.HTTP_403_FORBIDDEN,
)
data = {
"benefName": f"{user.first_name} {user.last_name}", # type: ignore
"accountNo": user.acc_no, # type: ignore
"absAmount": "{:.2f}".format(topup_instance.amount),
"time": localtime(topup_instance.created_at).strftime("%Y-%m-%d %H:%M"),
}
logger.info(
f"Verifying topup payment created at {localtime(topup_instance.created_at)} with data: {data}"
)
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.save()
topup_instance.status = "PAID"
topup_instance.save()
return Response(
{
"status": topup_verification_response.success,
"message": topup_verification_response.message,
"transaction": asdict(topup_verification_response.transaction)
if topup_verification_response.transaction
else None,
},
status=status.HTTP_200_OK,
)
else:
return Response(
{
"status": topup_verification_response.success,
"message": topup_verification_response.message
or "Topup payment verification failed.",
},
status=status.HTTP_400_BAD_REQUEST,
)
class DeletePaymentView(StaffEditorPermissionMixin, generics.DestroyAPIView):
queryset = Payment.objects.all()
serializer_class = PaymentSerializer
class CancelTopupView(StaffEditorPermissionMixin, generics.UpdateAPIView):
queryset = Topup.objects.all().select_related("user")
serializer_class = TopupSerializer
lookup_field = "pk"
def delete(self, request, *args, **kwargs):
def update(self, request, *args, **kwargs):
instance = self.get_object()
user = request.user
if instance.user != user and not user.is_superuser:
if instance.status == "CANCELLED":
return Response(
{"message": "You are not authorized to delete this payment."},
{"message": "Topup has already been cancelled."},
status=status.HTTP_400_BAD_REQUEST,
)
if instance.is_expired:
return Response(
{"message": "Expired topups cannot be cancelled."},
status=status.HTTP_400_BAD_REQUEST,
)
if (
instance.user != user
and getattr(user, "is_admin")
and not user.is_superuser
):
return Response(
{"message": "You are not authorized to delete this topup."},
status=status.HTTP_403_FORBIDDEN,
)
if instance.paid:
return Response(
{"message": "Paid payments cannot be deleted."},
{"message": "Paid topups cannot be deleted."},
status=status.HTTP_400_BAD_REQUEST,
)
devices = instance.devices.all()
devices.update(is_active=False, expiry_date=None, has_a_pending_payment=False)
return super().delete(request, *args, **kwargs)
instance.status = "CANCELLED"
instance.save()
return super().update(request, *args, **kwargs)
+1
View File
@@ -10,6 +10,7 @@ class DeviceAdmin(admin.ModelAdmin):
"user",
"mac",
"vendor",
"expiry_date",
"blocked_by",
"name",
"created_at",
+13 -3
View File
@@ -1,14 +1,24 @@
import django_filters
from .models import Device
from django.db.models import Q
class DeviceFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr="icontains")
mac = django_filters.CharFilter(lookup_expr="icontains")
vendor = django_filters.CharFilter(lookup_expr="icontains")
user = django_filters.CharFilter(
field_name="user__last_name", lookup_expr="icontains"
)
user = django_filters.CharFilter(method="filter_user_search")
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 = Device
+9 -10
View File
@@ -31,8 +31,16 @@ class BlockDeviceSerializer(serializers.ModelSerializer):
class DeviceSerializer(serializers.ModelSerializer):
pending_payment_id = serializers.SerializerMethodField()
user = serializers.SerializerMethodField()
pending_payment_id = serializers.SerializerMethodField()
def get_pending_payment_id(self, obj):
unpaid_payment = (
Payment.objects.filter(devices=obj, paid=False)
.order_by("-created_at")
.first()
)
return unpaid_payment.id if unpaid_payment else None
def get_user(self, obj):
user = obj.user
@@ -45,15 +53,6 @@ class DeviceSerializer(serializers.ModelSerializer):
}
return None
def get_pending_payment_id(self, obj):
# Query the last unpaid payment for the device
unpaid_payment = (
Payment.objects.filter(devices=obj, paid=False)
.order_by("-created_at")
.first()
)
return unpaid_payment.id if unpaid_payment else None
class Meta: # type: ignore
model = Device
fields = "__all__"
+7 -3
View File
@@ -30,11 +30,15 @@ class DeviceListCreateAPIView(
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
all_devices = request.query_params.get("all_devices", None)
all_devices = request.query_params.get("all_devices", "false").lower() in [
"true",
"1",
"yes",
]
if (
request.user.is_authenticated
and getattr(request.user, "is_admin", False)
and all_devices
and getattr(request.user, "is_admin")
and bool(all_devices)
):
pass
else:
+14
View File
@@ -4,3 +4,17 @@ default:
dev:
python manage.py runserver
python manage.py procrastinate worker
migrate:
python manage.py migrate
make-migrations:
python manage.py makemigrations
seed-topups:
python manage.py seed_topups --number=50
seed-payments:
python manage.py seed_payments --number=50
# TESTS
test-billing:
python manage.py test billing